@@ -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
28983183def 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
0 commit comments