diff --git a/CHANGELOG.md b/CHANGELOG.md index 6933087e3..c66a2ca7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + * Added support for a subset of qualified method syntax introduced in Clojure 1.12 (#1109) + ### Fixed * Fix a bug where tags in data readers were resolved as Vars within syntax quotes, rather than using standard data readers rules (#1129) * Fix a bug where `keyword` and `symbol` functions did not treat string arguments as potentially namespaced (#1131) diff --git a/docs/differencesfromclojure.rst b/docs/differencesfromclojure.rst index e6696814b..b5a18a99d 100644 --- a/docs/differencesfromclojure.rst +++ b/docs/differencesfromclojure.rst @@ -182,6 +182,8 @@ Host interoperability features generally match those of Clojure. * :lpy:fn:`new` is a macro for Clojure compatibility, as the ``new`` keyword is not required for constructing new objects in Python. * `Python builtins `_ are available under the special namespace ``python`` (as ``python/abs``, for instance) without requiring an import. +* 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. +* Qualified methods may be referenced with or without a leading ``.`` character regardless of whether they are static, class, or instance methods. .. seealso:: diff --git a/docs/pyinterop.rst b/docs/pyinterop.rst index faa01a512..280e53996 100644 --- a/docs/pyinterop.rst +++ b/docs/pyinterop.rst @@ -116,6 +116,20 @@ As a convenience, Basilisp offers a more compact syntax for method names known a (.strftime now "%Y-%m-%d") ;;=> "2020-03-31" +Basilisp also supports the "qualified method" syntax introduced in Clojure 1.12, albeit with fewer restrictions than the Clojure implementation. +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 ``.``. +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. + +.. code-block:: clojure + + ;; Python str instance method str.split() + (python.str/split "a b c") ;;=> #py ["a" "b" "c"] + (python.str/.split "a b c") ;;=> #py ["a" "b" "c"] + + ;; Python int classmethod int.from_bytes() + (python.int/from_bytes #b"\x00\x10") ;;=> 16 + (python.int/.from_bytes #b"\x00\x10") ;;=> 16 + In Python, objects often expose properties which can be read directly from the instance. To read properties from the instance, you can use the ``(.- object property)`` syntax. diff --git a/src/basilisp/lang/compiler/analyzer.py b/src/basilisp/lang/compiler/analyzer.py index 33413b8d2..1ff3a86fa 100644 --- a/src/basilisp/lang/compiler/analyzer.py +++ b/src/basilisp/lang/compiler/analyzer.py @@ -3468,13 +3468,15 @@ def _list_node(form: ISeq, ctx: AnalyzerContext) -> Node: return handle_special_form(form, ctx) elif s.name.startswith(".-"): return _host_prop_ast(form, ctx) - elif s.name.startswith(".") and s.name != _DOUBLE_DOT_MACRO_NAME: + elif ( + s.name.startswith(".") and s.ns is None and s.name != _DOUBLE_DOT_MACRO_NAME + ): return _host_call_ast(form, ctx) return _invoke_ast(form, ctx) -def _resolve_nested_symbol(ctx: AnalyzerContext, form: sym.Symbol) -> HostField: +def __resolve_nested_symbol(ctx: AnalyzerContext, form: sym.Symbol) -> HostField: """Resolve an attribute by recursively accessing the parent object as if it were its own namespaced symbol.""" assert form.ns is not None @@ -3580,6 +3582,23 @@ def __resolve_namespaced_symbol( # pylint: disable=too-many-branches # noqa: M ctx: AnalyzerContext, form: sym.Symbol ) -> Union[Const, HostField, MaybeClass, MaybeHostForm, VarRef]: """Resolve a namespaced symbol into a Python name or Basilisp Var.""" + # Support Clojure 1.12 qualified method names + # + # Does not discriminate between static/class and instance methods (the latter of + # which must include a leading `.` in Clojure). In Basilisp it was always possible + # to access object methods (static or otherwise) using the `Classname/field` form + # because Python objects are essentially just fancy dicts. It is possible to call + # Python methods by referencing the method directly on the class with the instance + # as an argument: + # + # "a b c".split() == str.split("a b c") # => ["a", "b", "c"] + # + # Basilisp supported this from the beginning: + # + # (python.str/split "a b c") ;;=> #py ["a" "b" "c"] + if form.name.startswith(".") and form.name != _DOUBLE_DOT_MACRO_NAME: + form = sym.symbol(form.name[1:], ns=form.ns, meta=form.meta) + assert form.ns is not None current_ns = ctx.current_ns @@ -3632,7 +3651,7 @@ def __resolve_namespaced_symbol( # pylint: disable=too-many-branches # noqa: M if "." in form.ns: try: - return _resolve_nested_symbol(ctx, form) + return __resolve_nested_symbol(ctx, form) except CompilerException: raise ctx.AnalyzerException( f"unable to resolve symbol '{form}' in this context", form=form diff --git a/src/basilisp/lang/reader.py b/src/basilisp/lang/reader.py index 0cd457a8a..6a675d4d8 100644 --- a/src/basilisp/lang/reader.py +++ b/src/basilisp/lang/reader.py @@ -1054,8 +1054,6 @@ def _read_sym(ctx: ReaderContext, is_reader_macro_sym: bool = False) -> MaybeSym "All '.' separated segments of a namespace " "must contain at least one character." ) - if name.startswith(".") and ns is not None: - raise ctx.syntax_error("Symbols starting with '.' may not have a namespace") if ns is None: if name == "nil": return None @@ -1065,6 +1063,8 @@ def _read_sym(ctx: ReaderContext, is_reader_macro_sym: bool = False) -> MaybeSym return False elif name == "&": return _AMPERSAND + elif name.startswith("."): + return sym.symbol(name) if ctx.is_syntax_quoted and not name.endswith("#") and not is_reader_macro_sym: return ctx.resolve(sym.symbol(name, ns)) return sym.symbol(name, ns=ns) diff --git a/tests/basilisp/compiler_test.py b/tests/basilisp/compiler_test.py index 3806acc46..751cf8fc3 100644 --- a/tests/basilisp/compiler_test.py +++ b/tests/basilisp/compiler_test.py @@ -3652,6 +3652,24 @@ def test_interop_call(self, lcompile: CompileFn): assert "example" == lcompile('(. "www.example.com" (strip "cmowz."))') assert "example" == lcompile('(. "www.example.com" strip "cmowz.")') + @pytest.mark.parametrize( + "code,v", + [ + ('(python.str/split "a b c")', ["a", "b", "c"]), + ('(python.str/.split "a b c")', ["a", "b", "c"]), + ( + '(python.int/from_bytes #b"\\x00\\x10" #?@(:lpy310- [** :byteorder "big"]))', + 16, + ), + ( + '(python.int/.from_bytes #b"\\x00\\x10" #?@(:lpy310- [** :byteorder "big"]))', + 16, + ), + ], + ) + def test_interop_qualified_method(self, lcompile: CompileFn, code: str, v): + assert v == lcompile(code) + def test_interop_prop_field_is_symbol(self, lcompile: CompileFn): with pytest.raises(compiler.CompilerException): lcompile("(.- 'some.ns/sym :ns)") @@ -6124,6 +6142,18 @@ def test_nested_bare_sym_will_not_resolve(self, lcompile: CompileFn): with pytest.raises(compiler.CompilerException): lcompile("basilisp.lang.map.MapEntry.of") + @pytest.mark.parametrize( + "code,v", + [ + ("python.str/split", str.split), + ("python.str/.split", str.split), + ("python.int/from_bytes", int.from_bytes), + ("python.int/.from_bytes", int.from_bytes), + ], + ) + def test_qualified_method_resolves(self, lcompile: CompileFn, code: str, v): + assert v == lcompile(code) + @pytest.mark.parametrize( "code", [ diff --git a/tests/basilisp/reader_test.py b/tests/basilisp/reader_test.py index 26f867931..8c53232ef 100644 --- a/tests/basilisp/reader_test.py +++ b/tests/basilisp/reader_test.py @@ -600,7 +600,7 @@ class TestKeyword: ("a:b", ":a:b"), ], ) - def test_legal_bare_symbol(self, v: str, raw: str): + def test_legal_bare_keyword(self, v: str, raw: str): assert kw.keyword(v) == read_str_first(raw) @pytest.mark.parametrize( @@ -613,13 +613,13 @@ def test_legal_bare_symbol(self, v: str, raw: str): ("a:b", "a:b", ":a:b/a:b"), ], ) - def test_legal_ns_symbol(self, k: str, ns: str, raw: str): + def test_legal_ns_keyword(self, k: str, ns: str, raw: str): assert kw.keyword(k, ns=ns) == read_str_first(raw) @pytest.mark.parametrize( "v", ["://", ":ns//kw", ":some/ns/sym", ":ns/sym/", ":/kw"] ) - def test_illegal_symbol(self, v: str): + def test_illegal_keyword(self, v: str): with pytest.raises(reader.SyntaxError): read_str_first(v) @@ -679,6 +679,7 @@ def test_legal_bare_symbol(self, s: str): ("sym", "ns", "ns/sym"), ("sym", "qualified.ns", "qualified.ns/sym"), ("sym", "really.qualified.ns", "really.qualified.ns/sym"), + (".interop", "ns.second", "ns.second/.interop"), ("sy:m", "ns", "ns/sy:m"), ("sy:m", "n:s", "n:s/sy:m"), ], @@ -696,7 +697,6 @@ def test_legal_ns_symbol(self, s: str, ns: str, raw: str): "/sym", ".second.ns/name", "ns..third/name", - "ns.second/.interop", # This will raise because the default pushback depth of the # reader.StreamReader instance used by the reader is 5, so # we are unable to pushback more - characters consumed by @@ -1161,15 +1161,43 @@ def test_reader_var_macro_works_with_unquote(self): ), ) == read_str_first("`(#'~'a-symbol)"), "Reader var macro works with unquote" - def test_do_not_resolve_unnamespaced_ampersand(self): - assert llist.l(sym.symbol("quote"), sym.symbol("&")) == read_str_first( - "`&" - ), "do not resolve the not namespaced ampersand" + @pytest.mark.parametrize( + "code,v", + [ + ("`&", llist.l(sym.symbol("quote"), sym.symbol("&"))), + ("`nil", None), + ("`true", True), + ("`false", False), + ("`.interop", llist.l(sym.symbol("quote"), sym.symbol(".interop"))), + ], + ) + def test_do_not_resolve_unnamespaced_special_symbols(self, code: str, v): + assert v == read_str_first(code) - def test_resolve_namespaced_ampersand(self): - assert llist.l( - sym.symbol("quote"), sym.symbol("&", ns="test-ns") - ) == read_str_first("`test-ns/&"), "resolve fq namespaced ampersand" + @pytest.mark.parametrize( + "code,v", + [ + ("`test-ns/&", llist.l(sym.symbol("quote"), sym.symbol("&", ns="test-ns"))), + ( + "`test-ns/nil", + llist.l(sym.symbol("quote"), sym.symbol("nil", ns="test-ns")), + ), + ( + "`test-ns/true", + llist.l(sym.symbol("quote"), sym.symbol("true", ns="test-ns")), + ), + ( + "`test-ns/false", + llist.l(sym.symbol("quote"), sym.symbol("false", ns="test-ns")), + ), + ( + "`test-ns/.interop", + llist.l(sym.symbol("quote"), sym.symbol(".interop", ns="test-ns")), + ), + ], + ) + def test_resolve_namespaced_special_symbols(self, code: str, v): + assert v == read_str_first(code) @pytest.mark.parametrize( "code,v",