Skip to content

Commit da1eaee

Browse files
authored
Add support for a subset of qualified method syntax introduced in Clojure 1.12 (#1117)
Fixes #1109 References: * [Qualified method references](https://clojure.org/reference/java_interop) in Clojure's Java Interop documentation * [Clojure 1.12 release notes](https://clojure.org/news/2024/09/05/clojure-1-12-0#qualified_methods) * [CLJ-2806](https://clojure.atlassian.net/browse/CLJ-2806)
1 parent 083f574 commit da1eaee

File tree

7 files changed

+113
-17
lines changed

7 files changed

+113
-17
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Added
9+
* Added support for a subset of qualified method syntax introduced in Clojure 1.12 (#1109)
10+
811
### Fixed
912
* Fix a bug where tags in data readers were resolved as Vars within syntax quotes, rather than using standard data readers rules (#1129)
1013
* Fix a bug where `keyword` and `symbol` functions did not treat string arguments as potentially namespaced (#1131)

docs/differencesfromclojure.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ Host interoperability features generally match those of Clojure.
182182

183183
* :lpy:fn:`new` is a macro for Clojure compatibility, as the ``new`` keyword is not required for constructing new objects in Python.
184184
* `Python builtins <https://docs.python.org/3/library/functions.html>`_ are available under the special namespace ``python`` (as ``python/abs``, for instance) without requiring an import.
185+
* The qualified constructor form ``Classname/new`` introduced in Clojure 1.12 is not supported, because ``new`` is a valid Python method identifier unlike in Java.
186+
* Qualified methods may be referenced with or without a leading ``.`` character regardless of whether they are static, class, or instance methods.
185187

186188
.. seealso::
187189

docs/pyinterop.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,20 @@ As a convenience, Basilisp offers a more compact syntax for method names known a
116116
117117
(.strftime now "%Y-%m-%d") ;;=> "2020-03-31"
118118
119+
Basilisp also supports the "qualified method" syntax introduced in Clojure 1.12, albeit with fewer restrictions than the Clojure implementation.
120+
In particular, there is no distinction between instance and static (or class) methods in syntax -- instance methods need not be prefixed with a leading ``.`` nor is it an error to prefix a static or class method with a leading ``.``.
121+
Static and class methods typically do not take an instance of their class as the first argument, so the distinction should already be clear by usage.
122+
123+
.. code-block:: clojure
124+
125+
;; Python str instance method str.split()
126+
(python.str/split "a b c") ;;=> #py ["a" "b" "c"]
127+
(python.str/.split "a b c") ;;=> #py ["a" "b" "c"]
128+
129+
;; Python int classmethod int.from_bytes()
130+
(python.int/from_bytes #b"\x00\x10") ;;=> 16
131+
(python.int/.from_bytes #b"\x00\x10") ;;=> 16
132+
119133
In Python, objects often expose properties which can be read directly from the instance.
120134
To read properties from the instance, you can use the ``(.- object property)`` syntax.
121135

src/basilisp/lang/compiler/analyzer.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3468,13 +3468,15 @@ def _list_node(form: ISeq, ctx: AnalyzerContext) -> Node:
34683468
return handle_special_form(form, ctx)
34693469
elif s.name.startswith(".-"):
34703470
return _host_prop_ast(form, ctx)
3471-
elif s.name.startswith(".") and s.name != _DOUBLE_DOT_MACRO_NAME:
3471+
elif (
3472+
s.name.startswith(".") and s.ns is None and s.name != _DOUBLE_DOT_MACRO_NAME
3473+
):
34723474
return _host_call_ast(form, ctx)
34733475

34743476
return _invoke_ast(form, ctx)
34753477

34763478

3477-
def _resolve_nested_symbol(ctx: AnalyzerContext, form: sym.Symbol) -> HostField:
3479+
def __resolve_nested_symbol(ctx: AnalyzerContext, form: sym.Symbol) -> HostField:
34783480
"""Resolve an attribute by recursively accessing the parent object
34793481
as if it were its own namespaced symbol."""
34803482
assert form.ns is not None
@@ -3580,6 +3582,23 @@ def __resolve_namespaced_symbol( # pylint: disable=too-many-branches # noqa: M
35803582
ctx: AnalyzerContext, form: sym.Symbol
35813583
) -> Union[Const, HostField, MaybeClass, MaybeHostForm, VarRef]:
35823584
"""Resolve a namespaced symbol into a Python name or Basilisp Var."""
3585+
# Support Clojure 1.12 qualified method names
3586+
#
3587+
# Does not discriminate between static/class and instance methods (the latter of
3588+
# which must include a leading `.` in Clojure). In Basilisp it was always possible
3589+
# to access object methods (static or otherwise) using the `Classname/field` form
3590+
# because Python objects are essentially just fancy dicts. It is possible to call
3591+
# Python methods by referencing the method directly on the class with the instance
3592+
# as an argument:
3593+
#
3594+
# "a b c".split() == str.split("a b c") # => ["a", "b", "c"]
3595+
#
3596+
# Basilisp supported this from the beginning:
3597+
#
3598+
# (python.str/split "a b c") ;;=> #py ["a" "b" "c"]
3599+
if form.name.startswith(".") and form.name != _DOUBLE_DOT_MACRO_NAME:
3600+
form = sym.symbol(form.name[1:], ns=form.ns, meta=form.meta)
3601+
35833602
assert form.ns is not None
35843603

35853604
current_ns = ctx.current_ns
@@ -3632,7 +3651,7 @@ def __resolve_namespaced_symbol( # pylint: disable=too-many-branches # noqa: M
36323651

36333652
if "." in form.ns:
36343653
try:
3635-
return _resolve_nested_symbol(ctx, form)
3654+
return __resolve_nested_symbol(ctx, form)
36363655
except CompilerException:
36373656
raise ctx.AnalyzerException(
36383657
f"unable to resolve symbol '{form}' in this context", form=form

src/basilisp/lang/reader.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,8 +1054,6 @@ def _read_sym(ctx: ReaderContext, is_reader_macro_sym: bool = False) -> MaybeSym
10541054
"All '.' separated segments of a namespace "
10551055
"must contain at least one character."
10561056
)
1057-
if name.startswith(".") and ns is not None:
1058-
raise ctx.syntax_error("Symbols starting with '.' may not have a namespace")
10591057
if ns is None:
10601058
if name == "nil":
10611059
return None
@@ -1065,6 +1063,8 @@ def _read_sym(ctx: ReaderContext, is_reader_macro_sym: bool = False) -> MaybeSym
10651063
return False
10661064
elif name == "&":
10671065
return _AMPERSAND
1066+
elif name.startswith("."):
1067+
return sym.symbol(name)
10681068
if ctx.is_syntax_quoted and not name.endswith("#") and not is_reader_macro_sym:
10691069
return ctx.resolve(sym.symbol(name, ns))
10701070
return sym.symbol(name, ns=ns)

tests/basilisp/compiler_test.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3652,6 +3652,24 @@ def test_interop_call(self, lcompile: CompileFn):
36523652
assert "example" == lcompile('(. "www.example.com" (strip "cmowz."))')
36533653
assert "example" == lcompile('(. "www.example.com" strip "cmowz.")')
36543654

3655+
@pytest.mark.parametrize(
3656+
"code,v",
3657+
[
3658+
('(python.str/split "a b c")', ["a", "b", "c"]),
3659+
('(python.str/.split "a b c")', ["a", "b", "c"]),
3660+
(
3661+
'(python.int/from_bytes #b"\\x00\\x10" #?@(:lpy310- [** :byteorder "big"]))',
3662+
16,
3663+
),
3664+
(
3665+
'(python.int/.from_bytes #b"\\x00\\x10" #?@(:lpy310- [** :byteorder "big"]))',
3666+
16,
3667+
),
3668+
],
3669+
)
3670+
def test_interop_qualified_method(self, lcompile: CompileFn, code: str, v):
3671+
assert v == lcompile(code)
3672+
36553673
def test_interop_prop_field_is_symbol(self, lcompile: CompileFn):
36563674
with pytest.raises(compiler.CompilerException):
36573675
lcompile("(.- 'some.ns/sym :ns)")
@@ -6124,6 +6142,18 @@ def test_nested_bare_sym_will_not_resolve(self, lcompile: CompileFn):
61246142
with pytest.raises(compiler.CompilerException):
61256143
lcompile("basilisp.lang.map.MapEntry.of")
61266144

6145+
@pytest.mark.parametrize(
6146+
"code,v",
6147+
[
6148+
("python.str/split", str.split),
6149+
("python.str/.split", str.split),
6150+
("python.int/from_bytes", int.from_bytes),
6151+
("python.int/.from_bytes", int.from_bytes),
6152+
],
6153+
)
6154+
def test_qualified_method_resolves(self, lcompile: CompileFn, code: str, v):
6155+
assert v == lcompile(code)
6156+
61276157
@pytest.mark.parametrize(
61286158
"code",
61296159
[

tests/basilisp/reader_test.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,7 @@ class TestKeyword:
600600
("a:b", ":a:b"),
601601
],
602602
)
603-
def test_legal_bare_symbol(self, v: str, raw: str):
603+
def test_legal_bare_keyword(self, v: str, raw: str):
604604
assert kw.keyword(v) == read_str_first(raw)
605605

606606
@pytest.mark.parametrize(
@@ -613,13 +613,13 @@ def test_legal_bare_symbol(self, v: str, raw: str):
613613
("a:b", "a:b", ":a:b/a:b"),
614614
],
615615
)
616-
def test_legal_ns_symbol(self, k: str, ns: str, raw: str):
616+
def test_legal_ns_keyword(self, k: str, ns: str, raw: str):
617617
assert kw.keyword(k, ns=ns) == read_str_first(raw)
618618

619619
@pytest.mark.parametrize(
620620
"v", ["://", ":ns//kw", ":some/ns/sym", ":ns/sym/", ":/kw"]
621621
)
622-
def test_illegal_symbol(self, v: str):
622+
def test_illegal_keyword(self, v: str):
623623
with pytest.raises(reader.SyntaxError):
624624
read_str_first(v)
625625

@@ -679,6 +679,7 @@ def test_legal_bare_symbol(self, s: str):
679679
("sym", "ns", "ns/sym"),
680680
("sym", "qualified.ns", "qualified.ns/sym"),
681681
("sym", "really.qualified.ns", "really.qualified.ns/sym"),
682+
(".interop", "ns.second", "ns.second/.interop"),
682683
("sy:m", "ns", "ns/sy:m"),
683684
("sy:m", "n:s", "n:s/sy:m"),
684685
],
@@ -696,7 +697,6 @@ def test_legal_ns_symbol(self, s: str, ns: str, raw: str):
696697
"/sym",
697698
".second.ns/name",
698699
"ns..third/name",
699-
"ns.second/.interop",
700700
# This will raise because the default pushback depth of the
701701
# reader.StreamReader instance used by the reader is 5, so
702702
# we are unable to pushback more - characters consumed by
@@ -1161,15 +1161,43 @@ def test_reader_var_macro_works_with_unquote(self):
11611161
),
11621162
) == read_str_first("`(#'~'a-symbol)"), "Reader var macro works with unquote"
11631163

1164-
def test_do_not_resolve_unnamespaced_ampersand(self):
1165-
assert llist.l(sym.symbol("quote"), sym.symbol("&")) == read_str_first(
1166-
"`&"
1167-
), "do not resolve the not namespaced ampersand"
1164+
@pytest.mark.parametrize(
1165+
"code,v",
1166+
[
1167+
("`&", llist.l(sym.symbol("quote"), sym.symbol("&"))),
1168+
("`nil", None),
1169+
("`true", True),
1170+
("`false", False),
1171+
("`.interop", llist.l(sym.symbol("quote"), sym.symbol(".interop"))),
1172+
],
1173+
)
1174+
def test_do_not_resolve_unnamespaced_special_symbols(self, code: str, v):
1175+
assert v == read_str_first(code)
11681176

1169-
def test_resolve_namespaced_ampersand(self):
1170-
assert llist.l(
1171-
sym.symbol("quote"), sym.symbol("&", ns="test-ns")
1172-
) == read_str_first("`test-ns/&"), "resolve fq namespaced ampersand"
1177+
@pytest.mark.parametrize(
1178+
"code,v",
1179+
[
1180+
("`test-ns/&", llist.l(sym.symbol("quote"), sym.symbol("&", ns="test-ns"))),
1181+
(
1182+
"`test-ns/nil",
1183+
llist.l(sym.symbol("quote"), sym.symbol("nil", ns="test-ns")),
1184+
),
1185+
(
1186+
"`test-ns/true",
1187+
llist.l(sym.symbol("quote"), sym.symbol("true", ns="test-ns")),
1188+
),
1189+
(
1190+
"`test-ns/false",
1191+
llist.l(sym.symbol("quote"), sym.symbol("false", ns="test-ns")),
1192+
),
1193+
(
1194+
"`test-ns/.interop",
1195+
llist.l(sym.symbol("quote"), sym.symbol(".interop", ns="test-ns")),
1196+
),
1197+
],
1198+
)
1199+
def test_resolve_namespaced_special_symbols(self, code: str, v):
1200+
assert v == read_str_first(code)
11731201

11741202
@pytest.mark.parametrize(
11751203
"code,v",

0 commit comments

Comments
 (0)