Skip to main content

codesynapse_core/ts_extract/
dm.rs

1use super::make_file_node;
2use crate::error::Result;
3use crate::extract::make_id;
4use crate::types::{Edge, ExtractionFragment, Node};
5use std::collections::{HashMap, HashSet};
6use std::path::Path;
7
8// ─── DM (DreamMaker source) ───────────────────────────────────────────────────
9
10pub struct DmExtractor;
11
12impl DmExtractor {
13    pub fn extract(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
14        let (file_id, _file_label, file_node) = make_file_node(path);
15        let str_path = path.to_string_lossy().to_string();
16
17        let mut nodes: Vec<Node> = vec![file_node];
18        let mut edges: Vec<Edge> = vec![];
19        let mut seen_ids: HashSet<String> = HashSet::new();
20        seen_ids.insert(file_id.clone());
21
22        let text = std::str::from_utf8(source).unwrap_or("");
23        let stem = file_id.clone();
24
25        // Tracks (type_path, type_nid) when inside a type block
26        let mut current_type: Option<(String, String)> = None;
27        // Tracks proc_nid of current proc whose body we're collecting calls for
28        let mut current_proc: Option<String> = None;
29        // Minimum indent for body lines of current_proc
30        let mut proc_body_min_indent: usize = 0;
31
32        // path_to_nid: type_path → nid, for `new` resolution
33        let mut path_to_nid: HashMap<String, String> = HashMap::new();
34        // name_to_nids: simple proc name (lowercase) → list of nid
35        let mut name_to_nids: HashMap<String, Vec<String>> = HashMap::new();
36
37        // Deferred calls: (caller_nid, callee_name, line_num)
38        let mut pending_calls: Vec<(String, String, usize)> = vec![];
39        // Deferred instantiates: (caller_nid, type_path, line_num)
40        let mut pending_news: Vec<(String, String, usize)> = vec![];
41
42        let add_node =
43            |nodes: &mut Vec<Node>, seen: &mut HashSet<String>, id: String, label: String| {
44                if seen.insert(id.clone()) {
45                    nodes.push(Node {
46                        id,
47                        label,
48                        file_type: "code".to_string(),
49                        source_file: str_path.clone(),
50                        source_location: None,
51                        community: None,
52                        rationale: None,
53                        docstring: None,
54                        metadata: HashMap::new(),
55                    });
56                }
57            };
58
59        let make_edge = |src: &str, tgt: &str, relation: &str, context: Option<&str>| Edge {
60            source: src.to_string(),
61            target: tgt.to_string(),
62            relation: relation.to_string(),
63            confidence: "EXTRACTED".to_string(),
64            source_file: Some(str_path.clone()),
65            weight: 1.0,
66            context: context.map(|s| s.to_string()),
67        };
68
69        // Count leading tab chars
70        let indent_of = |line: &str| line.chars().take_while(|&c| c == '\t').count();
71
72        for (line_idx, raw_line) in text.lines().enumerate() {
73            let tabs = indent_of(raw_line);
74            let trimmed = raw_line.trim_start_matches('\t');
75
76            if trimmed.is_empty() || trimmed.starts_with("//") {
77                continue;
78            }
79
80            // Reset proc context if we're back to a shallower indent
81            if current_proc.is_some() && tabs < proc_body_min_indent {
82                current_proc = None;
83            }
84
85            if tabs == 0 {
86                // Top-level line
87                current_type = None;
88                current_proc = None;
89
90                if trimmed.starts_with("#include") {
91                    // Parse include path
92                    let raw_inc = trimmed
93                        .trim_start_matches("#include")
94                        .trim()
95                        .trim_matches(|c| c == '"' || c == '\'' || c == '<' || c == '>');
96                    if !raw_inc.is_empty() {
97                        let norm = raw_inc.replace('\\', "/");
98                        let target_file = path.parent().and_then(|p| {
99                            let candidate = p.join(&norm);
100                            if candidate.exists() {
101                                Some(candidate)
102                            } else {
103                                None
104                            }
105                        });
106                        let (tgt_id, relation) = match &target_file {
107                            Some(resolved) => (
108                                make_id(&[resolved.to_string_lossy().as_ref()]),
109                                "imports_from",
110                            ),
111                            None => {
112                                let base = norm
113                                    .rsplit('/')
114                                    .next()
115                                    .unwrap_or(&norm)
116                                    .trim_end_matches(".dm");
117                                (make_id(&[base]), "imports")
118                            }
119                        };
120                        edges.push(make_edge(&file_id, &tgt_id, relation, Some("import")));
121                    }
122                    continue;
123                }
124
125                if trimmed.starts_with("var/") {
126                    continue; // global var
127                }
128
129                if !trimmed.starts_with('/') {
130                    continue;
131                }
132
133                // Parse declaration starting with '/'
134                if let Some(paren_pos) = trimmed.find('(') {
135                    let full_path = &trimmed[..paren_pos];
136                    let components: Vec<&str> =
137                        full_path.split('/').filter(|s| !s.is_empty()).collect();
138
139                    if components.is_empty() {
140                        continue;
141                    }
142
143                    if components[0] == "proc" {
144                        // Global proc: /proc/name(...)
145                        let proc_name = if components.len() >= 2 {
146                            components[1]
147                        } else {
148                            continue;
149                        };
150                        let label = format!("{}()", proc_name);
151                        let nid = make_id(&[&stem, proc_name]);
152                        add_node(&mut nodes, &mut seen_ids, nid.clone(), label.clone());
153                        edges.push(make_edge(&file_id, &nid, "contains", None));
154                        let simple = proc_name.to_lowercase();
155                        name_to_nids.entry(simple).or_default().push(nid.clone());
156                        current_proc = Some(nid);
157                        proc_body_min_indent = 1;
158                    } else if components.len() >= 2 && components[components.len() - 2] == "proc" {
159                        // Path-form proc def: /type/path/proc/name(...)
160                        let type_components = &components[..components.len() - 2];
161                        let proc_name = components[components.len() - 1];
162                        let type_path = format!("/{}", type_components.join("/"));
163                        let type_id_part = type_components.join("_");
164                        let type_nid = make_id(&[&stem, &type_id_part]);
165                        // Ensure type node exists
166                        if seen_ids.insert(type_nid.clone()) {
167                            nodes.push(Node {
168                                id: type_nid.clone(),
169                                label: type_path.clone(),
170                                file_type: "code".to_string(),
171                                source_file: str_path.clone(),
172                                source_location: None,
173                                community: None,
174                                rationale: None,
175                                docstring: None,
176                                metadata: HashMap::new(),
177                            });
178                            edges.push(make_edge(&file_id, &type_nid, "contains", None));
179                            path_to_nid.insert(type_path.clone(), type_nid.clone());
180                        }
181                        let proc_label = format!("{}/{}()", type_path, proc_name);
182                        let proc_id_part = format!("{}_{}", type_id_part, proc_name);
183                        let proc_nid = make_id(&[&stem, &proc_id_part]);
184                        add_node(
185                            &mut nodes,
186                            &mut seen_ids,
187                            proc_nid.clone(),
188                            proc_label.clone(),
189                        );
190                        edges.push(make_edge(&type_nid, &proc_nid, "method", None));
191                        let simple = proc_name.to_lowercase();
192                        name_to_nids
193                            .entry(simple)
194                            .or_default()
195                            .push(proc_nid.clone());
196                        current_proc = Some(proc_nid);
197                        proc_body_min_indent = 1;
198                    } else {
199                        // Path-form override: /type/path/name(...)
200                        let type_components = &components[..components.len() - 1];
201                        let proc_name = components[components.len() - 1];
202                        let type_path = if type_components.is_empty() {
203                            String::new()
204                        } else {
205                            format!("/{}", type_components.join("/"))
206                        };
207                        let proc_label = if type_path.is_empty() {
208                            format!("{}()", proc_name)
209                        } else {
210                            format!("{}/{}()", type_path, proc_name)
211                        };
212                        let type_id_part = type_components.join("_");
213                        let proc_id_part = if type_id_part.is_empty() {
214                            proc_name.to_string()
215                        } else {
216                            format!("{}_{}", type_id_part, proc_name)
217                        };
218                        let proc_nid = make_id(&[&stem, &proc_id_part]);
219                        // Ensure type node
220                        if !type_path.is_empty() {
221                            let type_nid = make_id(&[&stem, &type_id_part]);
222                            if seen_ids.insert(type_nid.clone()) {
223                                nodes.push(Node {
224                                    id: type_nid.clone(),
225                                    label: type_path.clone(),
226                                    file_type: "code".to_string(),
227                                    source_file: str_path.clone(),
228                                    source_location: None,
229                                    community: None,
230                                    rationale: None,
231                                    docstring: None,
232                                    metadata: HashMap::new(),
233                                });
234                                edges.push(make_edge(&file_id, &type_nid, "contains", None));
235                                path_to_nid.insert(type_path.clone(), type_nid.clone());
236                            }
237                            let type_nid = make_id(&[&stem, &type_id_part]);
238                            add_node(&mut nodes, &mut seen_ids, proc_nid.clone(), proc_label);
239                            edges.push(make_edge(&type_nid, &proc_nid, "method", None));
240                        } else {
241                            add_node(&mut nodes, &mut seen_ids, proc_nid.clone(), proc_label);
242                            edges.push(make_edge(&file_id, &proc_nid, "contains", None));
243                        }
244                        let simple = proc_name.to_lowercase();
245                        name_to_nids
246                            .entry(simple)
247                            .or_default()
248                            .push(proc_nid.clone());
249                        current_proc = Some(proc_nid);
250                        proc_body_min_indent = 1;
251                    }
252                } else {
253                    // Type definition: /datum/weapon (no paren)
254                    let type_path = trimmed.trim().to_string();
255                    let components: Vec<&str> =
256                        type_path.split('/').filter(|s| !s.is_empty()).collect();
257                    let type_id_part = components.join("_");
258                    let type_nid = make_id(&[&stem, &type_id_part]);
259                    if seen_ids.insert(type_nid.clone()) {
260                        nodes.push(Node {
261                            id: type_nid.clone(),
262                            label: type_path.clone(),
263                            file_type: "code".to_string(),
264                            source_file: str_path.clone(),
265                            source_location: None,
266                            community: None,
267                            rationale: None,
268                            docstring: None,
269                            metadata: HashMap::new(),
270                        });
271                        edges.push(make_edge(&file_id, &type_nid, "contains", None));
272                        path_to_nid.insert(type_path.clone(), type_nid.clone());
273                    }
274                    current_type = Some((type_path, type_nid));
275                }
276            } else if tabs == 1 {
277                if let Some((ref type_path, ref type_nid)) = current_type.clone() {
278                    // Inside type block: check if this is a method declaration
279                    if trimmed.starts_with("var/") || trimmed.starts_with("//") {
280                        // type variable or comment, skip
281                    } else if let Some(paren_pos) = trimmed.find('(') {
282                        let before_paren = &trimmed[..paren_pos];
283                        // Could be: proc/name(args) or Name(args)
284                        let proc_name = if let Some(stripped) = before_paren.strip_prefix("proc/") {
285                            stripped.trim()
286                        } else {
287                            // bare name like New()
288                            before_paren.trim()
289                        };
290                        if !proc_name.is_empty()
291                            && proc_name.chars().all(|c| c.is_alphanumeric() || c == '_')
292                        {
293                            let proc_label = format!("{}/{}()", type_path, proc_name);
294                            let type_id_part = type_path
295                                .split('/')
296                                .filter(|s| !s.is_empty())
297                                .collect::<Vec<_>>()
298                                .join("_");
299                            let proc_id_part = format!("{}_{}", type_id_part, proc_name);
300                            let proc_nid = make_id(&[&stem, &proc_id_part]);
301                            add_node(&mut nodes, &mut seen_ids, proc_nid.clone(), proc_label);
302                            edges.push(make_edge(type_nid, &proc_nid, "method", None));
303                            let simple = proc_name.to_lowercase();
304                            name_to_nids
305                                .entry(simple)
306                                .or_default()
307                                .push(proc_nid.clone());
308                            current_proc = Some(proc_nid);
309                            proc_body_min_indent = 2;
310                        }
311                    } else {
312                        // Not a method, clear current_proc (type var assignment, etc.)
313                        current_proc = None;
314                    }
315                } else if let Some(ref caller_nid) = current_proc.clone() {
316                    if proc_body_min_indent <= 1 {
317                        // Body line of a col-0 proc
318                        collect_calls(
319                            trimmed,
320                            caller_nid,
321                            &mut pending_calls,
322                            &mut pending_news,
323                            line_idx,
324                        );
325                    }
326                }
327            } else {
328                // tabs >= 2
329                if let Some(ref caller_nid) = current_proc.clone() {
330                    if tabs >= proc_body_min_indent {
331                        collect_calls(
332                            trimmed,
333                            caller_nid,
334                            &mut pending_calls,
335                            &mut pending_news,
336                            line_idx,
337                        );
338                    }
339                }
340            }
341        }
342
343        // Pass 2: resolve calls
344        let mut seen_call_pairs: HashSet<(String, String)> = HashSet::new();
345        for (caller_nid, callee_name, _line) in &pending_calls {
346            if callee_name == ".." {
347                continue;
348            }
349            let lower = callee_name.to_lowercase();
350            if let Some(candidates) = name_to_nids.get(&lower) {
351                if candidates.len() == 1 {
352                    let tgt = &candidates[0];
353                    if tgt != caller_nid {
354                        let pair = (caller_nid.clone(), tgt.clone());
355                        if seen_call_pairs.insert(pair) {
356                            edges.push(Edge {
357                                source: caller_nid.clone(),
358                                target: tgt.clone(),
359                                relation: "calls".to_string(),
360                                confidence: "EXTRACTED".to_string(),
361                                source_file: Some(str_path.clone()),
362                                weight: 1.0,
363                                context: Some("call".to_string()),
364                            });
365                        }
366                    }
367                }
368                // else: ambiguous → raw_call (not emitted)
369            }
370            // else: unknown → raw_call (not emitted)
371        }
372
373        // Resolve `new` expressions
374        for (caller_nid, type_path, _line) in &pending_news {
375            if let Some(tgt_nid) = path_to_nid.get(type_path) {
376                if tgt_nid != caller_nid {
377                    let pair = (caller_nid.clone(), tgt_nid.clone());
378                    if seen_call_pairs.insert(pair) {
379                        edges.push(Edge {
380                            source: caller_nid.clone(),
381                            target: tgt_nid.clone(),
382                            relation: "instantiates".to_string(),
383                            confidence: "EXTRACTED".to_string(),
384                            source_file: Some(str_path.clone()),
385                            weight: 1.0,
386                            context: Some("call".to_string()),
387                        });
388                    }
389                }
390            }
391        }
392
393        Ok(ExtractionFragment { nodes, edges })
394    }
395}
396
397fn collect_calls(
398    line: &str,
399    caller_nid: &str,
400    pending_calls: &mut Vec<(String, String, usize)>,
401    pending_news: &mut Vec<(String, String, usize)>,
402    line_idx: usize,
403) {
404    // Detect `new /type/path` patterns
405    let mut rest = line;
406    while let Some(pos) = rest.find("new ") {
407        let after = rest[pos + 4..].trim_start();
408        if after.starts_with('/') {
409            let end = after
410                .find(|c: char| !c.is_alphanumeric() && c != '/' && c != '_')
411                .unwrap_or(after.len());
412            let type_path = &after[..end];
413            if !type_path.is_empty() {
414                pending_news.push((caller_nid.to_string(), type_path.to_string(), line_idx));
415            }
416        }
417        rest = &rest[pos + 4..];
418    }
419
420    // Detect `identifier(` call patterns
421    let mut chars = line.char_indices().peekable();
422    while let Some((i, c)) = chars.next() {
423        if c.is_alphabetic() || c == '_' {
424            // Collect identifier
425            let start = i;
426            let mut end = i + c.len_utf8();
427            while let Some(&(j, nc)) = chars.peek() {
428                if nc.is_alphanumeric() || nc == '_' {
429                    end = j + nc.len_utf8();
430                    chars.next();
431                } else {
432                    break;
433                }
434            }
435            // Check if followed by '('
436            if let Some(&(_, '(')) = chars.peek() {
437                let ident = &line[start..end];
438                // Skip DM keywords and language constructs
439                if !is_dm_keyword(ident) && ident != ".." {
440                    // Check if preceded by '.' (member call)
441                    let preceded_by_dot =
442                        start > 0 && line.as_bytes().get(start - 1) == Some(&b'.');
443                    let _ = preceded_by_dot; // we don't distinguish for resolution
444                    pending_calls.push((caller_nid.to_string(), ident.to_string(), line_idx));
445                }
446            }
447        }
448    }
449}
450
451fn is_dm_keyword(s: &str) -> bool {
452    matches!(
453        s,
454        "if" | "else"
455            | "for"
456            | "while"
457            | "switch"
458            | "return"
459            | "var"
460            | "spawn"
461            | "new"
462            | "del"
463            | "null"
464            | "TRUE"
465            | "FALSE"
466            | "src"
467            | "usr"
468            | "world"
469            | "global"
470            | "proc"
471            | "verb"
472            | "list"
473            | "datum"
474            | "atom"
475            | "mob"
476            | "obj"
477            | "turf"
478            | "area"
479    )
480}
481
482// ─── DMI (BYOND icon sheets) ─────────────────────────────────────────────────
483
484pub struct DmiExtractor;
485
486impl DmiExtractor {
487    pub fn extract(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
488        let (file_id, _file_label, file_node) = make_file_node(path);
489        let str_path = path.to_string_lossy().to_string();
490        let stem = file_id.clone();
491
492        let mut nodes: Vec<Node> = vec![file_node];
493        let mut edges: Vec<Edge> = vec![];
494        let mut seen: HashSet<String> = HashSet::new();
495        seen.insert(file_id.clone());
496
497        let description = read_dmi_description(source);
498        for raw_line in description.lines() {
499            let stripped = raw_line.trim();
500            if !stripped.starts_with("state =") {
501                continue;
502            }
503            let value = stripped["state =".len()..].trim();
504            let state_name = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
505                &value[1..value.len() - 1]
506            } else {
507                value
508            };
509            if state_name.is_empty() {
510                continue;
511            }
512            let nid = make_id(&[&stem, "state", state_name]);
513            if seen.insert(nid.clone()) {
514                nodes.push(Node {
515                    id: nid.clone(),
516                    label: format!("\"{}\"", state_name),
517                    file_type: "code".to_string(),
518                    source_file: str_path.clone(),
519                    source_location: None,
520                    community: None,
521                    rationale: None,
522                    docstring: None,
523                    metadata: HashMap::new(),
524                });
525                edges.push(Edge {
526                    source: file_id.clone(),
527                    target: nid,
528                    relation: "contains".to_string(),
529                    confidence: "EXTRACTED".to_string(),
530                    source_file: Some(str_path.clone()),
531                    weight: 1.0,
532                    context: None,
533                });
534            }
535        }
536
537        Ok(ExtractionFragment { nodes, edges })
538    }
539}
540
541fn read_dmi_description(data: &[u8]) -> String {
542    const PNG_HEADER: &[u8] = b"\x89PNG\r\n\x1a\n";
543    if !data.starts_with(PNG_HEADER) {
544        return String::new();
545    }
546    let mut i = 8usize;
547    while i + 8 <= data.len() {
548        let length = u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
549        let chunk_type = &data[i + 4..i + 8];
550        let payload_start = i + 8;
551        let payload_end = payload_start + length;
552        if payload_end > data.len() {
553            break;
554        }
555        let payload = &data[payload_start..payload_end];
556
557        if chunk_type == b"tEXt" {
558            if let Some(null_pos) = payload.iter().position(|&b| b == 0) {
559                let keyword = &payload[..null_pos];
560                if keyword == b"Description" {
561                    let text = &payload[null_pos + 1..];
562                    return String::from_utf8_lossy(text).to_string();
563                }
564            }
565        }
566        // Skip zTXt for simplicity (decompression not needed for our fixture)
567        i += 8 + length + 4; // header + payload + CRC
568    }
569    String::new()
570}
571
572// ─── DMM (BYOND map files) ────────────────────────────────────────────────────
573
574pub struct DmmExtractor;
575
576impl DmmExtractor {
577    pub fn extract(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
578        let (file_id, _file_label, file_node) = make_file_node(path);
579        let str_path = path.to_string_lossy().to_string();
580
581        let nodes: Vec<Node> = vec![file_node];
582        let mut edges: Vec<Edge> = vec![];
583
584        let text = std::str::from_utf8(source).unwrap_or("");
585
586        // Find grid section start: (d,d,d) = pattern
587        let dict_text = if let Some(grid_pos) = find_grid_section(text) {
588            &text[..grid_pos]
589        } else {
590            text
591        };
592
593        let mut seen_targets: HashSet<String> = HashSet::new();
594
595        // Parse tile dictionary: each tile is "key" = (type, type, ...)
596        // We need to find all (/type/path) entries, stripping var overrides
597        for type_path in extract_type_paths(dict_text) {
598            let tgt = make_id(&[&type_path]);
599            if seen_targets.insert(tgt.clone()) {
600                edges.push(Edge {
601                    source: file_id.clone(),
602                    target: tgt,
603                    relation: "uses".to_string(),
604                    confidence: "EXTRACTED".to_string(),
605                    source_file: Some(str_path.clone()),
606                    weight: 1.0,
607                    context: Some("map".to_string()),
608                });
609            }
610        }
611
612        Ok(ExtractionFragment { nodes, edges })
613    }
614}
615
616fn find_grid_section(text: &str) -> Option<usize> {
617    // Grid section starts with pattern like (1,1,1) = {" at a line start
618    let mut pos = 0;
619    for line in text.lines() {
620        let trimmed = line.trim();
621        if trimmed.starts_with('(') {
622            // Check if it's (d,d,d) = pattern
623            if let Some(close) = trimmed.find(')') {
624                let inner = &trimmed[1..close];
625                let parts: Vec<&str> = inner.split(',').collect();
626                if parts.len() == 3 && parts.iter().all(|p| p.trim().parse::<u32>().is_ok()) {
627                    return Some(pos);
628                }
629            }
630        }
631        pos += line.len() + 1; // +1 for newline
632    }
633    None
634}
635
636fn extract_type_paths(dict_text: &str) -> Vec<String> {
637    let mut result = Vec::new();
638    // Find all occurrences of /type/path (starting with /) in tile entries
639    // Tiles look like: "key" = (/type/path, /other/type{var=val}, ...)
640    // We need to find all type paths, stripping var overrides (anything after {)
641
642    let mut chars = dict_text.char_indices().peekable();
643    while let Some((i, c)) = chars.next() {
644        if c == '/' {
645            // Collect type path: /alphanumeric and /
646            let start = i;
647            let mut end = i + 1;
648            while let Some(&(j, nc)) = chars.peek() {
649                if nc.is_alphanumeric() || nc == '_' || nc == '/' {
650                    end = j + nc.len_utf8();
651                    chars.next();
652                } else {
653                    break;
654                }
655            }
656            let raw = &dict_text[start..end];
657            // Must start with / and have at least one component
658            let components: Vec<&str> = raw.split('/').filter(|s| !s.is_empty()).collect();
659            if !components.is_empty() && components.iter().all(|c| !c.is_empty()) {
660                result.push(raw.to_string());
661            }
662        }
663    }
664    result
665}
666
667// ─── DMF (BYOND interface forms) ─────────────────────────────────────────────
668
669pub struct DmfExtractor;
670
671impl DmfExtractor {
672    pub fn extract(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
673        let (file_id, _file_label, file_node) = make_file_node(path);
674        let str_path = path.to_string_lossy().to_string();
675        let stem = file_id.clone();
676
677        let mut nodes: Vec<Node> = vec![file_node];
678        let mut edges: Vec<Edge> = vec![];
679        let mut seen: HashSet<String> = HashSet::new();
680        seen.insert(file_id.clone());
681
682        let text = std::str::from_utf8(source).unwrap_or("");
683
684        let mut current_window_nid: Option<String> = None;
685        // (elem_node_index, elem_name) for updating label when we see type=
686        let mut current_elem: Option<(usize, String)> = None;
687
688        for line in text.lines() {
689            let trimmed = line.trim();
690            if trimmed.is_empty() {
691                continue;
692            }
693
694            // window "name"
695            if let Some(name) = parse_quoted_directive(trimmed, "window") {
696                let nid = make_id(&[&stem, "window", &name]);
697                if seen.insert(nid.clone()) {
698                    nodes.push(Node {
699                        id: nid.clone(),
700                        label: format!("window \"{}\"", name),
701                        file_type: "code".to_string(),
702                        source_file: str_path.clone(),
703                        source_location: None,
704                        community: None,
705                        rationale: None,
706                        docstring: None,
707                        metadata: HashMap::new(),
708                    });
709                    edges.push(Edge {
710                        source: file_id.clone(),
711                        target: nid.clone(),
712                        relation: "contains".to_string(),
713                        confidence: "EXTRACTED".to_string(),
714                        source_file: Some(str_path.clone()),
715                        weight: 1.0,
716                        context: None,
717                    });
718                }
719                current_window_nid = Some(nid);
720                current_elem = None;
721                continue;
722            }
723
724            // elem "name"
725            if let Some(elem_name) = parse_quoted_directive(trimmed, "elem") {
726                if let Some(ref win_nid) = current_window_nid.clone() {
727                    let nid = make_id(&[&stem, "elem", win_nid, &elem_name]);
728                    if seen.insert(nid.clone()) {
729                        let elem_idx = nodes.len();
730                        nodes.push(Node {
731                            id: nid.clone(),
732                            label: format!("elem \"{}\"", elem_name),
733                            file_type: "code".to_string(),
734                            source_file: str_path.clone(),
735                            source_location: None,
736                            community: None,
737                            rationale: None,
738                            docstring: None,
739                            metadata: HashMap::new(),
740                        });
741                        edges.push(Edge {
742                            source: win_nid.clone(),
743                            target: nid.clone(),
744                            relation: "contains".to_string(),
745                            confidence: "EXTRACTED".to_string(),
746                            source_file: Some(str_path.clone()),
747                            weight: 1.0,
748                            context: None,
749                        });
750                        current_elem = Some((elem_idx, elem_name));
751                    }
752                }
753                continue;
754            }
755
756            // type = TYPENAME
757            if let Some((elem_idx, ref elem_name)) = current_elem.clone() {
758                if let Some(type_val) = parse_kv_directive(trimmed, "type") {
759                    if !nodes[elem_idx].label.contains('[') {
760                        let new_label = format!("elem \"{}\" [{}]", elem_name, type_val);
761                        nodes[elem_idx].label = new_label;
762                    }
763                }
764            }
765        }
766
767        Ok(ExtractionFragment { nodes, edges })
768    }
769}
770
771fn parse_quoted_directive(line: &str, keyword: &str) -> Option<String> {
772    let prefix = format!("{} \"", keyword);
773    if line.starts_with(&prefix) {
774        let rest = &line[prefix.len()..];
775        if let Some(end) = rest.find('"') {
776            return Some(rest[..end].to_string());
777        }
778    }
779    None
780}
781
782fn parse_kv_directive(line: &str, key: &str) -> Option<String> {
783    let prefix = format!("{} =", key);
784    if let Some(stripped) = line.strip_prefix(&prefix) {
785        return Some(stripped.trim().to_string());
786    }
787    let prefix2 = format!("{}=", key);
788    if let Some(stripped) = line.strip_prefix(&prefix2) {
789        return Some(stripped.trim().to_string());
790    }
791    None
792}