Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs/differencesfromclojure.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://docs.python.org/3/library/functions.html>`_ 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::

Expand Down
14 changes: 14 additions & 0 deletions docs/pyinterop.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
25 changes: 22 additions & 3 deletions src/basilisp/lang/compiler/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/basilisp/lang/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions tests/basilisp/compiler_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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",
[
Expand Down
52 changes: 40 additions & 12 deletions tests/basilisp/reader_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)

Expand Down Expand Up @@ -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"),
],
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down