Skip to content

Commit f0eda7b

Browse files
jgravelleclaude
andcommitted
fix(toml): keep every segment of a dotted key
Follow-up to #378 (@zuoYu-zzz), whose TOML extractor is otherwise sound and whose array-table handling I could not break. _extract_key handled a dotted_key by scanning its DIRECT children for bare_key/quoted_key. tree-sitter-toml nests dotted_key left-recursively: [tool.ruff.lint] -> dotted_key( dotted_key( bare_key tool, bare_key ruff ), bare_key lint ) The inner dotted_key was not in the filter tuple, so every segment except the last was dropped. Two-level paths worked, which is why it looked correct at a glance. Three-plus levels broke, on this repo's own pyproject.toml: [tool.hatch.build.targets.wheel] -> wheel / signature [wheel] [tool.pytest.ini_options] -> ini_options / [ini_options] [tool.ruff.lint] -> lint / [lint] [tool.ruff.lint.per-file-ignores] -> per-file-ignores / [per-file-ignores] The signature is the sharp end. "[wheel]" is a header that appears NOWHERE in the file, so search_symbols would hand an agent fabricated source text. And ...targets.wheel / ...targets.sdist collapse to names that collide with any other three-deep table sharing that leaf. _extract_key becomes _extract_key_parts, returning segments instead of a pre-joined string and recursing on the nested dotted_key. Building from parts also fixes name/qualified_name, which the PR set to the same value: name is now the LEAF and qualified_name the full dotted path, the convention every other extractor in this module already follows. New test asserts three- and five-deep tables, a dotted key inside a table (a.x.y.z), a dotted array table, and that a table's signature is text that actually occurs in the source. Proven non-vacuous: restoring the single-token bug flips it to FAIL. The PR's own test uses only single-segment headers, so nothing in the suite could have caught this. Docstring now records that the every-pair-at-every-depth policy diverges from _parse_json_symbols (root-level keys only) DELIBERATELY, so nobody "fixes" it later: TOML tables are genuinely structural, and lock files never route here because Cargo.lock / uv.lock / poetry.lock carry no .toml extension. Rides the next release alongside #379. No schema, tool-count, or INDEX_VERSION change; previously-skipped .toml files simply gain correct symbols on reindex. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b19e518 commit f0eda7b

2 files changed

Lines changed: 98 additions & 23 deletions

File tree

