feat(typescript): emit a node per enum member (#3064)

A TypeScript enum's members got no graph node. Emit a member node per member with a case_of
edge to the enum (matching the C# handler), handling both member spellings
(property_identifier and enum_assignment), const enums, and unquoting a whole string member
name instead of one fragment; the member initializer is still walked so a nested expression
is not dropped.
This commit is contained in:
durmazoguzhan
2026-08-25 15:45:33 +01:00
committed by safishamsi
parent 0e566345fc
commit 3b12d5ce68
2 changed files with 234 additions and 2 deletions
+46 -2
View File
@@ -2329,7 +2329,7 @@ def _ts_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
nodes: list, edges: list, seen_ids: set, function_bodies: list,
parent_class_nid: str | None, add_node_fn, add_edge_fn,
walk_fn) -> bool:
"""Emit a container node for a TS `namespace`/`module` declaration.
"""Emit enum member nodes, and a container node for a TS `namespace`/`module`.
`namespace Foo {}` parses as `internal_module` (with `name`/`body` fields);
`module Bar {}` and ambient `declare module "pkg" {}` parse as a named
@@ -2343,6 +2343,50 @@ def _ts_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
The guard requires `is_named` because the anonymous `module` keyword token
shares the `module` type string and would otherwise match here.
"""
if (parent_class_nid
and node.parent is not None
and node.parent.type == "enum_body"
and node.type in ("property_identifier", "enum_assignment")):
# `enum_declaration` is in TS's class_types "parity with Java/C#", so the
# enum type is a node while its members were not, leaving the type a leaf.
# Java emits a node per `enum_constant` with a `case_of` edge (#1719),
# Kotlin per `enum_entry` (#1738), Swift the same; this is that shape.
#
# Two member spellings: a bare `Red` is a `property_identifier`, while
# `Green = 5` is an `enum_assignment` whose `name` is either a
# `property_identifier` or, for a quoted member, a `string`. The parent
# check is what keeps this off the `property_identifier` nodes that
# appear all over a TS file.
name_node = node if node.type == "property_identifier" else node.child_by_field_name("name")
member_name = ""
if name_node is not None:
member_name = _read_text(name_node, source)
if name_node.type == "string":
# `"Odd Name" = 7`: the label is the member name, not the quoted
# literal. Unquote the whole text the way the namespace handler
# below does rather than reading a `string_fragment`, because an
# escape splits the string into several fragments and the first
# one alone truncates the name (`"A\tB"` would become `A`).
member_name = member_name.strip("'\"`")
if member_name:
line = node.start_point[0] + 1
member_nid = _make_id(parent_class_nid, member_name)
# TS is case-sensitive while the id recipe casefolds, so `enum E {
# Value, value }` puts two legal members on one id. The first
# declaration keeps the node rather than a second edge on it.
if member_nid not in seen_ids:
add_node_fn(member_nid, member_name, line)
add_edge_fn(parent_class_nid, member_nid, "case_of", line)
if node.type == "enum_assignment":
# Claiming the member must not swallow its initializer. An enum value
# can hold a whole expression, and `A = class Inner { m() {} }.name`
# loses Inner's method node if the walk stops here. Descend into the
# `value` only: the `name` is already read above, and walking it
# again would put the member through the default recurse as well.
value_node = node.child_by_field_name("value")
if value_node is not None:
walk_fn(value_node, parent_class_nid)
return True
if node.is_named and node.type in ("internal_module", "module"):
name_node = node.child_by_field_name("name")
if name_node is None:
@@ -4625,7 +4669,7 @@ def _extract_generic(
closure_locals_by_body, config=config):
return
# TS namespace / module containers (internal_module, module)
# TS enum members, and namespace / module containers
if config.ts_module == "tree_sitter_typescript":
if _ts_extra_walk(node, source, file_nid, stem, str_path,
nodes, edges, seen_ids, function_bodies,
+188
View File
@@ -0,0 +1,188 @@
"""TypeScript enum members get a node and a `case_of` edge, like Java's (#1719).
`enum_declaration` is in `_TS_CONFIG.class_types` with the comment "parity with
Java/C#", but only the container half was done: the enum type was a node while
its members were not, leaving the type a leaf. Java has emitted a node per
`enum_constant` with a `case_of` edge since #1719 and Kotlin since #1738 (both
from #1700), and Swift does the same for `enum_entry`.
TS spells a member two ways. A bare `Red` is a `property_identifier`; `Green = 5`
is an `enum_assignment` whose `name` is a `property_identifier`, or a `string`
when the member is quoted. Both live directly under `enum_body`, which is what
keeps this off the `property_identifier` nodes elsewhere in a file.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from graphify.extract import extract
def _extract(tmp_path, files: dict[str, str]):
for name, body in files.items():
p = tmp_path / name
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(body)
old = os.getcwd()
try:
os.chdir(tmp_path)
r = extract([Path(n) for n in files], cache_root=Path(tempfile.mkdtemp()))
finally:
os.chdir(old)
case_of = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "case_of"}
return case_of, r
def _find(r, label):
return next(n["id"] for n in r["nodes"] if n["label"] == label)
def _labels(r):
return [n["label"] for n in r["nodes"]]
def test_a_bare_member_becomes_a_node(tmp_path):
case_of, r = _extract(tmp_path, {"s.ts": (
"export enum Color {\n"
" Red\n"
"}\n"
)})
assert "Red" in _labels(r)
assert (_find(r, "Color"), _find(r, "Red")) in case_of
def test_an_assigned_member_becomes_a_node(tmp_path):
# `Green = 5` parses as enum_assignment, a different node type from the bare
# spelling, and both must land on the same edge.
case_of, r = _extract(tmp_path, {"s.ts": (
"export enum Color {\n"
" Red,\n"
" Green = 5\n"
"}\n"
)})
color = _find(r, "Color")
assert (color, _find(r, "Red")) in case_of
assert (color, _find(r, "Green")) in case_of
def test_a_quoted_member_uses_its_name_not_the_literal(tmp_path):
# The name node is a `string`; the label must be the member name without
# the surrounding quotes.
case_of, r = _extract(tmp_path, {"s.ts": (
"export enum Weird {\n"
" \"Odd Name\" = 7\n"
"}\n"
)})
labels = _labels(r)
assert "Odd Name" in labels
assert '"Odd Name"' not in labels
assert (_find(r, "Weird"), _find(r, "Odd Name")) in case_of
def test_an_escape_in_a_quoted_member_does_not_truncate_the_name(tmp_path):
# An escape splits the string into several fragments, so reading the first
# fragment alone would label this member `A`. The whole text is unquoted
# instead, which keeps the escape as it was written.
case_of, r = _extract(tmp_path, {"s.ts": (
"export enum E {\n"
" \"A\\tB\" = 1\n"
"}\n"
)})
assert "A\\tB" in _labels(r)
assert "A" not in _labels(r)
assert (_find(r, "E"), _find(r, "A\\tB")) in case_of
def test_a_string_valued_enum_still_emits_members(tmp_path):
# String enums are the common TS spelling and assign a string to a plain
# identifier name; the value must not be mistaken for the name.
case_of, r = _extract(tmp_path, {"s.ts": (
"export enum Direction {\n"
" Up = \"UP\",\n"
" Down = \"DOWN\"\n"
"}\n"
)})
direction = _find(r, "Direction")
assert (direction, _find(r, "Up")) in case_of
assert (direction, _find(r, "Down")) in case_of
assert "UP" not in _labels(r)
def test_case_only_sibling_members_do_not_duplicate_an_edge(tmp_path):
# Legal TS, one id after normalization. The first declaration keeps it.
case_of, r = _extract(tmp_path, {"s.ts": (
"export enum Odd {\n"
" Value,\n"
" value\n"
"}\n"
)})
assert len(case_of) == 1
def test_a_class_property_identifier_is_not_read_as_an_enum_member(tmp_path):
# `property_identifier` appears throughout a TS file. Only the ones directly
# under an enum_body are members, so a class with properties and methods
# must produce no case_of edges at all.
case_of, r = _extract(tmp_path, {"s.ts": (
"export class Widget {\n"
" label: string = \"x\";\n"
" render(): void {}\n"
"}\n"
)})
assert case_of == set()
def test_a_const_enum_emits_members(tmp_path):
case_of, r = _extract(tmp_path, {"s.ts": (
"export const enum Mode {\n"
" Fast\n"
"}\n"
)})
assert (_find(r, "Mode"), _find(r, "Fast")) in case_of
def test_an_enum_inside_a_namespace_emits_members(tmp_path):
# The namespace container is handled in the same walk; the enum must not
# lose its members to the namespace hop.
case_of, r = _extract(tmp_path, {"s.ts": (
"namespace App {\n"
" export enum Color {\n"
" Red\n"
" }\n"
"}\n"
)})
assert (_find(r, "Color"), _find(r, "Red")) in case_of
def test_the_enum_type_node_is_kept(tmp_path):
# Regression guard: the member nodes are additive, the enum type stays.
_, r = _extract(tmp_path, {"s.ts": (
"export enum Color {\n"
" Red\n"
"}\n"
)})
color = _find(r, "Color")
contains = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "contains"}
assert any(target == color for _, target in contains)
def test_an_empty_enum_emits_no_members(tmp_path):
case_of, r = _extract(tmp_path, {"s.ts": "export enum Empty {\n}\n"})
assert "Empty" in _labels(r)
assert case_of == set()
def test_claiming_a_member_does_not_swallow_its_initializer(tmp_path):
# An enum value can hold a whole expression. Handling the member must not
# stop the walk before it, or everything the initializer declares is lost:
# here the class expression's method disappeared until the `value` was
# walked explicitly.
case_of, r = _extract(tmp_path, {"s.ts": (
"export enum E {\n"
" A = class Inner { m() { return 1; } }.name.length\n"
"}\n"
)})
assert (_find(r, "E"), _find(r, "A")) in case_of
assert "m()" in _labels(r), "the initializer's class expression was not walked"