Skip to content

Commit fc8b1fd

Browse files
authored
Merge pull request #379 from oderwat/gleam-import-extractor
Gleam import extraction and module-path resolution
2 parents 23346c8 + dbba4c3 commit fc8b1fd

2 files changed

Lines changed: 293 additions & 0 deletions

File tree

src/jcodemunch_mcp/parser/imports.py

Lines changed: 104 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,47 @@ 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/``, ``test/`` or ``dev/`` directory, so those
941+
directories are the source roots. They are derived structurally: every
942+
path prefix of an indexed ``.gleam`` file that ends in a ``src``,
943+
``test`` or ``dev`` segment (``cells/webse/src/webse/config.gleam`` ->
944+
``cells/webse/src``), plus ``<dir>/src``, ``<dir>/test`` and
945+
``<dir>/dev`` for every indexed ``gleam.toml``. The two signals overlap
946+
for normal packages; the gleam.toml one also covers packages whose
947+
files did not make it into the index, and the structural one covers
948+
indexes that exclude toml files.
949+
"""
950+
cache_key = source_files if isinstance(source_files, frozenset) else frozenset(source_files)
951+
cached = _gleam_roots_cache.get(cache_key)
952+
if cached is not None:
953+
return cached
954+
955+
roots: set[str] = set()
956+
for f in source_files:
957+
if f.endswith(".gleam"):
958+
parts = f.split("/")
959+
for i, seg in enumerate(parts[:-1]):
960+
if seg in ("src", "test", "dev"):
961+
roots.add("/".join(parts[: i + 1]))
962+
elif f == "gleam.toml" or f.endswith("/gleam.toml"):
963+
pkg_dir = f[: -len("/gleam.toml")] if "/" in f else ""
964+
for sub in ("src", "test", "dev"):
965+
roots.add(f"{pkg_dir}/{sub}" if pkg_dir else sub)
966+
967+
result = tuple(sorted(roots))
968+
_gleam_roots_cache[cache_key] = result
969+
return result
970+
971+
893972
def _python_package_parents(source_files) -> dict[str, tuple[str, ...]]:
894973
"""Map every package basename to the parent dirs where it appears.
895974
@@ -1276,6 +1355,31 @@ def resolve_specifier(
12761355
if c in source_files:
12771356
return c
12781357

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

tests/test_gleam_imports.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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+
"cells/webse/dev/seed_data.gleam",
91+
"soma/src/soma/config_yaml.gleam",
92+
"cx/src/cx/cluster_config_yaml.gleam",
93+
"cells/webse/gleam.toml",
94+
"packages/yamlutils/gleam.toml",
95+
}
96+
97+
98+
class TestGleamSourceRoots:
99+
100+
def test_structural_roots_from_gleam_files(self):
101+
roots = _gleam_source_roots(frozenset(WEVE_STYLE_FILES))
102+
assert "packages/yamlutils/src" in roots
103+
assert "cells/webse/src" in roots
104+
assert "cells/webse/test" in roots
105+
assert "cells/webse/dev" in roots
106+
assert "soma/src" in roots
107+
assert "cx/src" in roots
108+
109+
def test_gleam_toml_derived_roots(self):
110+
files = frozenset({"cells/empty/gleam.toml"})
111+
roots = _gleam_source_roots(files)
112+
assert "cells/empty/src" in roots
113+
assert "cells/empty/test" in roots
114+
assert "cells/empty/dev" in roots
115+
116+
def test_root_level_package(self):
117+
files = frozenset({"src/app.gleam", "test/app_test.gleam", "gleam.toml"})
118+
roots = _gleam_source_roots(files)
119+
assert "src" in roots
120+
assert "test" in roots
121+
122+
123+
class TestGleamSpecifierResolution:
124+
125+
@pytest.mark.parametrize("specifier,importer,expected", [
126+
# Cross-package import into another package's src root
127+
("yamlutils", "packages/ion/src/ion/ion_yaml.gleam",
128+
"packages/yamlutils/src/yamlutils.gleam"),
129+
# Same-package import
130+
("webse/config_types", "cells/webse/src/webse/config_yaml.gleam",
131+
"cells/webse/src/webse/config_types.gleam"),
132+
# Test module importing its package's src module
133+
("webse/config_types", "cells/webse/test/webse_test.gleam",
134+
"cells/webse/src/webse/config_types.gleam"),
135+
# Test module importing a test-only helper module
136+
("support/helper", "cells/webse/test/webse_test.gleam",
137+
"cells/webse/test/support/helper.gleam"),
138+
# Dev module importing its package's src module
139+
("webse/config_types", "cells/webse/dev/seed_data.gleam",
140+
"cells/webse/src/webse/config_types.gleam"),
141+
# Stdlib / hex dependency: unresolvable, no edge
142+
("gleam/io", "cells/webse/src/webse/config_yaml.gleam", None),
143+
("gleam/list", "soma/src/soma/config_yaml.gleam", None),
144+
], ids=[
145+
"cross_package", "same_package", "test_to_src", "test_helper",
146+
"dev_to_src", "stdlib_io", "stdlib_list",
147+
])
148+
def test_resolution(self, specifier, importer, expected):
149+
result = resolve_specifier(specifier, importer, set(WEVE_STYLE_FILES))
150+
assert result == expected
151+
152+
def test_non_gleam_importer_unaffected(self):
153+
"""A slash specifier from a non-.gleam importer must not hit gleam roots."""
154+
result = resolve_specifier(
155+
"webse/config_types", "scripts/tool.py", set(WEVE_STYLE_FILES)
156+
)
157+
assert result is None
158+
159+
def test_end_to_end_extract_and_resolve(self):
160+
source = "import webse/config_types.{type Config}\n"
161+
edges = extract_imports(source, "cells/webse/src/webse/config_yaml.gleam", "gleam")
162+
assert len(edges) == 1
163+
resolved = resolve_specifier(
164+
edges[0]["specifier"],
165+
"cells/webse/src/webse/config_yaml.gleam",
166+
set(WEVE_STYLE_FILES),
167+
)
168+
assert resolved == "cells/webse/src/webse/config_types.gleam"
169+
170+
def test_gleam_not_in_missing_extractors(self, tmp_path):
171+
"""After the extractor lands, index_folder must not flag gleam."""
172+
from jcodemunch_mcp.tools.index_folder import index_folder
173+
174+
src = tmp_path / "src"
175+
store = tmp_path / "store"
176+
src.mkdir()
177+
store.mkdir()
178+
179+
(src / "app.gleam").write_text(
180+
"import gleam/io\n\npub fn main() {\n io.println(\"hello\")\n}\n"
181+
)
182+
183+
r = index_folder(str(src), use_ai_summaries=False, storage_path=str(store))
184+
assert r["success"] is True
185+
186+
missing = r.get("missing_extractors", [])
187+
assert "gleam" not in missing, (
188+
f"gleam should no longer be in missing_extractors: {missing}"
189+
)

0 commit comments

Comments
 (0)