Skip to content

Commit 8fbbc57

Browse files
committed
feat(parser): Gleam import extraction and module-path resolution
get_call_hierarchy, get_impact_preview and the dead-code analysis silently reported 0 callers for every Gleam symbol: _LANGUAGE_EXTRACTORS had no "gleam" entry, so no import edges were ever extracted, and resolve_specifier had no way to map a Gleam module path like webse/config_types to cells/webse/src/webse/config_types.gleam. - _extract_gleam_imports: plain imports, .{...} unqualified lists (including "type X" entries and "X as Y" aliases), module aliases, deep module paths, and multi-line lists as emitted by gleam format - _gleam_source_roots: derives package src/ and test/ roots structurally from indexed .gleam paths, plus gleam.toml locations when indexed (cached by source_files identity, like _python_source_roots) - resolve_specifier: resolves slash-style specifiers from .gleam importers against those roots, preferring the importer's own package (module paths are only unique per package); stdlib and hex-dependency imports resolve to nothing, so no edge is created - tests: extraction, root detection, resolution (cross-package, test-to-src, test-only helpers, stdlib negatives), and the index_folder missing_extractors integration Verified against a ~1200-file Gleam monorepo: a widely used helper now reports 24 callers across 9 files (previously 0), and find_importers matches full-text ground truth file-for-file (31/31).
1 parent 256ee56 commit 8fbbc57

2 files changed

Lines changed: 286 additions & 0 deletions

File tree

src/jcodemunch_mcp/parser/imports.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,17 @@ def _parse_reexport_clause(raw: str) -> list[dict]:
124124
# Haskell: import Data.Map (fromList)
125125
_HASKELL_IMPORT = re.compile(r"""^import\s+(?:qualified\s+)?(\S+)""", re.MULTILINE)
126126

127+
# Gleam: import gleam/option.{type Option, None, Some} as opt
128+
# Module paths are lowercase snake_case segments joined by '/'. The optional
129+
# `.{...}` clause lists unqualified imports (values, constructors, and
130+
# `type X` entries); the optional trailing `as` alias renames the module but
131+
# does not change the specifier. gleam format may wrap long `.{...}` lists
132+
# across lines, so the brace body must span newlines ([^}] does).
133+
_GLEAM_IMPORT = re.compile(
134+
r"""^\s*import\s+([a-z][a-z0-9_]*(?:/[a-z][a-z0-9_]*)*)(?:\.\{([^}]*)\})?""",
135+
re.MULTILINE,
136+
)
137+
127138

128139
def _clean_names(raw: str) -> list[str]:
129140
"""Parse comma-separated names from an import clause, stripping aliases/whitespace."""
@@ -389,6 +400,32 @@ def _extract_haskell_imports(content: str) -> list[dict]:
389400
return [{"specifier": m.group(1), "names": []} for m in _HASKELL_IMPORT.finditer(content)]
390401

391402