src/jcodemunch_mcp/parser/extractor.py

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6362,6 +6362,14 @@ def _parse_toml_symbols(source_bytes: bytes, filename: str) -> list[Symbol]:
63626362
TOML tables are the primary structural units, similar to sections in INI files.
63636363
Array tables represent lists of tables. Key-value pairs at the top level
63646364
and inside tables are extracted as constants.
6365+
6366+
Unlike ``_parse_json_symbols``, which caps at root-level keys to avoid noise
6367+
in large config files, this extracts every pair at every depth. That
6368+
divergence is deliberate: TOML tables are genuinely structural, lock files
6369+
do not route here (Cargo.lock / uv.lock / poetry.lock carry no ``.toml``
6370+
extension), and a typical pyproject.toml yields tens of symbols, not
6371+
thousands. ``name`` is the leaf segment and ``qualified_name`` the full
6372+
dotted path, matching every other extractor in this module.
63656373
"""
63666374
try:
63676375
parser = get_parser("toml")
@@ -6371,20 +6379,26 @@ def _parse_toml_symbols(source_bytes: bytes, filename: str) -> list[Symbol]:
63716379
tree = parser.parse(source_bytes)
63726380
symbols: list[Symbol] = []
63736381

6374-
def _extract_key(node) -> Optional[str]:
6375-
"""Extract key name from a TOML key node."""
6382+
def _extract_key_parts(node) -> list[str]:
6383+
"""Extract a TOML key node as its path segments.
6384+
6385+
tree-sitter-toml nests ``dotted_key`` left-recursively, so
6386+
``[tool.ruff.lint]`` is ``dotted_key(dotted_key(tool, ruff), lint)``.
6387+
Recursing on the nested node is what keeps every segment; matching only
6388+
the leaf types drops all but the last one.
6389+
"""
63766390
if node.type == "bare_key":
6377-
return source_bytes[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
6391+
return [source_bytes[node.start_byte:node.end_byte].decode("utf-8", errors="replace")]
63786392
elif node.type == "quoted_key":
63796393
content = source_bytes[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
6380-
return content.strip('"').strip("'")
6394+
return [content.strip('"').strip("'")]
63816395
elif node.type == "dotted_key":
6382-
parts = []
6396+
parts: list[str] = []
63836397
for child in node.children:
6384-
if child.type in ("bare_key", "quoted_key"):
6385-
parts.append(_extract_key(child))
6386-
return ".".join(p for p in parts if p)
6387-
return None
6398+
if child.type in ("bare_key", "quoted_key", "dotted_key"):
6399+
parts.extend(_extract_key_parts(child))
6400+
return [p for p in parts if p]
6401+
return []
63886402

63896403
def _walk_node(node, parent_path: list[str] = None):
63906404
"""Walk the AST and extract symbols."""
@@ -6394,13 +6408,13 @@ def _walk_node(node, parent_path: list[str] = None):
63946408
if node.type == "table":
63956409
key_node = next((c for c in node.children if c.type in ("bare_key", "quoted_key", "dotted_key")), None)
63966410
if key_node:
6397-
key = _extract_key(key_node)
6398-
if key:
6399-
full_path = ".".join(parent_path + [key]) if parent_path else key
6411+
key_parts = _extract_key_parts(key_node)
6412+
if key_parts:
6413+
full_path = ".".join(parent_path + key_parts)
64006414
symbols.append(Symbol(
64016415
id=make_symbol_id(filename, full_path, "type"),
64026416
file=filename,
6403-
name=key,
6417+
name=key_parts[-1],
64046418
qualified_name=full_path,
64056419
kind="type",
64066420
language="toml",
@@ -6411,21 +6425,21 @@ def _walk_node(node, parent_path: list[str] = None):
64116425
byte_length=node.end_byte - node.start_byte,
64126426
content_hash=compute_content_hash(source_bytes[node.start_byte:node.end_byte]),
64136427
))
6414-
new_path = parent_path + [key]
6428+
new_path = parent_path + key_parts
64156429
for child in node.children:
64166430
_walk_node(child, new_path)
64176431
return
64186432

64196433
if node.type == "table_array_element":
64206434
key_node = next((c for c in node.children if c.type in ("bare_key", "quoted_key", "dotted_key")), None)
64216435
if key_node:
6422-
key = _extract_key(key_node)
6423-
if key:
6424-
full_path = ".".join(parent_path + [key]) if parent_path else key
6436+
key_parts = _extract_key_parts(key_node)
6437+
if key_parts:
6438+
full_path = ".".join(parent_path + key_parts)
64256439
symbols.append(Symbol(
64266440
id=make_symbol_id(filename, full_path + "[]", "class"),
64276441
file=filename,
6428-
name=key + "[]",
6442+
name=key_parts[-1] + "[]",
64296443
qualified_name=full_path,
64306444
kind="class",
64316445
language="toml",
@@ -6436,7 +6450,7 @@ def _walk_node(node, parent_path: list[str] = None):
64366450
byte_length=node.end_byte - node.start_byte,
64376451
content_hash=compute_content_hash(source_bytes[node.start_byte:node.end_byte]),
64386452
))
6439-
new_path = parent_path + [key]
6453+
new_path = parent_path + key_parts
64406454
for child in node.children:
64416455
_walk_node(child, new_path)
64426456
return
@@ -6448,17 +6462,17 @@ def _walk_node(node, parent_path: list[str] = None):
64486462
key_node = child
64496463
break
64506464
if key_node:
6451-
key = _extract_key(key_node)
6452-
if key:
6453-
full_path = ".".join(parent_path + [key]) if parent_path else key
6465+
key_parts = _extract_key_parts(key_node)
6466+
if key_parts:
6467+
full_path = ".".join(parent_path + key_parts)
64546468
val_src = source_bytes[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
64556469
sig = " ".join(val_src.split())
64566470
if len(sig) > 100:
64576471
sig = sig[:97] + "..."
64586472
symbols.append(Symbol(
64596473
id=make_symbol_id(filename, full_path, "constant"),
64606474
file=filename,
6461-
name=key,
6475+
name=key_parts[-1],
64626476
qualified_name=full_path,
64636477
kind="constant",
64646478
language="toml",

tests/test_languages.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2539,3 +2539,64 @@ def test_toml_extension_mapping():
25392539
assert get_language_for_path("pyproject.toml") == "toml"
25402540
assert get_language_for_path("config/settings.toml") == "toml"
25412541
assert get_language_for_path("CONFIG/SETTINGS.TOML") == "toml"
2542+
2543+
2544+
TOML_DEEP_SOURCE = '''\
2545+
[tool.ruff]
2546+
line-length = 100
2547+
2548+
[tool.ruff.lint]
2549+
select = ["E"]
2550+
2551+
[tool.hatch.build.targets.wheel]
2552+
packages = ["src/pkg"]
2553+
2554+
[a]
2555+
x.y.z = 1
2556+
2557+
[[tool.pytest.suites]]
2558+
name = "unit"
2559+
'''
2560+
2561+
2562+
def test_parse_toml_dotted_paths_deeper_than_two_levels():
2563+
"""Every segment of a dotted key survives.
2564+
2565+
tree-sitter-toml nests ``dotted_key`` left-recursively, so a walker that
2566+
matches only leaf key types keeps the last segment and silently drops the
2567+
rest. Two-level paths still look right under that bug, which is why the
2568+
assertions below go three and five deep: ``[tool.hatch.build.targets.wheel]``
2569+
came back as ``wheel`` with a fabricated ``[wheel]`` signature.
2570+
"""
2571+
symbols = parse_file(TOML_DEEP_SOURCE, "pyproject.toml", "toml")
2572+
tables = {s.qualified_name: s for s in symbols if s.kind == "type"}
2573+
2574+
assert "tool.ruff.lint" in tables
2575+
assert "tool.hatch.build.targets.wheel" in tables
2576+
2577+
lint = tables["tool.ruff.lint"]
2578+
# name is the leaf, qualified_name the full path — the convention every
2579+
# other extractor in extractor.py follows.
2580+
assert lint.name == "lint"
2581+
# The signature must be text that actually appears in the file.
2582+
assert lint.signature == "[tool.ruff.lint]"
2583+
assert lint.signature in TOML_DEEP_SOURCE
2584+
2585+
wheel = tables["tool.hatch.build.targets.wheel"]
2586+
assert wheel.name == "wheel"
2587+
assert wheel.signature == "[tool.hatch.build.targets.wheel]"
2588+
2589+
# Pairs inherit the full table path, and a dotted key inside a table keeps
2590+
# its own segments too.
2591+
select = next(s for s in symbols if s.qualified_name == "tool.ruff.lint.select")
2592+
assert select.kind == "constant"
2593+
assert select.name == "select"
2594+
2595+
nested_pair = next(s for s in symbols if s.qualified_name == "a.x.y.z")
2596+
assert nested_pair.name == "z"
2597+
2598+
# Array tables carry the full path as well.
2599+
suites = next(s for s in symbols if s.kind == "class")
2600+
assert suites.qualified_name == "tool.pytest.suites"
2601+
assert suites.name == "suites[]"
2602+
assert suites.signature == "[[tool.pytest.suites]]"

0 commit comments

Comments
 (0)