Skip to content

Commit 879aefe

Browse files
committed
feat: add DM (BYOND DreamMaker) language extractor
Wires tree-sitter-dm into the extraction pipeline so .dm and .dme files become first-class corpus members. PyPI's tree-sitter-dm wheel is broken right now, so the dep installs from the FeudeyTF/tree-sitter-dm git repo. DM's identity model is path-based, not block-based — `/datum/foo/proc/bar` defines `bar` on `/datum/foo` from top level — so the generic class-body walker doesn't fit. extract_dm is a custom AST walk that handles: - type_definition + nested type_proc_definition/type_proc_override - top-level proc_definition with optional type_path prefix - proc_override (no `proc` keyword, type_path required) - preproc_include (resolved to file nodes when the target exists) - call_expression / field_proc_expression as `calls` - new_expression as `instantiates` Same-name ambiguity (e.g. 8 path-distinct `f()` overrides in one file) is left unresolved rather than picked arbitrarily — matches the cross-file resolver's single-match rule. `..()` super-calls are skipped because resolving them needs corpus-wide type hierarchy we don't have at single-file extract time. Smoke-tested across all 660 files in spacestation13/dm-test-suite: 0 extraction errors, 1840 nodes, 1405 edges.
1 parent 299b6ba commit 879aefe

5 files changed

Lines changed: 410 additions & 2 deletions

File tree

graphify/detect.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class FileType(str, Enum):
2424

2525
_MANIFEST_PATH = "graphify-out/manifest.json"
2626

27-
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.mjs', '.ejs', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json'}
27+
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.mjs', '.ejs', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.dm', '.dme'}
2828
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'}
2929
PAPER_EXTENSIONS = {'.pdf'}
3030
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}

graphify/extract.py

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2893,6 +2893,291 @@ def extract_swift(path: Path) -> dict:
28932893
return _extract_generic(path, _SWIFT_CONFIG)
28942894

28952895

