Skip to main content

codegraph/
extraction.rs

1use crate::config::CodeGraphConfig;
2use crate::types::*;
3use regex::Regex;
4use std::path::Path;
5use tree_sitter::{Node as SyntaxNode, Parser};
6
7pub fn should_include_file(path: &Path, config: &CodeGraphConfig) -> bool {
8    let s = path.to_string_lossy().replace('\\', "/");
9    if s.starts_with(".codegraph/") {
10        return false;
11    }
12    if config.exclude.iter().any(|p| glob_match(p, &s)) {
13        return false;
14    }
15    config.include.iter().any(|p| glob_match(p, &s))
16}
17
18fn glob_match(pattern: &str, path: &str) -> bool {
19    let suffix = pattern.strip_prefix("**/*.");
20    if let Some(ext) = suffix {
21        return path.ends_with(&format!(".{}", ext));
22    }
23    if let Some(dir) = pattern
24        .strip_prefix("**/")
25        .and_then(|p| p.strip_suffix("/**"))
26    {
27        return path.contains(&format!("{}/", dir)) || path == dir;
28    }
29    if let Some(suffix) = pattern.strip_prefix("**/") {
30        return path.ends_with(suffix);
31    }
32    pattern == path
33}
34
35pub fn detect_language(path: &Path, _source: &str) -> Language {
36    let name = path
37        .file_name()
38        .and_then(|s| s.to_str())
39        .unwrap_or_default()
40        .to_lowercase();
41    if name == "moon.mod.json" || name == "moon.pkg.json" || name == "moon.pkg" {
42        return Language::MoonBit;
43    }
44    if name.ends_with(".mbt.md") {
45        return Language::MoonBit;
46    }
47    match path
48        .extension()
49        .and_then(|s| s.to_str())
50        .unwrap_or_default()
51        .to_lowercase()
52        .as_str()
53    {
54        "ts" => Language::TypeScript,
55        "tsx" => Language::Tsx,
56        "js" | "mjs" | "cjs" => Language::JavaScript,
57        "jsx" => Language::Jsx,
58        "py" | "pyw" => Language::Python,
59        "go" => Language::Go,
60        "rs" => Language::Rust,
61        "java" => Language::Java,
62        "c" | "h" => Language::C,
63        "cpp" | "cc" | "cxx" | "hpp" | "hxx" => Language::Cpp,
64        "cs" => Language::CSharp,
65        "php" => Language::Php,
66        "rb" | "rake" => Language::Ruby,
67        "swift" => Language::Swift,
68        "kt" | "kts" => Language::Kotlin,
69        "dart" => Language::Dart,
70        "svelte" => Language::Svelte,
71        "vue" => Language::Vue,
72        "liquid" => Language::Liquid,
73        "pas" | "dpr" | "dpk" | "lpr" | "dfm" | "fmx" => Language::Pascal,
74        "scala" | "sc" => Language::Scala,
75        "mbt" | "mbti" => Language::MoonBit,
76        _ => Language::Unknown,
77    }
78}
79
80pub fn extract_from_source(path: &Path, source: &str, language: Language) -> ExtractionResult {
81    let file_path = path.to_string_lossy().replace('\\', "/");
82    let now = now_ms();
83    let mut nodes = vec![Node {
84        id: format!("file:{}", file_path),
85        kind: NodeKind::File,
86        name: path
87            .file_name()
88            .and_then(|s| s.to_str())
89            .unwrap_or(&file_path)
90            .to_string(),
91        qualified_name: file_path.clone(),
92        file_path: file_path.clone(),
93        language,
94        start_line: 1,
95        end_line: source.lines().count().max(1) as i64,
96        start_column: 0,
97        end_column: 0,
98        docstring: None,
99        signature: None,
100        visibility: None,
101        is_exported: false,
102        is_async: false,
103        is_static: false,
104        is_abstract: false,
105        updated_at: now,
106    }];
107    let mut edges = Vec::new();
108    let mut refs = Vec::new();
109
110    match language {
111        Language::Rust => extract_rust(&file_path, source, now, &mut nodes, &mut edges, &mut refs),
112        Language::MoonBit => {
113            extract_moonbit(&file_path, source, now, &mut nodes, &mut edges, &mut refs)
114        }
115        _ => extract_generic(
116            &file_path, source, language, now, &mut nodes, &mut edges, &mut refs,
117        ),
118    }
119
120    ExtractionResult {
121        nodes,
122        edges,
123        unresolved_references: refs,
124    }
125}
126
127fn extract_rust(
128    file_path: &str,
129    source: &str,
130    now: i64,
131    nodes: &mut Vec<Node>,
132    edges: &mut Vec<Edge>,
133    refs: &mut Vec<UnresolvedReference>,
134) {
135    if try_extract_rust_tree_sitter(file_path, source, now, nodes, edges, refs) {
136        return;
137    }
138
139    add_regex_nodes(
140        file_path,
141        source,
142        Language::Rust,
143        now,
144        nodes,
145        edges,
146        r"(?m)^\s*(pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*([^{;]*)",
147        NodeKind::Function,
148    );
149    add_regex_nodes(
150        file_path,
151        source,
152        Language::Rust,
153        now,
154        nodes,
155        edges,
156        r"(?m)^\s*(pub(?:\([^)]*\))?\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)",
157        NodeKind::Struct,
158    );
159    add_regex_nodes(
160        file_path,
161        source,
162        Language::Rust,
163        now,
164        nodes,
165        edges,
166        r"(?m)^\s*(pub(?:\([^)]*\))?\s+)?trait\s+([A-Za-z_][A-Za-z0-9_]*)",
167        NodeKind::Trait,
168    );
169    add_regex_nodes(
170        file_path,
171        source,
172        Language::Rust,
173        now,
174        nodes,
175        edges,
176        r"(?m)^\s*(pub(?:\([^)]*\))?\s+)?enum\s+([A-Za-z_][A-Za-z0-9_]*)",
177        NodeKind::Enum,
178    );
179    add_regex_nodes(
180        file_path,
181        source,
182        Language::Rust,
183        now,
184        nodes,
185        edges,
186        r"(?m)^\s*(pub(?:\([^)]*\))?\s+)?type\s+([A-Za-z_][A-Za-z0-9_]*)",
187        NodeKind::TypeAlias,
188    );
189
190    let use_re = Regex::new(r"(?m)^\s*use\s+([^;]+);").unwrap();
191    for cap in use_re.captures_iter(source) {
192        let full = cap.get(1).unwrap();
193        let root = full
194            .as_str()
195            .split("::")
196            .next()
197            .unwrap_or(full.as_str())
198            .trim_matches('{')
199            .trim();
200        let node = make_node(
201            file_path,
202            Language::Rust,
203            NodeKind::Import,
204            root,
205            line_for(source, full.start()),
206            0,
207            now,
208            Some(format!("use {};", full.as_str())),
209        );
210        add_contains(nodes, edges, &node);
211        refs.push(unresolved(
212            &nodes[0].id,
213            root,
214            EdgeKind::Imports,
215            file_path,
216            Language::Rust,
217            node.start_line,
218        ));
219        nodes.push(node);
220    }
221
222    let impl_re = Regex::new(
223        r"(?m)^\s*impl(?:<[^>]+>)?\s+([A-Za-z_][A-Za-z0-9_:]*)\s+for\s+([A-Za-z_][A-Za-z0-9_]*)",
224    )
225    .unwrap();
226    for cap in impl_re.captures_iter(source) {
227        let trait_name = cap.get(1).unwrap().as_str().rsplit("::").next().unwrap();
228        let type_name = cap.get(2).unwrap().as_str();
229        if let Some(src) = nodes
230            .iter()
231            .find(|n| n.name == type_name && matches!(n.kind, NodeKind::Struct | NodeKind::Enum))
232            .map(|n| n.id.clone())
233        {
234            refs.push(unresolved(
235                &src,
236                trait_name,
237                EdgeKind::Implements,
238                file_path,
239                Language::Rust,
240                line_for(source, cap.get(1).unwrap().start()),
241            ));
242        }
243    }
244    add_call_refs(
245        file_path,
246        source,
247        Language::Rust,
248        nodes,
249        refs,
250        r"([A-Za-z_][A-Za-z0-9_:]*)\s*\(",
251    );
252}
253
254fn extract_moonbit(
255    file_path: &str,
256    source: &str,
257    now: i64,
258    nodes: &mut Vec<Node>,
259    edges: &mut Vec<Edge>,
260    refs: &mut Vec<UnresolvedReference>,
261) {
262    if file_path.ends_with("moon.mod.json")
263        || file_path.ends_with("moon.pkg.json")
264        || file_path.ends_with("moon.pkg")
265    {
266        extract_moonbit_metadata(file_path, source, now, nodes, edges, refs);
267        return;
268    }
269
270    let source = if file_path.ends_with(".mbt.md") {
271        extract_mbt_markdown_code_with_padding(source)
272    } else {
273        source.to_string()
274    };
275
276    if try_extract_moonbit_tree_sitter(file_path, &source, now, nodes, edges, refs) {
277        extract_moonbit_sol_routes(file_path, &source, now, nodes, edges, refs);
278        return;
279    }
280
281    add_regex_nodes(
282        file_path,
283        &source,
284        Language::MoonBit,
285        now,
286        nodes,
287        edges,
288        r"(?m)^\s*(pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*([^{]*)",
289        NodeKind::Function,
290    );
291    add_regex_nodes(
292        file_path,
293        &source,
294        Language::MoonBit,
295        now,
296        nodes,
297        edges,
298        r"(?m)^\s*(pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*::[A-Za-z_][A-Za-z0-9_]*)\s*([^{]*)",
299        NodeKind::Method,
300    );
301    add_regex_nodes(
302        file_path,
303        &source,
304        Language::MoonBit,
305        now,
306        nodes,
307        edges,
308        r"(?m)^\s*(pub\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)",
309        NodeKind::Struct,
310    );
311    add_regex_nodes(
312        file_path,
313        &source,
314        Language::MoonBit,
315        now,
316        nodes,
317        edges,
318        r"(?m)^\s*(pub\s+)?trait\s+([A-Za-z_][A-Za-z0-9_]*)",
319        NodeKind::Trait,
320    );
321    add_regex_nodes(
322        file_path,
323        &source,
324        Language::MoonBit,
325        now,
326        nodes,
327        edges,
328        r"(?m)^\s*(pub\s+)?enum\s+([A-Za-z_][A-Za-z0-9_]*)",
329        NodeKind::Enum,
330    );
331    add_regex_nodes(
332        file_path,
333        &source,
334        Language::MoonBit,
335        now,
336        nodes,
337        edges,
338        r"(?m)^\s*(pub\s+)?type\s+([A-Za-z_][A-Za-z0-9_]*)",
339        NodeKind::TypeAlias,
340    );
341    add_regex_nodes(
342        file_path,
343        &source,
344        Language::MoonBit,
345        now,
346        nodes,
347        edges,
348        r"(?m)^\s*(pub\s+)?let\s+([A-Za-z_][A-Za-z0-9_]*)",
349        NodeKind::Variable,
350    );
351
352    let import_re =
353        Regex::new(r#"(?m)^\s*import\s+([@\w/.\-]+)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?"#)
354            .unwrap();
355    for cap in import_re.captures_iter(&source) {
356        let package = cap.get(1).unwrap().as_str();
357        let name = cap.get(2).map(|m| m.as_str()).unwrap_or(package);
358        let node = make_node(
359            file_path,
360            Language::MoonBit,
361            NodeKind::Import,
362            name,
363            line_for(&source, cap.get(0).unwrap().start()),
364            0,
365            now,
366            Some(cap.get(0).unwrap().as_str().to_string()),
367        );
368        add_contains(nodes, edges, &node);
369        refs.push(unresolved(
370            &nodes[0].id,
371            name,
372            EdgeKind::Imports,
373            file_path,
374            Language::MoonBit,
375            node.start_line,
376        ));
377        nodes.push(node);
378    }
379    add_call_refs(
380        file_path,
381        &source,
382        Language::MoonBit,
383        nodes,
384        refs,
385        r"([@A-Za-z_][@A-Za-z0-9_:/]*)\s*\(",
386    );
387    extract_moonbit_sol_routes(file_path, &source, now, nodes, edges, refs);
388}
389
390fn extract_moonbit_sol_routes(
391    file_path: &str,
392    source: &str,
393    now: i64,
394    nodes: &mut Vec<Node>,
395    edges: &mut Vec<Edge>,
396    refs: &mut Vec<UnresolvedReference>,
397) {
398    if !file_path.ends_with(".mbt") && !file_path.ends_with(".mbt.md") {
399        return;
400    }
401
402    let safe = strip_moonbit_comments_preserve_lines(source);
403    let call_re = Regex::new(
404        r#"@(?:sol|router)\.(route|page|api_get|api_post|api_put|api_delete|api_patch|raw_get|raw_post|raw_put|raw_delete|raw_patch)\s*\(\s*"([^"]+)"\s*,\s*([@A-Za-z_][@A-Za-z0-9_:.]*)"#,
405    )
406    .unwrap();
407    let wrap_re = Regex::new(r#"@(?:sol|router)\.wrap\s*\(\s*"([^"]*)"\s*,"#).unwrap();
408    let constructor_re = Regex::new(
409        r#"SolRoutes::(Page|RawGet|RawPost|RawPut|RawDelete|RawPatch)\s*\([^)]*path\s*=\s*"([^"]+)"[^)]*handler\s*=\s*(?:PageHandler|RawHandler)?\(?\s*([@A-Za-z_][@A-Za-z0-9_:.]*)"#,
410    )
411    .unwrap();
412    let named_page_re = Regex::new(
413        r#"@(?:sol|router)\.page\s*\([^)]*path\s*=\s*"([^"]+)"[^)]*handler\s*=\s*([@A-Za-z_][@A-Za-z0-9_:.]*)"#,
414    )
415    .unwrap();
416
417    let mut prefix_stack: Vec<(usize, String)> = Vec::new();
418    let mut byte_offset = 0usize;
419    for line in safe.lines() {
420        let indent = line.chars().take_while(|c| c.is_whitespace()).count();
421        while prefix_stack
422            .last()
423            .map(|(stack_indent, _)| indent <= *stack_indent && line.trim_start().starts_with(']'))
424            .unwrap_or(false)
425        {
426            prefix_stack.pop();
427        }
428
429        if let Some(cap) = wrap_re.captures(line) {
430            let prefix = cap.get(1).map(|m| m.as_str()).unwrap_or("");
431            let full_prefix = join_route_paths(current_route_prefix(&prefix_stack), prefix);
432            prefix_stack.push((indent, full_prefix));
433        }
434
435        for cap in call_re.captures_iter(line) {
436            let helper = cap.get(1).unwrap().as_str();
437            let path = cap.get(2).unwrap().as_str();
438            let handler = cap.get(3).map(|m| clean_moonbit_handler(m.as_str()));
439            let route_path = join_route_paths(current_route_prefix(&prefix_stack), path);
440            add_moonbit_route_node(
441                file_path,
442                &safe,
443                byte_offset + cap.get(0).unwrap().start(),
444                helper_route_method(helper),
445                &route_path,
446                handler.as_deref(),
447                now,
448                nodes,
449                edges,
450                refs,
451            );
452        }
453
454        for cap in named_page_re.captures_iter(line) {
455            let path = cap.get(1).unwrap().as_str();
456            let handler = cap.get(2).map(|m| clean_moonbit_handler(m.as_str()));
457            let route_path = join_route_paths(current_route_prefix(&prefix_stack), path);
458            add_moonbit_route_node(
459                file_path,
460                &safe,
461                byte_offset + cap.get(0).unwrap().start(),
462                "PAGE",
463                &route_path,
464                handler.as_deref(),
465                now,
466                nodes,
467                edges,
468                refs,
469            );
470        }
471
472        for cap in constructor_re.captures_iter(line) {
473            let variant = cap.get(1).unwrap().as_str();
474            let path = cap.get(2).unwrap().as_str();
475            let handler = cap.get(3).map(|m| clean_moonbit_handler(m.as_str()));
476            let route_path = join_route_paths(current_route_prefix(&prefix_stack), path);
477            add_moonbit_route_node(
478                file_path,
479                &safe,
480                byte_offset + cap.get(0).unwrap().start(),
481                constructor_route_method(variant),
482                &route_path,
483                handler.as_deref(),
484                now,
485                nodes,
486                edges,
487                refs,
488            );
489        }
490
491        byte_offset += line.len() + 1;
492    }
493}
494
495fn add_moonbit_route_node(
496    file_path: &str,
497    source: &str,
498    byte_offset: usize,
499    method: &str,
500    route_path: &str,
501    handler: Option<&str>,
502    now: i64,
503    nodes: &mut Vec<Node>,
504    edges: &mut Vec<Edge>,
505    refs: &mut Vec<UnresolvedReference>,
506) {
507    let line = line_for(source, byte_offset);
508    let name = format!("{method} {route_path}");
509    let node = Node {
510        id: format!("route:{file_path}:{line}:{method}:{route_path}"),
511        kind: NodeKind::Route,
512        name,
513        qualified_name: format!("{file_path}::route:{method}:{route_path}"),
514        file_path: file_path.to_string(),
515        language: Language::MoonBit,
516        start_line: line,
517        end_line: line,
518        start_column: 0,
519        end_column: 0,
520        docstring: None,
521        signature: handler.map(|h| format!("{method} {route_path} -> {h}")),
522        visibility: None,
523        is_exported: false,
524        is_async: false,
525        is_static: false,
526        is_abstract: false,
527        updated_at: now,
528    };
529    add_contains(nodes, edges, &node);
530    if let Some(handler) = handler {
531        refs.push(unresolved(
532            &node.id,
533            handler,
534            EdgeKind::References,
535            file_path,
536            Language::MoonBit,
537            line,
538        ));
539    }
540    nodes.push(node);
541}
542
543fn helper_route_method(helper: &str) -> &'static str {
544    match helper {
545        "route" | "page" => "PAGE",
546        "api_get" => "GET",
547        "api_post" => "POST",
548        "api_put" => "PUT",
549        "api_delete" => "DELETE",
550        "api_patch" => "PATCH",
551        "raw_get" => "RAW GET",
552        "raw_post" => "RAW POST",
553        "raw_put" => "RAW PUT",
554        "raw_delete" => "RAW DELETE",
555        "raw_patch" => "RAW PATCH",
556        _ => "PAGE",
557    }
558}
559
560fn constructor_route_method(variant: &str) -> &'static str {
561    match variant {
562        "RawGet" => "RAW GET",
563        "RawPost" => "RAW POST",
564        "RawPut" => "RAW PUT",
565        "RawDelete" => "RAW DELETE",
566        "RawPatch" => "RAW PATCH",
567        _ => "PAGE",
568    }
569}
570
571fn current_route_prefix(prefix_stack: &[(usize, String)]) -> &str {
572    prefix_stack
573        .last()
574        .map(|(_, prefix)| prefix.as_str())
575        .unwrap_or("")
576}
577
578fn join_route_paths(prefix: &str, path: &str) -> String {
579    if prefix.is_empty() || prefix == "/" {
580        return normalize_route_path(path);
581    }
582    let path = normalize_route_path(path);
583    if path == "/" {
584        return normalize_route_path(prefix);
585    }
586    format!(
587        "{}/{}",
588        prefix.trim_end_matches('/'),
589        path.trim_start_matches('/')
590    )
591}
592
593fn normalize_route_path(path: &str) -> String {
594    if path.is_empty() {
595        return "/".into();
596    }
597    let path = path.replace('\\', "/");
598    if path.starts_with('/') {
599        path
600    } else {
601        format!("/{path}")
602    }
603}
604
605fn clean_moonbit_handler(handler: &str) -> String {
606    handler
607        .trim()
608        .trim_start_matches('@')
609        .rsplit(['.', ':'])
610        .next()
611        .unwrap_or(handler)
612        .trim_matches(')')
613        .to_string()
614}
615
616fn extract_moonbit_metadata(
617    file_path: &str,
618    source: &str,
619    now: i64,
620    nodes: &mut Vec<Node>,
621    edges: &mut Vec<Edge>,
622    refs: &mut Vec<UnresolvedReference>,
623) {
624    let Ok(json) = serde_json::from_str::<serde_json::Value>(source) else {
625        return;
626    };
627    if file_path.ends_with("moon.mod.json") {
628        if let Some(name) = json.get("name").and_then(|v| v.as_str()) {
629            let node = make_node(
630                file_path,
631                Language::MoonBit,
632                NodeKind::Module,
633                name,
634                1,
635                0,
636                now,
637                Some("moon.mod.json".into()),
638            );
639            add_contains(nodes, edges, &node);
640            nodes.push(node);
641        }
642        return;
643    }
644
645    let package_name = json
646        .get("name")
647        .and_then(|v| v.as_str())
648        .or_else(|| file_path.rsplit('/').nth(1))
649        .unwrap_or("moonbit-package");
650    let node = make_node(
651        file_path,
652        Language::MoonBit,
653        NodeKind::Module,
654        package_name,
655        1,
656        0,
657        now,
658        Some(file_path.rsplit('/').next().unwrap_or("moon.pkg").into()),
659    );
660    add_contains(nodes, edges, &node);
661    let package_node_id = node.id.clone();
662    nodes.push(node);
663
664    if let Some(imports) = json.get("import").or_else(|| json.get("imports")) {
665        if let Some(obj) = imports.as_object() {
666            for (alias, value) in obj {
667                let target = value.as_str().unwrap_or(alias);
668                let import_node = make_node(
669                    file_path,
670                    Language::MoonBit,
671                    NodeKind::Import,
672                    alias,
673                    1,
674                    0,
675                    now,
676                    Some(target.to_string()),
677                );
678                add_contains(nodes, edges, &import_node);
679                refs.push(unresolved(
680                    &package_node_id,
681                    alias,
682                    EdgeKind::Imports,
683                    file_path,
684                    Language::MoonBit,
685                    1,
686                ));
687                nodes.push(import_node);
688            }
689        }
690    }
691}
692
693fn try_extract_rust_tree_sitter(
694    file_path: &str,
695    source: &str,
696    now: i64,
697    nodes: &mut Vec<Node>,
698    edges: &mut Vec<Edge>,
699    refs: &mut Vec<UnresolvedReference>,
700) -> bool {
701    let mut parser = Parser::new();
702    if parser
703        .set_language(&tree_sitter_rust::LANGUAGE.into())
704        .is_err()
705    {
706        return false;
707    }
708    let Some(tree) = parser.parse(source, None) else {
709        return false;
710    };
711    if tree.root_node().has_error() {
712        return false;
713    }
714
715    let root = tree.root_node();
716    let mut stack = Vec::new();
717    collect_rust_nodes(file_path, source, root, now, nodes, edges, refs, &mut stack);
718    collect_rust_refs(file_path, source, root, nodes, refs);
719    true
720}
721
722fn collect_rust_nodes(
723    file_path: &str,
724    source: &str,
725    node: SyntaxNode,
726    now: i64,
727    nodes: &mut Vec<Node>,
728    edges: &mut Vec<Edge>,
729    refs: &mut Vec<UnresolvedReference>,
730    stack: &mut Vec<String>,
731) {
732    let kind = match node.kind() {
733        "function_item" => {
734            if rust_receiver_type(node, source).is_some() {
735                Some(NodeKind::Method)
736            } else {
737                Some(NodeKind::Function)
738            }
739        }
740        "struct_item" => Some(NodeKind::Struct),
741        "trait_item" => Some(NodeKind::Trait),
742        "enum_item" => Some(NodeKind::Enum),
743        "enum_variant" => Some(NodeKind::EnumMember),
744        "type_item" => Some(NodeKind::TypeAlias),
745        "const_item" => Some(NodeKind::Constant),
746        "static_item" => Some(NodeKind::Variable),
747        "let_declaration" => Some(NodeKind::Variable),
748        "field_declaration" => Some(NodeKind::Field),
749        "function_signature_item" => Some(NodeKind::Method),
750        "use_declaration" => Some(NodeKind::Import),
751        "mod_item" => Some(NodeKind::Module),
752        _ => None,
753    };
754
755    let mut pushed = false;
756    if let Some(kind) = kind {
757        if let Some(name) = rust_node_name(node, source, kind) {
758            let signature = Some(
759                node_text(node, source)
760                    .lines()
761                    .next()
762                    .unwrap_or("")
763                    .trim()
764                    .to_string(),
765            );
766            let mut out =
767                make_node_span(file_path, Language::Rust, kind, &name, node, now, signature);
768            out.is_exported = rust_is_public(node, source);
769            out.visibility = if out.is_exported {
770                Some("public".into())
771            } else if matches!(
772                kind,
773                NodeKind::Function
774                    | NodeKind::Method
775                    | NodeKind::Struct
776                    | NodeKind::Trait
777                    | NodeKind::Enum
778                    | NodeKind::TypeAlias
779            ) {
780                Some("private".into())
781            } else {
782                None
783            };
784            out.is_async = node_text(node, source).trim_start().starts_with("async ")
785                || node_text(node, source).contains(" async fn ");
786            if kind == NodeKind::Method {
787                if let Some(owner) = rust_receiver_type(node, source) {
788                    out.qualified_name = format!("{owner}::{name}");
789                }
790            }
791            add_contains_from_stack(nodes, edges, stack, &out, "tree-sitter");
792            let id = out.id.clone();
793            nodes.push(out);
794            if matches!(
795                kind,
796                NodeKind::Struct
797                    | NodeKind::Trait
798                    | NodeKind::Enum
799                    | NodeKind::Module
800                    | NodeKind::Function
801                    | NodeKind::Method
802            ) {
803                stack.push(id);
804                pushed = true;
805            }
806        }
807    }
808
809    if node.kind() == "impl_item" {
810        if let Some((trait_name, type_name)) = rust_impl_trait_for_type(node, source) {
811            if let Some(type_node) = nodes.iter().find(|n| {
812                n.name == type_name
813                    && matches!(n.kind, NodeKind::Struct | NodeKind::Enum | NodeKind::Trait)
814            }) {
815                refs_push(
816                    refs,
817                    &type_node.id,
818                    &trait_name,
819                    EdgeKind::Implements,
820                    file_path,
821                    Language::Rust,
822                    node.start_position().row as i64 + 1,
823                    node.start_position().column as i64,
824                );
825            }
826        }
827    }
828
829    for child in named_children(node) {
830        collect_rust_nodes(file_path, source, child, now, nodes, edges, refs, stack);
831    }
832
833    if pushed {
834        stack.pop();
835    }
836}
837
838fn collect_rust_refs(
839    file_path: &str,
840    source: &str,
841    node: SyntaxNode,
842    nodes: &[Node],
843    refs: &mut Vec<UnresolvedReference>,
844) {
845    match node.kind() {
846        "use_declaration" => {
847            if let Some(name) = rust_import_root(node, source) {
848                refs_push(
849                    refs,
850                    &format!("file:{file_path}"),
851                    &name,
852                    EdgeKind::Imports,
853                    file_path,
854                    Language::Rust,
855                    node.start_position().row as i64 + 1,
856                    node.start_position().column as i64,
857                );
858            }
859        }
860        "call_expression" => {
861            if let Some(function) = node.child_by_field_name("function") {
862                if let Some(name) = callable_name(function, source) {
863                    if let Some(caller) =
864                        enclosing_callable(nodes, node.start_position().row as i64 + 1)
865                    {
866                        refs_push(
867                            refs,
868                            &caller.id,
869                            &name,
870                            EdgeKind::Calls,
871                            file_path,
872                            Language::Rust,
873                            node.start_position().row as i64 + 1,
874                            node.start_position().column as i64,
875                        );
876                    }
877                }
878            }
879        }
880        _ => {}
881    }
882
883    for child in named_children(node) {
884        collect_rust_refs(file_path, source, child, nodes, refs);
885    }
886}
887
888fn try_extract_moonbit_tree_sitter(
889    file_path: &str,
890    source: &str,
891    now: i64,
892    nodes: &mut Vec<Node>,
893    edges: &mut Vec<Edge>,
894    refs: &mut Vec<UnresolvedReference>,
895) -> bool {
896    let mut parser = Parser::new();
897    if parser
898        .set_language(&tree_sitter_moonbit::LANGUAGE.into())
899        .is_err()
900    {
901        return false;
902    }
903    let Some(tree) = parser.parse(source, None) else {
904        return false;
905    };
906    if tree.root_node().has_error() {
907        return false;
908    }
909
910    let root = tree.root_node();
911    let mut stack = Vec::new();
912    collect_moonbit_nodes(file_path, source, root, now, nodes, edges, &mut stack);
913    collect_moonbit_refs(file_path, source, root, nodes, refs);
914    true
915}
916
917fn collect_moonbit_nodes(
918    file_path: &str,
919    source: &str,
920    node: SyntaxNode,
921    now: i64,
922    nodes: &mut Vec<Node>,
923    edges: &mut Vec<Edge>,
924    stack: &mut Vec<String>,
925) {
926    let kind = match node.kind() {
927        "function_definition" => Some(NodeKind::Function),
928        "impl_definition" => Some(NodeKind::Method),
929        "struct_definition" | "tuple_struct_definition" => Some(NodeKind::Struct),
930        "trait_definition" => Some(NodeKind::Trait),
931        "trait_method_declaration" => Some(NodeKind::Method),
932        "enum_definition" => Some(NodeKind::Enum),
933        "enum_constructor" => Some(NodeKind::EnumMember),
934        "type_alias_definition" | "type_definition" => Some(NodeKind::TypeAlias),
935        "const_definition" => Some(NodeKind::Constant),
936        "import_declaration" => Some(NodeKind::Import),
937        "package_declaration" => Some(NodeKind::Module),
938        _ => None,
939    };
940
941    let mut pushed = false;
942    if let Some(kind) = kind {
943        if let Some(name) = moonbit_node_name(node, source, kind) {
944            let signature = Some(
945                node_text(node, source)
946                    .lines()
947                    .next()
948                    .unwrap_or("")
949                    .trim()
950                    .to_string(),
951            );
952            let mut out = make_node_span(
953                file_path,
954                Language::MoonBit,
955                kind,
956                &name,
957                node,
958                now,
959                signature,
960            );
961            out.is_exported = moonbit_is_public(node, source);
962            out.visibility = if out.is_exported {
963                Some("public".into())
964            } else {
965                None
966            };
967            if kind == NodeKind::Method {
968                if let Some(owner) = moonbit_impl_owner(node, source) {
969                    out.qualified_name = format!("{owner}::{name}");
970                }
971            }
972            add_contains_from_stack(nodes, edges, stack, &out, "tree-sitter");
973            let id = out.id.clone();
974            nodes.push(out);
975            if matches!(
976                kind,
977                NodeKind::Struct
978                    | NodeKind::Trait
979                    | NodeKind::Enum
980                    | NodeKind::Module
981                    | NodeKind::Function
982                    | NodeKind::Method
983            ) {
984                stack.push(id);
985                pushed = true;
986            }
987        }
988    }
989
990    for child in named_children(node) {
991        collect_moonbit_nodes(file_path, source, child, now, nodes, edges, stack);
992    }
993
994    if pushed {
995        stack.pop();
996    }
997}
998
999fn collect_moonbit_refs(
1000    file_path: &str,
1001    source: &str,
1002    node: SyntaxNode,
1003    nodes: &[Node],
1004    refs: &mut Vec<UnresolvedReference>,
1005) {
1006    match node.kind() {
1007        "import_declaration" => {
1008            for child in named_children(node) {
1009                if child.kind() == "import_item" {
1010                    if let Some(name) = moonbit_import_name(child, source) {
1011                        refs_push(
1012                            refs,
1013                            &format!("file:{file_path}"),
1014                            &name,
1015                            EdgeKind::Imports,
1016                            file_path,
1017                            Language::MoonBit,
1018                            child.start_position().row as i64 + 1,
1019                            child.start_position().column as i64,
1020                        );
1021                    }
1022                }
1023            }
1024        }
1025        "apply_expression" | "dot_apply_expression" | "dot_dot_apply_expression" => {
1026            if let Some(name) = moonbit_call_name(node, source) {
1027                if let Some(caller) =
1028                    enclosing_callable(nodes, node.start_position().row as i64 + 1)
1029                {
1030                    refs_push(
1031                        refs,
1032                        &caller.id,
1033                        &name,
1034                        EdgeKind::Calls,
1035                        file_path,
1036                        Language::MoonBit,
1037                        node.start_position().row as i64 + 1,
1038                        node.start_position().column as i64,
1039                    );
1040                }
1041            }
1042        }
1043        _ => {}
1044    }
1045
1046    for child in named_children(node) {
1047        collect_moonbit_refs(file_path, source, child, nodes, refs);
1048    }
1049}
1050
1051fn extract_generic(
1052    file_path: &str,
1053    source: &str,
1054    language: Language,
1055    now: i64,
1056    nodes: &mut Vec<Node>,
1057    edges: &mut Vec<Edge>,
1058    refs: &mut Vec<UnresolvedReference>,
1059) {
1060    add_regex_nodes(
1061        file_path,
1062        source,
1063        language,
1064        now,
1065        nodes,
1066        edges,
1067        r"(?m)^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)",
1068        NodeKind::Function,
1069    );
1070    add_regex_nodes(
1071        file_path,
1072        source,
1073        language,
1074        now,
1075        nodes,
1076        edges,
1077        r"(?m)^\s*(?:export\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)",
1078        NodeKind::Class,
1079    );
1080    add_call_refs(
1081        file_path,
1082        source,
1083        language,
1084        nodes,
1085        refs,
1086        r"([A-Za-z_$][A-Za-z0-9_$.]*)\s*\(",
1087    );
1088}
1089
1090fn add_regex_nodes(
1091    file_path: &str,
1092    source: &str,
1093    language: Language,
1094    now: i64,
1095    nodes: &mut Vec<Node>,
1096    edges: &mut Vec<Edge>,
1097    pattern: &str,
1098    kind: NodeKind,
1099) {
1100    let re = Regex::new(pattern).unwrap();
1101    for cap in re.captures_iter(source) {
1102        let Some(name_match) = cap.get(2).or_else(|| cap.get(1)) else {
1103            continue;
1104        };
1105        let mut name = name_match.as_str().to_string();
1106        if kind == NodeKind::Method && name.contains("::") {
1107            name = name.rsplit("::").next().unwrap_or(&name).to_string();
1108        }
1109        let signature = cap.get(0).map(|m| m.as_str().trim().to_string());
1110        let line = line_for(source, name_match.start());
1111        let mut node = make_node(file_path, language, kind, &name, line, 0, now, signature);
1112        node.is_exported = cap
1113            .get(1)
1114            .map(|m| m.as_str().contains("pub") || m.as_str().contains("export"))
1115            .unwrap_or(false);
1116        node.visibility = if node.is_exported {
1117            Some("public".into())
1118        } else {
1119            None
1120        };
1121        add_contains(nodes, edges, &node);
1122        nodes.push(node);
1123    }
1124}
1125
1126fn add_call_refs(
1127    file_path: &str,
1128    source: &str,
1129    language: Language,
1130    nodes: &[Node],
1131    refs: &mut Vec<UnresolvedReference>,
1132    pattern: &str,
1133) {
1134    let re = Regex::new(pattern).unwrap();
1135    let keywords = [
1136        "if", "for", "while", "match", "return", "fn", "test", "inspect", "Some", "Ok", "Err",
1137    ];
1138    for cap in re.captures_iter(source) {
1139        let name = cap.get(1).unwrap().as_str().rsplit("::").next().unwrap();
1140        if keywords.contains(&name) {
1141            continue;
1142        }
1143        let line = line_for(source, cap.get(1).unwrap().start());
1144        if let Some(caller) = nodes
1145            .iter()
1146            .filter(|n| matches!(n.kind, NodeKind::Function | NodeKind::Method))
1147            .rev()
1148            .find(|n| n.start_line <= line)
1149        {
1150            refs.push(unresolved(
1151                &caller.id,
1152                name,
1153                EdgeKind::Calls,
1154                file_path,
1155                language,
1156                line,
1157            ));
1158        }
1159    }
1160}
1161
1162fn make_node(
1163    file_path: &str,
1164    language: Language,
1165    kind: NodeKind,
1166    name: &str,
1167    line: i64,
1168    col: i64,
1169    now: i64,
1170    signature: Option<String>,
1171) -> Node {
1172    Node {
1173        id: format!("{}:{}:{}:{}", kind.as_str(), file_path, name, line),
1174        kind,
1175        name: name.to_string(),
1176        qualified_name: name.to_string(),
1177        file_path: file_path.to_string(),
1178        language,
1179        start_line: line,
1180        end_line: line,
1181        start_column: col,
1182        end_column: col,
1183        docstring: None,
1184        signature,
1185        visibility: None,
1186        is_exported: false,
1187        is_async: false,
1188        is_static: false,
1189        is_abstract: false,
1190        updated_at: now,
1191    }
1192}
1193
1194fn make_node_span(
1195    file_path: &str,
1196    language: Language,
1197    kind: NodeKind,
1198    name: &str,
1199    node: SyntaxNode,
1200    now: i64,
1201    signature: Option<String>,
1202) -> Node {
1203    let start = node.start_position();
1204    let end = node.end_position();
1205    Node {
1206        id: format!("{}:{}:{}:{}", kind.as_str(), file_path, name, start.row + 1),
1207        kind,
1208        name: name.to_string(),
1209        qualified_name: name.to_string(),
1210        file_path: file_path.to_string(),
1211        language,
1212        start_line: start.row as i64 + 1,
1213        end_line: end.row as i64 + 1,
1214        start_column: start.column as i64,
1215        end_column: end.column as i64,
1216        docstring: None,
1217        signature,
1218        visibility: None,
1219        is_exported: false,
1220        is_async: false,
1221        is_static: false,
1222        is_abstract: false,
1223        updated_at: now,
1224    }
1225}
1226
1227fn add_contains(nodes: &[Node], edges: &mut Vec<Edge>, node: &Node) {
1228    if let Some(file) = nodes.first() {
1229        edges.push(Edge {
1230            id: None,
1231            source: file.id.clone(),
1232            target: node.id.clone(),
1233            kind: EdgeKind::Contains,
1234            line: None,
1235            col: None,
1236            provenance: Some("regex".into()),
1237        });
1238    }
1239}
1240
1241fn add_contains_from_stack(
1242    nodes: &[Node],
1243    edges: &mut Vec<Edge>,
1244    stack: &[String],
1245    node: &Node,
1246    provenance: &str,
1247) {
1248    let source = stack
1249        .last()
1250        .cloned()
1251        .or_else(|| nodes.first().map(|n| n.id.clone()));
1252    if let Some(source) = source {
1253        edges.push(Edge {
1254            id: None,
1255            source,
1256            target: node.id.clone(),
1257            kind: EdgeKind::Contains,
1258            line: None,
1259            col: None,
1260            provenance: Some(provenance.into()),
1261        });
1262    }
1263}
1264
1265fn unresolved(
1266    from: &str,
1267    name: &str,
1268    kind: EdgeKind,
1269    file_path: &str,
1270    language: Language,
1271    line: i64,
1272) -> UnresolvedReference {
1273    UnresolvedReference {
1274        from_node_id: from.to_string(),
1275        reference_name: name.to_string(),
1276        reference_kind: kind,
1277        line,
1278        column: 0,
1279        file_path: file_path.to_string(),
1280        language,
1281    }
1282}
1283
1284fn refs_push(
1285    refs: &mut Vec<UnresolvedReference>,
1286    from: &str,
1287    name: &str,
1288    kind: EdgeKind,
1289    file_path: &str,
1290    language: Language,
1291    line: i64,
1292    column: i64,
1293) {
1294    if !name.is_empty() {
1295        refs.push(UnresolvedReference {
1296            from_node_id: from.to_string(),
1297            reference_name: name.to_string(),
1298            reference_kind: kind,
1299            line,
1300            column,
1301            file_path: file_path.to_string(),
1302            language,
1303        });
1304    }
1305}
1306
1307fn named_children(node: SyntaxNode) -> Vec<SyntaxNode> {
1308    (0..node.named_child_count())
1309        .filter_map(|i| node.named_child(i as u32))
1310        .collect()
1311}
1312
1313fn node_text<'a>(node: SyntaxNode, source: &'a str) -> &'a str {
1314    source.get(node.byte_range()).unwrap_or_default()
1315}
1316
1317fn child_text_by_kind<'a>(node: SyntaxNode, source: &'a str, kinds: &[&str]) -> Option<&'a str> {
1318    named_children(node)
1319        .into_iter()
1320        .find(|child| kinds.contains(&child.kind()))
1321        .map(|child| node_text(child, source))
1322}
1323
1324fn descendant_text_by_kind<'a>(
1325    node: SyntaxNode,
1326    source: &'a str,
1327    kinds: &[&str],
1328) -> Option<&'a str> {
1329    if kinds.contains(&node.kind()) {
1330        return Some(node_text(node, source));
1331    }
1332    for child in named_children(node) {
1333        if let Some(text) = descendant_text_by_kind(child, source, kinds) {
1334            return Some(text);
1335        }
1336    }
1337    None
1338}
1339
1340fn rust_node_name(node: SyntaxNode, source: &str, kind: NodeKind) -> Option<String> {
1341    if kind == NodeKind::Import {
1342        return rust_import_root(node, source);
1343    }
1344    if kind == NodeKind::Variable && node.kind() == "let_declaration" {
1345        return descendant_text_by_kind(node, source, &["identifier"]).map(clean_symbol_name);
1346    }
1347    if kind == NodeKind::Field {
1348        return child_text_by_kind(node, source, &["field_identifier", "identifier"])
1349            .map(clean_symbol_name);
1350    }
1351    node.child_by_field_name("name")
1352        .map(|n| clean_symbol_name(node_text(n, source)))
1353        .or_else(|| {
1354            child_text_by_kind(
1355                node,
1356                source,
1357                &["identifier", "type_identifier", "field_identifier"],
1358            )
1359            .map(clean_symbol_name)
1360        })
1361}
1362
1363fn rust_is_public(node: SyntaxNode, source: &str) -> bool {
1364    node_text(node, source).trim_start().starts_with("pub")
1365        || named_children(node).into_iter().any(|child| {
1366            child.kind() == "visibility_modifier" && node_text(child, source).contains("pub")
1367        })
1368}
1369
1370fn rust_receiver_type(node: SyntaxNode, source: &str) -> Option<String> {
1371    let mut parent = node.parent();
1372    while let Some(p) = parent {
1373        if p.kind() == "impl_item" {
1374            let mut direct = named_children(p)
1375                .into_iter()
1376                .filter(|child| {
1377                    matches!(
1378                        child.kind(),
1379                        "type_identifier" | "generic_type" | "scoped_type_identifier"
1380                    )
1381                })
1382                .collect::<Vec<_>>();
1383            if let Some(last) = direct.pop() {
1384                return Some(clean_type_name(node_text(last, source)));
1385            }
1386            return descendant_text_by_kind(p, source, &["type_identifier"]).map(clean_type_name);
1387        }
1388        parent = p.parent();
1389    }
1390    None
1391}
1392
1393fn rust_impl_trait_for_type(node: SyntaxNode, source: &str) -> Option<(String, String)> {
1394    if node.kind() != "impl_item" || !node_text(node, source).contains(" for ") {
1395        return None;
1396    }
1397    let names: Vec<String> = named_children(node)
1398        .into_iter()
1399        .filter(|child| {
1400            matches!(
1401                child.kind(),
1402                "type_identifier" | "generic_type" | "scoped_type_identifier"
1403            )
1404        })
1405        .map(|child| clean_type_name(node_text(child, source)))
1406        .collect();
1407    if names.len() >= 2 {
1408        Some((names[0].clone(), names[names.len() - 1].clone()))
1409    } else {
1410        None
1411    }
1412}
1413
1414fn rust_import_root(node: SyntaxNode, source: &str) -> Option<String> {
1415    let text = node_text(node, source)
1416        .trim()
1417        .strip_prefix("use")
1418        .unwrap_or(node_text(node, source))
1419        .trim()
1420        .trim_end_matches(';')
1421        .trim();
1422    text.split("::")
1423        .next()
1424        .map(|s| s.trim_matches('{').trim().to_string())
1425        .filter(|s| !s.is_empty())
1426}
1427
1428fn callable_name(node: SyntaxNode, source: &str) -> Option<String> {
1429    match node.kind() {
1430        "identifier" | "field_identifier" => Some(clean_symbol_name(node_text(node, source))),
1431        "scoped_identifier" => node_text(node, source)
1432            .rsplit("::")
1433            .next()
1434            .map(clean_symbol_name),
1435        "field_expression" => node
1436            .child_by_field_name("field")
1437            .map(|field| clean_symbol_name(node_text(field, source))),
1438        "generic_function" => named_children(node)
1439            .into_iter()
1440            .find_map(|child| callable_name(child, source)),
1441        _ => None,
1442    }
1443}
1444
1445fn moonbit_node_name(node: SyntaxNode, source: &str, kind: NodeKind) -> Option<String> {
1446    match kind {
1447        NodeKind::Function | NodeKind::Method => child_text_by_kind(
1448            node,
1449            source,
1450            &["function_identifier", "lowercase_identifier", "identifier"],
1451        )
1452        .map(|s| clean_symbol_name(s.rsplit("::").next().unwrap_or(s))),
1453        NodeKind::Struct | NodeKind::Trait | NodeKind::Enum => child_text_by_kind(
1454            node,
1455            source,
1456            &[
1457                "identifier",
1458                "type_identifier",
1459                "type_name",
1460                "uppercase_identifier",
1461            ],
1462        )
1463        .map(clean_symbol_name),
1464        NodeKind::EnumMember => child_text_by_kind(
1465            node,
1466            source,
1467            &["uppercase_identifier", "identifier", "type_name"],
1468        )
1469        .map(clean_symbol_name),
1470        NodeKind::TypeAlias => descendant_text_by_kind(
1471            node,
1472            source,
1473            &[
1474                "type_identifier",
1475                "type_name",
1476                "identifier",
1477                "uppercase_identifier",
1478            ],
1479        )
1480        .map(clean_symbol_name),
1481        NodeKind::Constant => {
1482            child_text_by_kind(node, source, &["uppercase_identifier", "identifier"])
1483                .map(clean_symbol_name)
1484        }
1485        NodeKind::Import => moonbit_import_name(node, source),
1486        NodeKind::Module => node
1487            .named_child(0)
1488            .map(|child| clean_quoted(node_text(child, source))),
1489        _ => None,
1490    }
1491}
1492
1493fn moonbit_is_public(node: SyntaxNode, source: &str) -> bool {
1494    named_children(node)
1495        .into_iter()
1496        .any(|child| child.kind() == "visibility" && node_text(child, source).contains("pub"))
1497        || node_text(node, source).trim_start().starts_with("pub ")
1498}
1499
1500fn moonbit_impl_owner(node: SyntaxNode, source: &str) -> Option<String> {
1501    child_text_by_kind(
1502        node,
1503        source,
1504        &["type_name", "type_identifier", "qualified_type_identifier"],
1505    )
1506    .map(clean_type_name)
1507}
1508
1509fn moonbit_import_name(node: SyntaxNode, source: &str) -> Option<String> {
1510    if node.kind() == "import_declaration" {
1511        return named_children(node)
1512            .into_iter()
1513            .find(|child| child.kind() == "import_item")
1514            .and_then(|child| moonbit_import_name(child, source));
1515    }
1516    named_children(node)
1517        .into_iter()
1518        .find(|child| child.kind() == "string_literal")
1519        .map(|child| clean_quoted(node_text(child, source)))
1520}
1521
1522fn moonbit_call_name(node: SyntaxNode, source: &str) -> Option<String> {
1523    for child in named_children(node) {
1524        match child.kind() {
1525            "qualified_identifier" | "function_identifier" | "method_expression" => {
1526                let text = node_text(child, source);
1527                let name = text
1528                    .rsplit(['.', ':'])
1529                    .find(|part| !part.is_empty())
1530                    .unwrap_or(text);
1531                return Some(clean_symbol_name(name));
1532            }
1533            "lowercase_identifier" | "identifier" => {
1534                return Some(clean_symbol_name(node_text(child, source)));
1535            }
1536            _ => {}
1537        }
1538    }
1539    None
1540}
1541
1542fn enclosing_callable(nodes: &[Node], line: i64) -> Option<&Node> {
1543    nodes
1544        .iter()
1545        .filter(|n| matches!(n.kind, NodeKind::Function | NodeKind::Method))
1546        .filter(|n| n.start_line <= line && line <= n.end_line.max(n.start_line))
1547        .min_by_key(|n| n.end_line - n.start_line)
1548}
1549
1550fn clean_symbol_name(s: &str) -> String {
1551    s.trim()
1552        .trim_matches('"')
1553        .trim_matches('\'')
1554        .trim_start_matches('.')
1555        .to_string()
1556}
1557
1558fn clean_quoted(s: &str) -> String {
1559    s.trim().trim_matches('"').trim_matches('\'').to_string()
1560}
1561
1562fn clean_type_name(s: &str) -> String {
1563    let s = s.trim();
1564    let before_generics = s.split('<').next().unwrap_or(s);
1565    before_generics
1566        .rsplit("::")
1567        .next()
1568        .unwrap_or(before_generics)
1569        .trim()
1570        .to_string()
1571}
1572
1573fn line_for(source: &str, idx: usize) -> i64 {
1574    source[..idx.min(source.len())]
1575        .bytes()
1576        .filter(|b| *b == b'\n')
1577        .count() as i64
1578        + 1
1579}
1580
1581fn extract_mbt_markdown_code_with_padding(source: &str) -> String {
1582    let mut out = String::new();
1583    let mut in_mbt = false;
1584    for line in source.lines() {
1585        let trimmed = line.trim_start();
1586        if trimmed.starts_with("```") {
1587            in_mbt = trimmed.contains("mbt");
1588            out.push('\n');
1589            continue;
1590        }
1591        if in_mbt {
1592            out.push_str(line);
1593        }
1594        out.push('\n');
1595    }
1596    out
1597}
1598
1599fn strip_moonbit_comments_preserve_lines(source: &str) -> String {
1600    let mut out = String::with_capacity(source.len());
1601    let mut chars = source.chars().peekable();
1602    let mut in_string = false;
1603    let mut escaped = false;
1604    while let Some(ch) = chars.next() {
1605        if in_string {
1606            out.push(ch);
1607            if escaped {
1608                escaped = false;
1609            } else if ch == '\\' {
1610                escaped = true;
1611            } else if ch == '"' {
1612                in_string = false;
1613            }
1614            continue;
1615        }
1616
1617        if ch == '"' {
1618            in_string = true;
1619            out.push(ch);
1620            continue;
1621        }
1622
1623        if ch == '/' && chars.peek() == Some(&'/') {
1624            chars.next();
1625            out.push(' ');
1626            out.push(' ');
1627            for next in chars.by_ref() {
1628                if next == '\n' {
1629                    out.push('\n');
1630                    break;
1631                }
1632                out.push(' ');
1633            }
1634            continue;
1635        }
1636
1637        if ch == '/' && chars.peek() == Some(&'*') {
1638            chars.next();
1639            out.push(' ');
1640            out.push(' ');
1641            let mut prev = '\0';
1642            for next in chars.by_ref() {
1643                if next == '\n' {
1644                    out.push('\n');
1645                } else {
1646                    out.push(' ');
1647                }
1648                if prev == '*' && next == '/' {
1649                    break;
1650                }
1651                prev = next;
1652            }
1653            continue;
1654        }
1655
1656        out.push(ch);
1657    }
1658    out
1659}
1660
1661fn now_ms() -> i64 {
1662    std::time::SystemTime::now()
1663        .duration_since(std::time::UNIX_EPOCH)
1664        .map(|d| d.as_millis() as i64)
1665        .unwrap_or_default()
1666}