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:
safishamsiandClaude Opus 4.8 committed 2026-10-10 21:20:29 +01:00
1 parent 177d2071e5
commit aa4564378a
3 files changed
+130 -5

No files matched your search

+7 -3
View File
@@ -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,
)
+36 -2
View File
@@ -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("::")
)
+87
View File
@@ -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"