2896+
# ── DM (BYOND DreamMaker) extractor ──────────────────────────────────────────
2897+
# DM's identity model is path-based, not block-based: `/datum/object` is a type,
2898+
# and `/datum/object/proc/New()` defines `New` on that type. Methods can also be
2899+
# nested under a `type_definition { ... }` body. The generic class-body walker
2900+
# doesn't fit, so this is a custom extractor in the Julia/Elixir style.
2901+
2902+
def extract_dm(path: Path) -> dict:
2903+
"""Extract types, procs, includes, and calls from a .dm/.dme file."""
2904+
try:
2905+
import tree_sitter_dm as tsdm
2906+
from tree_sitter import Language, Parser
2907+
except ImportError:
2908+
return {"nodes": [], "edges": [], "error": "tree-sitter-dm not installed"}
2909+
try:
2910+
language = Language(tsdm.language())
2911+
parser = Parser(language)
2912+
source = path.read_bytes()
2913+
tree = parser.parse(source)
2914+
root = tree.root_node
2915+
except Exception as e:
2916+
return {"nodes": [], "edges": [], "error": str(e)}
2917+
2918+
stem = _file_stem(path)
2919+
str_path = str(path)
2920+
nodes: list[dict] = []
2921+
edges: list[dict] = []
2922+
seen_ids: set[str] = set()
2923+
# (proc_nid, block_node, owner_type_path_or_None) for the call-graph pass
2924+
function_bodies: list[tuple[str, Any, "str | None"]] = []
2925+
2926+
def add_node(nid: str, label: str, line: int) -> None:
2927+
if nid and nid not in seen_ids:
2928+
seen_ids.add(nid)
2929+
nodes.append({"id": nid, "label": label, "file_type": "code",
2930+
"source_file": str_path, "source_location": f"L{line}"})
2931+
2932+
def add_edge(src: str, tgt: str, relation: str, line: int,
2933+
confidence: str = "EXTRACTED", weight: float = 1.0,
2934+
context: str | None = None) -> None:
2935+
if not src or not tgt or src == tgt:
2936+
return
2937+
edge = {"source": src, "target": tgt, "relation": relation,
2938+
"confidence": confidence, "source_file": str_path,
2939+
"source_location": f"L{line}", "weight": weight}
2940+
if context:
2941+
edge["context"] = context
2942+
edges.append(edge)
2943+
2944+
file_nid = _make_id(str(path))
2945+
add_node(file_nid, path.name, 1)
2946+
2947+
def _type_path_text(node) -> str:
2948+
# type_path includes whitespace-irrelevant tokens like / and identifiers,
2949+
# but no leading/trailing whitespace. Normalise just in case.
2950+
return _read_text(node, source).strip()
2951+
2952+
def _ensure_type(path_text: str, line: int) -> str:
2953+
nid = _make_id(stem, path_text)
2954+
add_node(nid, path_text, line)
2955+
return nid
2956+
2957+
def _find_child(node, type_name: str):
2958+
for c in node.children:
2959+
if c.type == type_name:
2960+
return c
2961+
return None
2962+
2963+
def _read_include_path(file_node) -> str:
2964+
"""Pull the inner path out of a string_literal or file_literal."""
2965+
if file_node is None:
2966+
return ""
2967+
if file_node.type == "string_literal":
2968+
parts = []
2969+
for c in file_node.children:
2970+
if c.type == "string_content":
2971+
parts.append(_read_text(c, source))
2972+
return "".join(parts)
2973+
return _read_text(file_node, source).strip("'\"")
2974+
2975+
def walk(node, parent_type_path: "str | None" = None,
2976+
parent_type_nid: "str | None" = None) -> None:
2977+
t = node.type
2978+
line = node.start_point[0] + 1
2979+
2980+
if t == "preproc_include":
2981+
file_node = node.child_by_field_name("file")
2982+
raw = _read_include_path(file_node)
2983+
if raw:
2984+
# BYOND include paths are typically project-relative with backslashes
2985+
# on Windows-authored projects; normalise to forward slashes.
2986+
norm = raw.replace("\\", "/").lstrip("./")
2987+
resolved = (path.parent / norm).resolve()
2988+
if resolved.exists():
2989+
tgt_nid = _make_id(str(resolved))
2990+
relation = "imports_from"
2991+
else:
2992+
tgt_nid = _make_id(norm)
2993+
relation = "imports"
2994+
edges.append({
2995+
"source": file_nid,
2996+
"target": tgt_nid,
2997+
"relation": relation,
2998+
"context": "import",
2999+
"confidence": "EXTRACTED",
3000+
"source_file": str_path,
3001+
"source_location": f"L{line}",
3002+
"weight": 1.0,
3003+
})
3004+
return
3005+
3006+
if t == "type_definition":
3007+
tp_node = _find_child(node, "type_path")
3008+
if tp_node is None:
3009+
return
3010+
type_path_str = _type_path_text(tp_node)
3011+
type_nid = _ensure_type(type_path_str, line)
3012+
add_edge(file_nid, type_nid, "contains", line)
3013+
body = _find_child(node, "type_body")
3014+
if body is not None:
3015+
for c in body.children:
3016+
walk(c, parent_type_path=type_path_str, parent_type_nid=type_nid)
3017+
return
3018+
3019+
if t in ("type_body_intended", "type_body_braced"):
3020+
for c in node.children:
3021+
walk(c, parent_type_path, parent_type_nid)
3022+
return
3023+
3024+
# Procs defined inside a `type_definition` body — owner is the enclosing type
3025+
if t in ("type_proc_definition", "type_proc_override"):
3026+
if parent_type_nid is None or parent_type_path is None:
3027+
return
3028+
name_node = node.child_by_field_name("name")
3029+
if name_node is None:
3030+
return
3031+
proc_name = _read_text(name_node, source)
3032+
proc_nid = _make_id(stem, parent_type_path, proc_name)
3033+
add_node(proc_nid, f"{parent_type_path}/{proc_name}()", line)
3034+
add_edge(parent_type_nid, proc_nid, "method", line)
3035+
block = _find_child(node, "block")
3036+
if block is not None:
3037+
function_bodies.append((proc_nid, block, parent_type_path))
3038+
return
3039+
3040+
# Top-level procs: `/proc/foo()`, `/datum/foo/proc/bar()`, `/datum/foo/bar()` (override)
3041+
if t in ("proc_definition", "proc_override"):
3042+
tp_node = _find_child(node, "type_path")
3043+
owner_path: "str | None" = None
3044+
owner_nid: "str | None" = None
3045+
if tp_node is not None:
3046+
owner_path = _type_path_text(tp_node)
3047+
owner_nid = _ensure_type(owner_path, line)
3048+
add_edge(file_nid, owner_nid, "contains", line)
3049+
name_node = node.child_by_field_name("name")
3050+
if name_node is None:
3051+
return
3052+
proc_name = _read_text(name_node, source)
3053+
if owner_path and owner_nid:
3054+
proc_nid = _make_id(stem, owner_path, proc_name)
3055+
add_node(proc_nid, f"{owner_path}/{proc_name}()", line)
3056+
add_edge(owner_nid, proc_nid, "method", line)
3057+
else:
3058+
proc_nid = _make_id(stem, proc_name)
3059+
add_node(proc_nid, f"{proc_name}()", line)
3060+
add_edge(file_nid, proc_nid, "contains", line)
3061+
block = _find_child(node, "block")
3062+
if block is not None:
3063+
function_bodies.append((proc_nid, block, owner_path))
3064+
return
3065+
3066+
# operator_override / type_operator_override: the grammar often errors on
3067+
# operator names (e.g. `+`, `[]`) so we just skip them rather than emit
3068+
# half-broken nodes. Their bodies (if present) are still walked below.
3069+
if t in ("operator_override", "type_operator_override"):
3070+
return
3071+
3072+
for child in node.children:
3073+
walk(child, parent_type_path, parent_type_nid)
3074+
3075+
walk(root)
3076+
3077+
# ── Call-graph pass ───────────────────────────────────────────────────────
3078+
# In-file index: last path segment → all matching nids. DM has heavy method
3079+
# overriding by path (e.g. 8 different `f()` procs across `/datum/do/...`
3080+
# subtypes), so we must NOT pick one arbitrarily on collision. Resolve only
3081+
# when exactly one match exists; otherwise defer to cross-file resolution,
3082+
# which has the same single-match rule.
3083+
label_to_nids: dict[str, list[str]] = {}
3084+
# Type paths get an additional full-label index so `new /datum/weapon/sword()`
3085+
# finds the type node directly. Without this, the type path falls through to
3086+
# last-segment lookup and never matches the indexed "sword" entry's full id.
3087+
path_to_nids: dict[str, list[str]] = {}
3088+
for n in nodes:
3089+
label = n["label"].strip("()")
3090+
last = label.rsplit("/", 1)[-1] if "/" in label else label
3091+
if last:
3092+
label_to_nids.setdefault(last.lower(), []).append(n["id"])
3093+
if label.startswith("/"):
3094+
path_to_nids.setdefault(label.lower(), []).append(n["id"])
3095+
3096+
seen_call_pairs: set[tuple[str, str]] = set()
3097+
raw_calls: list[dict] = []
3098+
3099+
def _emit_call(caller_nid: str, callee: str, line: int, is_member: bool) -> None:
3100+
candidates = label_to_nids.get(callee.lower(), [])
3101+
tgt_nid = candidates[0] if len(candidates) == 1 else None
3102+
if tgt_nid and tgt_nid != caller_nid:
3103+
pair = (caller_nid, tgt_nid)
3104+
if pair in seen_call_pairs:
3105+
return
3106+
seen_call_pairs.add(pair)
3107+
edges.append({
3108+
"source": caller_nid,
3109+
"target": tgt_nid,
3110+
"relation": "calls",
3111+
"context": "call",
3112+
"confidence": "EXTRACTED",
3113+
"source_file": str_path,
3114+
"source_location": f"L{line}",
3115+
"weight": 1.0,
3116+
})
3117+
else:
3118+
raw_calls.append({
3119+
"caller_nid": caller_nid,
3120+
"callee": callee,
3121+
"is_member_call": is_member,
3122+
"source_file": str_path,
3123+
"source_location": f"L{line}",
3124+
})
3125+
3126+
def walk_calls(body_node, caller_nid: str) -> None:
3127+
if body_node is None:
3128+
return
3129+
t = body_node.type
3130+
# Don't descend into nested defs — they get their own call pass.
3131+
if t in ("proc_definition", "proc_override", "type_proc_definition",
3132+
"type_proc_override", "type_definition"):
3133+
return
3134+
if t == "call_expression":
3135+
name_node = body_node.child_by_field_name("name")
3136+
if name_node is not None:
3137+
callee = _read_text(name_node, source)
3138+
# `..` is a super-call; resolving it correctly needs a corpus-wide
3139+
# type hierarchy, which we don't have at single-file extraction
3140+
# time. Skip rather than emit a wrong edge.
3141+
if callee and callee != "..":
3142+
_emit_call(caller_nid, callee, body_node.start_point[0] + 1,
3143+
is_member=False)
3144+
elif t == "field_proc_expression":
3145+
proc_field = body_node.child_by_field_name("proc")
3146+
if proc_field is not None:
3147+
callee = _read_text(proc_field, source)
3148+
if callee:
3149+
_emit_call(caller_nid, callee, body_node.start_point[0] + 1,
3150+
is_member=True)
3151+
elif t == "new_expression":
3152+
tp_node = _find_child(body_node, "type_path")
3153+
if tp_node is not None:
3154+
target_text = _type_path_text(tp_node)
3155+
candidates = path_to_nids.get(target_text.lower(), [])
3156+
tgt_nid = candidates[0] if len(candidates) == 1 else None
3157+
if tgt_nid and tgt_nid != caller_nid:
3158+
pair = (caller_nid, tgt_nid)
3159+
if pair not in seen_call_pairs:
3160+
seen_call_pairs.add(pair)
3161+
edges.append({
3162+
"source": caller_nid,
3163+
"target": tgt_nid,
3164+
"relation": "instantiates",
3165+
"context": "call",
3166+
"confidence": "EXTRACTED",
3167+
"source_file": str_path,
3168+
"source_location": f"L{body_node.start_point[0] + 1}",
3169+
"weight": 1.0,
3170+
})
3171+
3172+
for child in body_node.children:
3173+
walk_calls(child, caller_nid)
3174+
3175+
for proc_nid, block, _owner_path in function_bodies:
3176+
walk_calls(block, proc_nid)
3177+
3178+
return {"nodes": nodes, "edges": edges, "raw_calls": raw_calls}
3179+
3180+
28963181
# ── Julia extractor (custom walk) ────────────────────────────────────────────
28973182

