mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 23:49:14 +00:00
fix(scala): scope given...with members to the given instance (#4130)
A Scala 3 `given ... with` instance's `def`s leaked to file scope, where `show` (cased to `_show`) collided with the trait TYPE node `Show`, so the given's call/reference edges landed on the trait. Treat `given_definition` as a class-like container with a `with_template_body`, and synthesize a stable name for an anonymous given (`given Show[Int] with`) from its implemented type so its members are scoped instead of dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
177d2071e5
commit
aa4564378a
3 files changed
+130
-5
No files matched your search
+7
-3
@@ -1536,7 +1536,10 @@ _SCALA_CONFIG = LanguageConfig(
|
||||
# Scala 3 `enum` is a class-like container too: it owns methods and a set of
|
||||
# cases, so it needs a node and a body walk like the others (its cases are
|
||||
# emitted by _scala_extra_walk, the parity of Java #1719 / Kotlin #1738).
|
||||
class_types=frozenset({"class_definition", "object_definition", "trait_definition", "enum_definition"}),
|
||||
# A Scala 3 `given ... with` instance is a class-like container: its `def`s
|
||||
# belong to the given instance, not to file scope (where they leak and collide
|
||||
# with same-named trait methods, #4130). Its body is a `with_template_body`.
|
||||
class_types=frozenset({"class_definition", "object_definition", "trait_definition", "enum_definition", "given_definition"}),
|
||||
# `function_declaration` is a bodyless `def area: Double` — a deferred
|
||||
# (abstract) method. In a `trait` or `abstract class` it is the contract a
|
||||
# subclass must implement, exactly like Java/C#/TS abstract methods. Only
|
||||
@@ -1551,8 +1554,9 @@ _SCALA_CONFIG = LanguageConfig(
|
||||
call_accessor_field="field",
|
||||
name_fallback_child_types=("identifier",),
|
||||
# an enum wraps its members in `enum_body` rather than a `template_body`,
|
||||
# so the body walk needs it to reach the enum's methods and cases.
|
||||
body_fallback_child_types=("template_body", "enum_body"),
|
||||
# so the body walk needs it to reach the enum's methods and cases; a
|
||||
# `given ... with` instance wraps its members in a `with_template_body`.
|
||||
body_fallback_child_types=("template_body", "enum_body", "with_template_body"),
|
||||
function_boundary_types=frozenset({"function_definition"}),
|
||||
import_handler=_import_scala,
|
||||
)
|
||||
|
||||
@@ -1272,6 +1272,32 @@ def _cpp_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[s
|
||||
if c.is_named:
|
||||
_cpp_collect_type_refs(c, source, generic, out)
|
||||
|
||||
def _scala_given_synthetic_name(node, source: bytes) -> str | None:
|
||||
"""A stable label for an anonymous Scala 3 given (`given Show[Int] with`).
|
||||
|
||||
Derived from the implemented type's base name so the given instance gets its
|
||||
own scope (`given Show` -> id `..._given_show`), distinct from the trait type
|
||||
node `Show` (`..._show`); otherwise its members would be dropped or leak to
|
||||
file scope (#4130). Deterministic (no line numbers): two anonymous givens for
|
||||
the same type in one file are a Scala ambiguity error, so no real collision.
|
||||
"""
|
||||
for c in node.children:
|
||||
base = None
|
||||
if c.type == "generic_type":
|
||||
base = next(
|
||||
(cc for cc in c.children
|
||||
if cc.type in ("type_identifier", "stable_type_identifier", "identifier")),
|
||||
None,
|
||||
)
|
||||
elif c.type in ("type_identifier", "stable_type_identifier"):
|
||||
base = c
|
||||
if base is not None:
|
||||
text = _read_text(base, source).strip()
|
||||
if text:
|
||||
return f"given {text}"
|
||||
return "given"
|
||||
|
||||
|
||||
def _scala_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
|
||||
"""Walk a Scala type expression; append (name, role) tuples.
|
||||
Handles type_identifier, generic_type (List[T]), and common type wrappers."""
|
||||
@@ -4924,9 +4950,17 @@ def _extract_generic(
|
||||
if child.type in config.name_fallback_child_types:
|
||||
name_node = child
|
||||
break
|
||||
synthetic_name = None
|
||||
if not name_node:
|
||||
return
|
||||
class_name = _read_text(name_node, source)
|
||||
# A Scala 3 anonymous given (`given Show[Int] with`) has no
|
||||
# identifier child. Synthesize a stable name from the implemented
|
||||
# type so its members stay scoped to the given instead of being
|
||||
# dropped (or leaking to file scope) (#4130).
|
||||
if config.ts_module == "tree_sitter_scala" and t == "given_definition":
|
||||
synthetic_name = _scala_given_synthetic_name(node, source)
|
||||
if not synthetic_name:
|
||||
return
|
||||
class_name = synthetic_name if synthetic_name else _read_text(name_node, source)
|
||||
ruby_absolute_declaration = (
|
||||
config.ts_module == "tree_sitter_ruby" and class_name.startswith("::")
|
||||
)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Scala 3 `given ... with` members are scoped to the given, not file scope (#4130).
|
||||
|
||||
A `given intShow: Show[Int] with` instance owns its `def`s. They used to leak to
|
||||
file scope, where `show` (cased to `_show`) collided with the trait TYPE node
|
||||
`Show` — so the given's call/reference edges landed on the trait. The given is now
|
||||
a class-like container; an anonymous `given Show[String] with` gets a synthesized
|
||||
name so its members are kept (scoped) instead of dropped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.extract import extract_scala
|
||||
|
||||
SRC = (
|
||||
"trait Show[A]:\n"
|
||||
" def show(a: A): String\n"
|
||||
"\n"
|
||||
"given intShow: Show[Int] with\n"
|
||||
" def show(a: Int): String = helper(a)\n"
|
||||
" def helper(a: Int): String = a.toString\n"
|
||||
"\n"
|
||||
"given Show[String] with\n"
|
||||
" def show(a: String): String = a\n"
|
||||
)
|
||||
|
||||
|
||||
def _graph(tmp_path):
|
||||
f = tmp_path / "given.scala"
|
||||
f.write_text(SRC, encoding="utf-8")
|
||||
r = extract_scala(f)
|
||||
label = {n["id"]: str(n.get("label", "")) for n in r["nodes"]}
|
||||
return r, label
|
||||
|
||||
|
||||
def _method(r, label, owner, method):
|
||||
return any(
|
||||
e.get("relation") == "method"
|
||||
and label.get(e.get("source")) == owner
|
||||
and label.get(e.get("target")) == method
|
||||
for e in r["edges"]
|
||||
)
|
||||
|
||||
|
||||
def test_named_given_owns_its_methods(tmp_path):
|
||||
r, label = _graph(tmp_path)
|
||||
assert _method(r, label, "intShow", ".show()"), "given's show not scoped to the given"
|
||||
assert _method(r, label, "intShow", ".helper()"), "given's helper not scoped to the given"
|
||||
|
||||
|
||||
def test_intra_given_call_resolves(tmp_path):
|
||||
r, label = _graph(tmp_path)
|
||||
calls = {
|
||||
(label.get(e["source"]), label.get(e["target"]))
|
||||
for e in r["edges"] if e.get("relation") == "calls"
|
||||
}
|
||||
assert (".show()", ".helper()") in calls
|
||||
|
||||
|
||||
def test_trait_method_is_untouched_by_the_given(tmp_path):
|
||||
r, label = _graph(tmp_path)
|
||||
assert _method(r, label, "Show", ".show()"), "trait lost its own method"
|
||||
# The trait TYPE node must carry no call/method edge stolen from the given.
|
||||
show_trait_id = next(
|
||||
n["id"] for n in r["nodes"]
|
||||
if n.get("label") == "Show" and n.get("source_file")
|
||||
)
|
||||
for e in r["edges"]:
|
||||
if e.get("source") == show_trait_id and e.get("relation") == "calls":
|
||||
raise AssertionError(f"given's call leaked onto the trait node: {e}")
|
||||
|
||||
|
||||
def test_given_members_do_not_leak_to_file_scope(tmp_path):
|
||||
r, _ = _graph(tmp_path)
|
||||
labels = [n.get("label") for n in r["nodes"]]
|
||||
# a leaked member would be a bare, file-scoped `helper()` (no leading dot)
|
||||
assert "helper()" not in labels, "given member leaked to file scope"
|
||||
|
||||
|
||||
def test_anonymous_given_members_are_kept(tmp_path):
|
||||
r, label = _graph(tmp_path)
|
||||
# the anonymous `given Show[String] with` is scoped under a synthesized owner,
|
||||
# distinct from the trait node, and still owns its show().
|
||||
assert _method(r, label, "given Show", ".show()"), "anonymous given's members dropped"
|
||||
trait_id = next(n["id"] for n in r["nodes"] if n.get("label") == "Show" and n.get("source_file"))
|
||||
anon_ids = [n["id"] for n in r["nodes"] if n.get("label") == "given Show"]
|
||||
assert anon_ids and trait_id not in anon_ids, "anonymous given collided with the trait node"
|
||||
Reference in new issue
Block a user