403+
def _extract_gleam_imports(content: str) -> list[dict]:
404+
"""Extract Gleam import statements.
405+
406+
Handles:
407+
import gleam/io
408+
import gleam/option.{type Option, None, Some}
409+
import holmes_msg as msg
410+
import webse/config_types.{type Config, Config}
411+
412+
``names`` are the unqualified imports from the ``.{...}`` clause with the
413+
``type `` prefix stripped and ``X as Y`` reduced to the original name
414+
(both handled by :func:`_clean_names`). Gleam forbids duplicate imports
415+
of the same module, so first-wins dedup is sufficient.
416+
"""
417+
edges: list[dict] = []
418+
seen: set[str] = set()
419+
for m in _GLEAM_IMPORT.finditer(content):
420+
specifier, names_raw = m.group(1), m.group(2)
421+
if specifier in seen:
422+
continue
423+
seen.add(specifier)
424+
names = _clean_names(names_raw) if names_raw else []
425+
edges.append({"specifier": specifier, "names": names})
426+
return edges
427+
428+
392429
# Dart: import 'package:flutter/material.dart' / import 'dart:async' / import './foo.dart'
393430
_DART_IMPORT = re.compile(
394431
r"""^\s*(?:import|export)\s+['"]([^'"]+)['"]""", re.MULTILINE
@@ -642,6 +679,7 @@ def _extract_svelte_imports(content: str) -> list[dict]:
642679
"swift": _extract_swift_imports,
643680
"scala": _extract_scala_imports,
644681
"haskell": _extract_haskell_imports,
682+
"gleam": _extract_gleam_imports,
645683
"dart": _extract_dart_imports,
646684
"sql": _extract_sql_dbt_imports,
647685
"asm": _extract_asm_imports,
@@ -890,6 +928,46 @@ def _python_source_roots(source_files) -> tuple[str, ...]:
890928
return result
891929

892930

931+
# Cache: frozenset(source_files) -> tuple of Gleam source root prefixes.
932+
# Same keying rationale as _python_roots_cache above.
933+
_gleam_roots_cache: dict[frozenset, tuple[str, ...]] = {}
934+
935+
936+
def _gleam_source_roots(source_files) -> tuple[str, ...]:
937+
"""Detect Gleam source roots from the indexed file set.
938+
939+
A Gleam module path like ``webse/config_types`` is relative to a
940+
package's ``src/`` or ``test/`` directory, so those directories are the
941+
source roots. They are derived structurally: every path prefix of an
942+
indexed ``.gleam`` file that ends in a ``src`` or ``test`` segment
943+
(``cells/webse/src/webse/config.gleam`` -> ``cells/webse/src``), plus
944+
``<dir>/src`` and ``<dir>/test`` for every indexed ``gleam.toml``. The
945+
two signals overlap for normal packages; the gleam.toml one also covers
946+
packages whose files did not make it into the index, and the structural
947+
one covers indexes that exclude toml files.
948+
"""
949+
cache_key = source_files if isinstance(source_files, frozenset) else frozenset(source_files)
950+
cached = _gleam_roots_cache.get(cache_key)
951+
if cached is not None:
952+
return cached
953+
954+
roots: set[str] = set()
955+
for f in source_files:
956+
if f.endswith(".gleam"):
957+
parts = f.split("/")
958+
for i, seg in enumerate(parts[:-1]):
959+
if seg in ("src", "test"):
960+
roots.add("/".join(parts[: i + 1]))
961+
elif f == "gleam.toml" or f.endswith("/gleam.toml"):
962+
pkg_dir = f[: -len("/gleam.toml")] if "/" in f else ""
963+
for sub in ("src", "test"):
964+
roots.add(f"{pkg_dir}/{sub}" if pkg_dir else sub)
965+
966+
result = tuple(sorted(roots))
967+
_gleam_roots_cache[cache_key] = result
968+
return result
969+
970+
893971
def _python_package_parents(source_files) -> dict[str, tuple[str, ...]]:
894972
"""Map every package basename to the parent dirs where it appears.
895973
@@ -1276,6 +1354,31 @@ def resolve_specifier(
12761354
if c in source_files:
12771355
return c
12781356

1357+
# Gleam module-style import: 'webse/config_types' →
1358+
# 'cells/webse/src/webse/config_types.gleam'. Gleam module paths are
1359+
# slash-joined lowercase segments resolved against a package's src/ or
1360+
# test/ root; the target extension is always .gleam, so candidates are
1361+
# built directly instead of via _candidates. The importer's own package
1362+
# root is tried first: module paths are only unique per package, and
1363+
# same-package imports are the common case. Stdlib and hex-dependency
1364+
# imports (gleam/io, gleam/list) resolve to nothing — no edge is created.
1365+
if importer_path.endswith(".gleam") and not specifier.startswith("."):
1366+
candidate = specifier + ".gleam"
1367+
if candidate in source_files:
1368+
return candidate
1369+
roots = _gleam_source_roots(source_files)
1370+
# "Own" roots share the importer's package dir (the part above
1371+
# src/test), so a test module prefers its package's src/ root too.
1372+
# A root-level src/test (dirname "") means a single-package repo.
1373+
own = [
1374+
r for r in roots
1375+
if not posixpath.dirname(r) or importer_path.startswith(posixpath.dirname(r) + "/")
1376+
]
1377+
for root in own + [r for r in roots if r not in own]:
1378+
prefixed = f"{root}/{candidate}"
1379+
if prefixed in source_files:
1380+
return prefixed
1381+
12791382
# Python module-style absolute import: 'app.notifications.mentions' →
12801383
# 'app/notifications/mentions.py'. Also try prefixing with detected
12811384
# Python source roots so layouts like backend/app/... or src/app/...

tests/test_gleam_imports.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Tests for Gleam import extraction and specifier resolution."""
2+
3+
import pytest
4+
5+
from jcodemunch_mcp.parser.imports import (
6+
_LANGUAGE_EXTRACTORS,
7+
_gleam_source_roots,
8+
extract_imports,
9+
resolve_specifier,
10+
)
11+
12+
13+
GLEAM_SOURCE = """\
14+
import gleam/io
15+
import gleam/option.{type Option, None, Some}
16+
import holmes_msg as msg
17+
import webse/config_types.{type Config, Config}
18+
import cx/commands/vetf/aggregate
19+
20+
pub fn main() {
21+
io.println("import ignored inside a string")
22+
}
23+
"""
24+
25+
26+
class TestGleamImportExtraction:
27+
28+
def test_gleam_in_language_extractors(self):
29+
"""Gleam must be registered in _LANGUAGE_EXTRACTORS."""
30+
assert "gleam" in _LANGUAGE_EXTRACTORS
31+
32+
def test_extracts_plain_import(self):
33+
edges = extract_imports(GLEAM_SOURCE, "src/app.gleam", "gleam")
34+
specifiers = [e["specifier"] for e in edges]
35+
assert "gleam/io" in specifiers
36+
37+
def test_extracts_unqualified_names_stripping_type_prefix(self):
38+
edges = extract_imports(GLEAM_SOURCE, "src/app.gleam", "gleam")
39+
by_spec = {e["specifier"]: e["names"] for e in edges}
40+
assert by_spec["gleam/option"] == ["Option", "None", "Some"]
41+
# `{type Config, Config}` imports the type and the constructor —
42+
# both surface as the mention name "Config".
43+
assert by_spec["webse/config_types"] == ["Config", "Config"]
44+
45+
def test_module_alias_keeps_specifier(self):
46+
edges = extract_imports(GLEAM_SOURCE, "src/app.gleam", "gleam")
47+
by_spec = {e["specifier"]: e["names"] for e in edges}
48+
assert "holmes_msg" in by_spec
49+
assert by_spec["holmes_msg"] == []
50+
51+
def test_extracts_deep_module_path(self):
52+
edges = extract_imports(GLEAM_SOURCE, "src/app.gleam", "gleam")
53+
specifiers = [e["specifier"] for e in edges]
54+
assert "cx/commands/vetf/aggregate" in specifiers
55+
56+
def test_edge_count(self):
57+
edges = extract_imports(GLEAM_SOURCE, "src/app.gleam", "gleam")
58+
assert len(edges) == 5
59+
60+
def test_multiline_unqualified_list(self):
61+
source = (
62+
"import holmes_msg.{\n"
63+
" type HolmesMsg, type Envelope,\n"
64+
" decode_msg,\n"
65+
"}\n"
66+
)
67+
edges = extract_imports(source, "src/app.gleam", "gleam")
68+
assert len(edges) == 1
69+
assert edges[0]["specifier"] == "holmes_msg"
70+
assert edges[0]["names"] == ["HolmesMsg", "Envelope", "decode_msg"]
71+
72+
def test_unqualified_name_alias(self):
73+
source = "import gleam/option.{Some as S}\n"
74+
edges = extract_imports(source, "src/app.gleam", "gleam")
75+
assert edges[0]["names"] == ["Some"]
76+
77+
def test_no_false_positives_from_non_import_lines(self):
78+
source = 'pub fn main() {\n io.println("import gleam/io")\n}\n'
79+
edges = extract_imports(source, "src/app.gleam", "gleam")
80+
assert edges == []
81+
82+
83+
WEVE_STYLE_FILES = {
84+
"packages/yamlutils/src/yamlutils.gleam",
85+
"packages/ion/src/ion/ion_yaml.gleam",
86+
"cells/webse/src/webse/config_yaml.gleam",
87+
"cells/webse/src/webse/config_types.gleam",
88+
"cells/webse/test/webse_test.gleam",
89+
"cells/webse/test/support/helper.gleam",
90+
"soma/src/soma/config_yaml.gleam",
91+
"cx/src/cx/cluster_config_yaml.gleam",
92+
"cells/webse/gleam.toml",
93+
"packages/yamlutils/gleam.toml",
94+
}
95+
96+
97+
class TestGleamSourceRoots:
98+
99+
def test_structural_roots_from_gleam_files(self):
100+
roots = _gleam_source_roots(frozenset(WEVE_STYLE_FILES))
101+
assert "packages/yamlutils/src" in roots
102+
assert "cells/webse/src" in roots
103+
assert "cells/webse/test" in roots
104+
assert "soma/src" in roots
105+
assert "cx/src" in roots
106+
107+
def test_gleam_toml_derived_roots(self):
108+
files = frozenset({"cells/empty/gleam.toml"})
109+
roots = _gleam_source_roots(files)
110+
assert "cells/empty/src" in roots
111+
assert "cells/empty/test" in roots
112+
113+
def test_root_level_package(self):
114+
files = frozenset({"src/app.gleam", "test/app_test.gleam", "gleam.toml"})
115+
roots = _gleam_source_roots(files)
116+
assert "src" in roots
117+
assert "test" in roots
118+
119+
120+
class TestGleamSpecifierResolution:
121+
122+
@pytest.mark.parametrize("specifier,importer,expected", [
123+
# Cross-package import into another package's src root
124+
("yamlutils", "packages/ion/src/ion/ion_yaml.gleam",
125+
"packages/yamlutils/src/yamlutils.gleam"),
126+
# Same-package import
127+
("webse/config_types", "cells/webse/src/webse/config_yaml.gleam",
128+
"cells/webse/src/webse/config_types.gleam"),
129+
# Test module importing its package's src module
130+
("webse/config_types", "cells/webse/test/webse_test.gleam",
131+
"cells/webse/src/webse/config_types.gleam"),
132+
# Test module importing a test-only helper module
133+
("support/helper", "cells/webse/test/webse_test.gleam",
134+
"cells/webse/test/support/helper.gleam"),
135+
# Stdlib / hex dependency: unresolvable, no edge
136+
("gleam/io", "cells/webse/src/webse/config_yaml.gleam", None),
137+
("gleam/list", "soma/src/soma/config_yaml.gleam", None),
138+
], ids=[
139+
"cross_package", "same_package", "test_to_src", "test_helper",
140+
"stdlib_io", "stdlib_list",
141+
])
142+
def test_resolution(self, specifier, importer, expected):
143+
result = resolve_specifier(specifier, importer, set(WEVE_STYLE_FILES))
144+
assert result == expected
145+
146+
def test_non_gleam_importer_unaffected(self):
147+
"""A slash specifier from a non-.gleam importer must not hit gleam roots."""
148+
result = resolve_specifier(
149+
"webse/config_types", "scripts/tool.py", set(WEVE_STYLE_FILES)
150+
)
151+
assert result is None
152+
153+
def test_end_to_end_extract_and_resolve(self):
154+
source = "import webse/config_types.{type Config}\n"
155+
edges = extract_imports(source, "cells/webse/src/webse/config_yaml.gleam", "gleam")
156+
assert len(edges) == 1
157+
resolved = resolve_specifier(
158+
edges[0]["specifier"],
159+
"cells/webse/src/webse/config_yaml.gleam",
160+
set(WEVE_STYLE_FILES),
161+
)
162+
assert resolved == "cells/webse/src/webse/config_types.gleam"
163+
164+
def test_gleam_not_in_missing_extractors(self, tmp_path):
165+
"""After the extractor lands, index_folder must not flag gleam."""
166+
from jcodemunch_mcp.tools.index_folder import index_folder
167+
168+
src = tmp_path / "src"
169+
store = tmp_path / "store"
170+
src.mkdir()
171+
store.mkdir()
172+
173+
(src / "app.gleam").write_text(
174+
"import gleam/io\n\npub fn main() {\n io.println(\"hello\")\n}\n"
175+
)
176+
177+
r = index_folder(str(src), use_ai_summaries=False, storage_path=str(store))
178+
assert r["success"] is True
179+
180+
missing = r.get("missing_extractors", [])
181+
assert "gleam" not in missing, (
182+
f"gleam should no longer be in missing_extractors: {missing}"
183+
)

0 commit comments

Comments
 (0)