28983183
def extract_julia(path: Path) -> dict:
@@ -6062,6 +6347,8 @@ def walk_object(obj_node, parent_nid: str, parent_key: str | None,
60626347
".sh": extract_bash,
60636348
".bash": extract_bash,
60646349
".json": extract_json,
6350+
".dm": extract_dm,
6351+
".dme": extract_dm,
60656352
}
60666353

60676354

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ dependencies = [
4040
"tree-sitter-fortran",
4141
"tree-sitter-bash",
4242
"tree-sitter-json",
43+
# tree-sitter-dm PyPI wheel is currently broken; install from git instead.
44+
"tree-sitter-dm @ git+https://github.com/FeudeyTF/tree-sitter-dm.git",
4345
]
4446

4547
[project.urls]

tests/fixtures/sample.dm

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#include "helpers.dm"
2+
3+
var/global_counter = 0
4+
5+
/proc/log_event(msg)
6+
world.log << msg
7+
global_counter++
8+
9+
/datum/weapon
10+
var/damage = 10
11+
var/name = "weapon"
12+
13+
proc/attack(mob/target)
14+
log_event("attack")
15+
target.take_damage(damage)
16+
return damage
17+
18+
New()
19+
log_event("weapon created")
20+
21+
/datum/weapon/sword
22+
damage = 20
23+
name = "sword"
24+
25+
/datum/weapon/sword/proc/sharpen()
26+
damage += 5
27+
log_event("sharpened")
28+
29+
/datum/weapon/sword/attack(mob/target)
30+
sharpen()
31+
return ..()
32+
33+
/proc/RunTest()
34+
var/datum/weapon/sword/s = new /datum/weapon/sword()
35+
s.attack(null)

0 commit comments

Comments
 (0)