Skip to content

Commit 6b873b4

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 6b873b4

5 files changed

Lines changed: 409 additions & 3 deletions

File tree

graphify/detect.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ 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'}
30-
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
30+
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.dmi'}
3131
OFFICE_EXTENSIONS = {'.docx', '.xlsx'}
3232
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.mp3', '.wav', '.m4a', '.ogg'}
3333

graphify/extract.py

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

28952895

2896+
# ── DM (BYOND DreamMaker) extractor (custom walk) ────────────────────────────
2897+
# DM identity is path-based (`/datum/object/proc/New()`), not block-based, so
2898+
# the generic class-body walker doesn't fit.
2899+
2900+
def extract_dm(path: Path) -> dict:
2901+
"""Extract types, procs, includes, and calls from a .dm/.dme file."""
2902+
try:
2903+
import tree_sitter_dm as tsdm
2904+
from tree_sitter import Language, Parser
2905+
except ImportError:
2906+
return {"nodes": [], "edges": [], "error": "tree-sitter-dm not installed"}
2907+
try:
2908+
language = Language(tsdm.language())
2909+
parser = Parser(language)
2910+
source = path.read_bytes()
2911+
tree = parser.parse(source)
2912+
root = tree.root_node
2913+
except Exception as e:
2914+
return {"nodes": [], "edges": [], "error": str(e)}
2915+
2916+
stem = _file_stem(path)
2917+
str_path = str(path)
2918+
nodes: list[dict] = []
2919+
edges: list[dict] = []
2920+
seen_ids: set[str] = set()
2921+
function_bodies: list[tuple[str, Any, "str | None"]] = []
2922+
2923+
def add_node(nid: str, label: str, line: int) -> None:
2924+
if nid and nid not in seen_ids:
2925+
seen_ids.add(nid)
2926+
nodes.append({"id": nid, "label": label, "file_type": "code",
2927+
"source_file": str_path, "source_location": f"L{line}"})
2928+
2929+
def add_edge(src: str, tgt: str, relation: str, line: int,
2930+
confidence: str = "EXTRACTED", weight: float = 1.0,
2931+
context: str | None = None) -> None:
2932+
if not src or not tgt or src == tgt:
2933+
return
2934+
edge = {"source": src, "target": tgt, "relation": relation,
2935+
"confidence": confidence, "source_file": str_path,
2936+
"source_location": f"L{line}", "weight": weight}
2937+
if context:
2938+
edge["context"] = context
2939+
edges.append(edge)
2940+
2941+
file_nid = _make_id(str(path))
2942+
add_node(file_nid, path.name, 1)
2943+
2944+
def _type_path_text(node) -> str:
2945+
return _read_text(node, source).strip()
2946+
2947+
def _ensure_type(path_text: str, line: int) -> str:
2948+
nid = _make_id(stem, path_text)
2949+
add_node(nid, path_text, line)
2950+
return nid
2951+
2952+
def _find_child(node, type_name: str):
2953+
for c in node.children:
2954+
if c.type == type_name:
2955+
return c
2956+
return None
2957+
2958+
def _read_include_path(file_node) -> str:
2959+
"""Pull the inner path out of a string_literal or file_literal."""
2960+
if file_node is None:
2961+
return ""
2962+
if file_node.type == "string_literal":
2963+
parts = []
2964+
for c in file_node.children:
2965+
if c.type == "string_content":
2966+
parts.append(_read_text(c, source))
2967+
return "".join(parts)
2968+
return _read_text(file_node, source).strip("'\"")
2969+
2970+
def walk(node, parent_type_path: "str | None" = None,
2971+
parent_type_nid: "str | None" = None) -> None:
2972+
t = node.type
2973+
line = node.start_point[0] + 1
2974+
2975+
if t == "preproc_include":
2976+
file_node = node.child_by_field_name("file")
2977+
raw = _read_include_path(file_node)
2978+
if raw:
2979+
# BYOND projects on Windows author includes with backslashes.
2980+
norm = raw.replace("\\", "/").lstrip("./")
2981+
resolved = (path.parent / norm).resolve()
2982+
edge = {
2983+
"source": file_nid,
2984+
"target": _make_id(str(resolved)) if resolved.exists() else _make_id(norm),
2985+
"relation": "imports_from" if resolved.exists() else "imports",
2986+
"context": "import",
2987+
"confidence": "EXTRACTED",
2988+
"source_file": str_path,
2989+
"source_location": f"L{line}",
2990+
"weight": 1.0,
2991+
}
2992+
# If the include doesn't exist on disk it's a BYOND stdlib or
2993+
# external dep; mark it so downstream consumers reading raw
2994+
# extractions can skip without re-deriving that.
2995+
if not resolved.exists():
2996+
edge["external"] = True
2997+
edges.append(edge)
2998+
return
2999+
3000+
if t == "type_definition":
3001+
tp_node = _find_child(node, "type_path")
3002+
if tp_node is None:
3003+
return
3004+
type_path_str = _type_path_text(tp_node)
3005+
type_nid = _ensure_type(type_path_str, line)
3006+
add_edge(file_nid, type_nid, "contains", line)
3007+
body = _find_child(node, "type_body")
3008+
if body is not None:
3009+
for c in body.children:
3010+
walk(c, parent_type_path=type_path_str, parent_type_nid=type_nid)
3011+
return
3012+
3013+
if t in ("type_body_intended", "type_body_braced"):
3014+
for c in node.children:
3015+
walk(c, parent_type_path, parent_type_nid)
3016+
return
3017+
3018+
if t in ("type_proc_definition", "type_proc_override"):
3019+
if parent_type_nid is None or parent_type_path is None:
3020+
return
3021+
name_node = node.child_by_field_name("name")
3022+
if name_node is None:
3023+
return
3024+
proc_name = _read_text(name_node, source)
3025+
proc_nid = _make_id(stem, parent_type_path, proc_name)
3026+
add_node(proc_nid, f"{parent_type_path}/{proc_name}()", line)
3027+
add_edge(parent_type_nid, proc_nid, "method", line)
3028+
block = _find_child(node, "block")
3029+
if block is not None:
3030+
function_bodies.append((proc_nid, block, parent_type_path))
3031+
return
3032+
3033+
# `/proc/foo()`, `/datum/foo/proc/bar()`, or `/datum/foo/bar()` override.
3034+
if t in ("proc_definition", "proc_override"):
3035+
tp_node = _find_child(node, "type_path")
3036+
owner_path: "str | None" = None
3037+
owner_nid: "str | None" = None
3038+
if tp_node is not None:
3039+
owner_path = _type_path_text(tp_node)
3040+
owner_nid = _ensure_type(owner_path, line)
3041+
add_edge(file_nid, owner_nid, "contains", line)
3042+
name_node = node.child_by_field_name("name")
3043+
if name_node is None:
3044+
return
3045+
proc_name = _read_text(name_node, source)
3046+
if owner_path and owner_nid:
3047+
proc_nid = _make_id(stem, owner_path, proc_name)
3048+
add_node(proc_nid, f"{owner_path}/{proc_name}()", line)
3049+
add_edge(owner_nid, proc_nid, "method", line)
3050+
else:
3051+
proc_nid = _make_id(stem, proc_name)
3052+
add_node(proc_nid, f"{proc_name}()", line)
3053+
add_edge(file_nid, proc_nid, "contains", line)
3054+
block = _find_child(node, "block")
3055+
if block is not None:
3056+
function_bodies.append((proc_nid, block, owner_path))
3057+
return
3058+
3059+
# The grammar mis-parses operator overload names (`+`, `[]`, …), so skip
3060+
# rather than emit half-broken nodes.
3061+
if t in ("operator_override", "type_operator_override"):
3062+
return
3063+
3064+
for child in node.children:
3065+
walk(child, parent_type_path, parent_type_nid)
3066+
3067+
walk(root)
3068+
3069+
# ── Call-graph pass ───────────────────────────────────────────────────────
3070+
# Two indexes: last-segment for proc lookups, full-path for `new`. DM has
3071+
# heavy method overriding by path (e.g. 8 same-named overrides in one file),
3072+
# so on last-segment collision we resolve only when there's exactly one
3073+
# match — same rule the cross-file resolver uses.
3074+
label_to_nids: dict[str, list[str]] = {}
3075+
path_to_nids: dict[str, list[str]] = {}
3076+
for n in nodes:
3077+
label = n["label"].strip("()")
3078+
last = label.rsplit("/", 1)[-1] if "/" in label else label
3079+
if last:
3080+
label_to_nids.setdefault(last.lower(), []).append(n["id"])
3081+
if label.startswith("/"):
3082+
path_to_nids.setdefault(label.lower(), []).append(n["id"])
3083+
3084+
seen_call_pairs: set[tuple[str, str]] = set()
3085+
raw_calls: list[dict] = []
3086+
3087+
def _emit_call(caller_nid: str, callee: str, line: int, is_member: bool) -> None:
3088+
candidates = label_to_nids.get(callee.lower(), [])
3089+
tgt_nid = candidates[0] if len(candidates) == 1 else None
3090+
if tgt_nid and tgt_nid != caller_nid:
3091+
pair = (caller_nid, tgt_nid)
3092+
if pair in seen_call_pairs:
3093+
return
3094+
seen_call_pairs.add(pair)
3095+
edges.append({
3096+
"source": caller_nid,
3097+
"target": tgt_nid,
3098+
"relation": "calls",
3099+
"context": "call",
3100+
"confidence": "EXTRACTED",
3101+
"source_file": str_path,
3102+
"source_location": f"L{line}",
3103+
"weight": 1.0,
3104+
})
3105+
else:
3106+
raw_calls.append({
3107+
"caller_nid": caller_nid,
3108+
"callee": callee,
3109+
"is_member_call": is_member,
3110+
"source_file": str_path,
3111+
"source_location": f"L{line}",
3112+
})
3113+
3114+
def walk_calls(body_node, caller_nid: str) -> None:
3115+
if body_node is None:
3116+
return
3117+
t = body_node.type
3118+
if t in ("proc_definition", "proc_override", "type_proc_definition",
3119+
"type_proc_override", "type_definition"):
3120+
return
3121+
if t == "call_expression":
3122+
name_node = body_node.child_by_field_name("name")
3123+
if name_node is not None:
3124+
callee = _read_text(name_node, source)
3125+
# `..` is a super-call; resolving it needs corpus-wide type
3126+
# hierarchy we don't have here, so skip rather than guess.
3127+
if callee and callee != "..":
3128+
_emit_call(caller_nid, callee, body_node.start_point[0] + 1,
3129+
is_member=False)
3130+
elif t == "field_proc_expression":
3131+
proc_field = body_node.child_by_field_name("proc")
3132+
if proc_field is not None:
3133+
callee = _read_text(proc_field, source)
3134+
if callee:
3135+
_emit_call(caller_nid, callee, body_node.start_point[0] + 1,
3136+
is_member=True)
3137+
elif t == "new_expression":
3138+
tp_node = _find_child(body_node, "type_path")
3139+
if tp_node is not None:
3140+
target_text = _type_path_text(tp_node)
3141+
candidates = path_to_nids.get(target_text.lower(), [])
3142+
tgt_nid = candidates[0] if len(candidates) == 1 else None
3143+
if tgt_nid and tgt_nid != caller_nid:
3144+
pair = (caller_nid, tgt_nid)
3145+
if pair not in seen_call_pairs:
3146+
seen_call_pairs.add(pair)
3147+
edges.append({
3148+
"source": caller_nid,
3149+
"target": tgt_nid,
3150+
"relation": "instantiates",
3151+
"context": "call",
3152+
"confidence": "EXTRACTED",
3153+
"source_file": str_path,
3154+
"source_location": f"L{body_node.start_point[0] + 1}",
3155+
"weight": 1.0,
3156+
})
3157+
3158+
for child in body_node.children:
3159+
walk_calls(child, caller_nid)
3160+
3161+
for proc_nid, block, _owner_path in function_bodies:
3162+
walk_calls(block, proc_nid)
3163+
3164+
return {"nodes": nodes, "edges": edges, "raw_calls": raw_calls}
3165+
3166+
28963167
# ── Julia extractor (custom walk) ────────────────────────────────────────────
28973168

28983169
def extract_julia(path: Path) -> dict:
@@ -6062,6 +6333,8 @@ def walk_object(obj_node, parent_nid: str, parent_key: str | None,
60626333
".sh": extract_bash,
60636334
".bash": extract_bash,
60646335
".json": extract_json,
6336+
".dm": extract_dm,
6337+
".dme": extract_dm,
60656338
}
60666339

60676340

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ dependencies = [
4040
"tree-sitter-fortran",
4141
"tree-sitter-bash",
4242
"tree-sitter-json",
43+
# tree-sitter-dm 0.25.0 PyPI sdist is missing scanner.c; pinning to git
44+
# until the next release (the fix is already on master).
45+
"tree-sitter-dm @ git+https://github.com/FeudeyTF/tree-sitter-dm.git",
4346
]
4447

4548
[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)