Skip to main content

lean_ctx/core/
signatures.rs

1use regex::Regex;
2
3macro_rules! static_regex {
4    ($pattern:expr) => {{
5        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6        RE.get_or_init(|| {
7            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
8        })
9    }};
10}
11
12#[derive(Debug, Clone)]
13pub struct Signature {
14    pub kind: &'static str,
15    pub name: String,
16    pub params: String,
17    pub return_type: String,
18    pub is_async: bool,
19    pub is_exported: bool,
20    pub indent: usize,
21    pub start_line: Option<usize>,
22    pub end_line: Option<usize>,
23}
24
25impl Signature {
26    pub fn no_span() -> Self {
27        Self {
28            kind: "",
29            name: String::new(),
30            params: String::new(),
31            return_type: String::new(),
32            is_async: false,
33            is_exported: false,
34            indent: 0,
35            start_line: None,
36            end_line: None,
37        }
38    }
39
40    pub fn to_compact(&self) -> String {
41        let export = if self.is_exported { "⊛ " } else { "" };
42        let async_prefix = if self.is_async { "async " } else { "" };
43
44        match self.kind {
45            "fn" | "method" => {
46                let ret = if self.return_type.is_empty() {
47                    String::new()
48                } else {
49                    format!(" → {}", self.return_type)
50                };
51                let indent = " ".repeat(self.indent);
52                format!(
53                    "{indent}fn {async_prefix}{export}{}({}){}",
54                    self.name, self.params, ret
55                )
56            }
57            "class" | "struct" => format!("cl {export}{}", self.name),
58            "interface" | "trait" => format!("if {export}{}", self.name),
59            "type" => format!("ty {export}{}", self.name),
60            "enum" => format!("en {export}{}", self.name),
61            "const" | "let" | "var" => {
62                let ty = if self.return_type.is_empty() {
63                    String::new()
64                } else {
65                    format!(":{}", self.return_type)
66                };
67                format!("val {export}{}{ty}", self.name)
68            }
69            _ => format!("{} {}", self.kind, self.name),
70        }
71    }
72
73    pub fn to_tdd(&self) -> String {
74        let vis = if self.is_exported { "+" } else { "-" };
75        let a = if self.is_async { "~" } else { "" };
76
77        match self.kind {
78            "fn" | "method" => {
79                let ret = if self.return_type.is_empty() {
80                    String::new()
81                } else {
82                    format!("→{}", compact_type(&self.return_type))
83                };
84                let params = tdd_params(&self.params);
85                let indent = if self.indent > 0 { " " } else { "" };
86                format!("{indent}{a}λ{vis}{}({params}){ret}", self.name)
87            }
88            "class" | "struct" => format!("§{vis}{}", self.name),
89            "interface" | "trait" => format!("∂{vis}{}", self.name),
90            "type" => format!("τ{vis}{}", self.name),
91            "enum" => format!("ε{vis}{}", self.name),
92            "const" | "let" | "var" => {
93                let ty = if self.return_type.is_empty() {
94                    String::new()
95                } else {
96                    format!(":{}", compact_type(&self.return_type))
97                };
98                format!("ν{vis}{}{ty}", self.name)
99            }
100            _ => format!(
101                "{}{vis}{}",
102                self.kind.chars().next().unwrap_or('?'),
103                self.name
104            ),
105        }
106    }
107
108    /// Compact ` @Lstart[-end]` suffix for navigation-focused modes.
109    /// Returns an empty string when the span is unknown, so compression-first
110    /// modes that render the base `to_compact`/`to_tdd` stay byte-identical.
111    pub fn line_suffix(&self) -> String {
112        match (self.start_line, self.end_line) {
113            (Some(start), Some(end)) if start > 0 && end > start => format!(" @L{start}-{end}"),
114            (Some(start), _) if start > 0 => format!(" @L{start}"),
115            _ => String::new(),
116        }
117    }
118
119    /// `to_compact` plus a line-span suffix. Reserved for navigation modes
120    /// (`map`/`signatures`) where locating code outweighs the few extra tokens.
121    pub fn to_compact_located(&self) -> String {
122        format!("{}{}", self.to_compact(), self.line_suffix())
123    }
124
125    /// `to_tdd` plus a line-span suffix. Reserved for navigation modes.
126    pub fn to_tdd_located(&self) -> String {
127        format!("{}{}", self.to_tdd(), self.line_suffix())
128    }
129}
130
131fn fn_re() -> &'static Regex {
132    static_regex!(
133        r"^(\s*)(export\s+)?(async\s+)?function\s+(\w+)\s*(?:<[^>]*>)?\s*\(([^)]*)\)(?:\s*:\s*([^\{]+))?\s*\{?"
134    )
135}
136
137fn class_re() -> &'static Regex {
138    static_regex!(r"^(\s*)(export\s+)?(abstract\s+)?class\s+(\w+)")
139}
140
141fn iface_re() -> &'static Regex {
142    static_regex!(r"^(\s*)(export\s+)?interface\s+(\w+)")
143}
144
145fn type_re() -> &'static Regex {
146    static_regex!(r"^(\s*)(export\s+)?type\s+(\w+)")
147}
148
149fn const_re() -> &'static Regex {
150    static_regex!(r"^(\s*)(export\s+)?(const|let|var)\s+(\w+)(?:\s*:\s*(\w+))?")
151}
152
153fn rust_fn_re() -> &'static Regex {
154    static_regex!(
155        r"^(\s*)(pub\s+)?(async\s+)?fn\s+(\w+)\s*(?:<[^>]*>)?\s*\(([^)]*)\)(?:\s*->\s*([^\{]+))?\s*\{?"
156    )
157}
158
159fn rust_struct_re() -> &'static Regex {
160    static_regex!(r"^(\s*)(pub\s+)?struct\s+(\w+)")
161}
162
163fn rust_enum_re() -> &'static Regex {
164    static_regex!(r"^(\s*)(pub\s+)?enum\s+(\w+)")
165}
166
167fn rust_trait_re() -> &'static Regex {
168    static_regex!(r"^(\s*)(pub\s+)?trait\s+(\w+)")
169}
170
171fn rust_impl_re() -> &'static Regex {
172    static_regex!(r"^(\s*)impl\s+(?:(\w+)\s+for\s+)?(\w+)")
173}
174
175use std::sync::atomic::{AtomicU64, Ordering};
176
177static TREE_SITTER_HITS: AtomicU64 = AtomicU64::new(0);
178static REGEX_FALLBACK_HITS: AtomicU64 = AtomicU64::new(0);
179
180/// Returns (tree_sitter_hits, regex_fallback_hits) since process start.
181pub fn signature_backend_stats() -> (u64, u64) {
182    (
183        TREE_SITTER_HITS.load(Ordering::Relaxed),
184        REGEX_FALLBACK_HITS.load(Ordering::Relaxed),
185    )
186}
187
188pub fn extract_signatures(content: &str, file_ext: &str) -> Vec<Signature> {
189    #[cfg(feature = "tree-sitter")]
190    {
191        if let Some(sigs) = super::signatures_ts::extract_signatures_ts(content, file_ext) {
192            TREE_SITTER_HITS.fetch_add(1, Ordering::Relaxed);
193            return sigs;
194        }
195    }
196
197    REGEX_FALLBACK_HITS.fetch_add(1, Ordering::Relaxed);
198    match file_ext {
199        "rs" => extract_rust_signatures(content),
200        "ts" | "tsx" | "js" | "jsx" | "svelte" | "vue" => extract_ts_signatures(content),
201        "py" => extract_python_signatures(content),
202        "go" => extract_go_signatures(content),
203        _ => extract_generic_signatures(content),
204    }
205}
206
207pub fn extract_file_map(path: &str, content: &str) -> String {
208    let ext = std::path::Path::new(path)
209        .extension()
210        .and_then(|e| e.to_str())
211        .unwrap_or("rs");
212    let dep_info = super::deps::extract_deps(content, ext);
213    let sigs = extract_signatures(content, ext);
214    let mut parts = Vec::new();
215    if !dep_info.imports.is_empty() {
216        parts.push(dep_info.imports.join(","));
217    }
218    let key_sigs: Vec<String> = sigs
219        .iter()
220        .filter(|s| s.is_exported || s.indent == 0)
221        .map(Signature::to_compact_located)
222        .collect();
223    if !key_sigs.is_empty() {
224        parts.push(key_sigs.join("\n"));
225    }
226    parts.join("\n")
227}
228
229fn extract_ts_signatures(content: &str) -> Vec<Signature> {
230    let mut sigs = Vec::new();
231
232    for (line_idx, line) in content.lines().enumerate() {
233        let line_no = line_idx + 1;
234        let trimmed = line.trim();
235        if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') {
236            continue;
237        }
238
239        if let Some(caps) = fn_re().captures(line) {
240            let indent = caps.get(1).map_or(0, |m| m.as_str().len());
241            sigs.push(Signature {
242                kind: if indent > 0 { "method" } else { "fn" },
243                name: caps[4].to_string(),
244                params: compact_params(&caps[5]),
245                return_type: caps
246                    .get(6)
247                    .map_or(String::new(), |m| m.as_str().trim().to_string()),
248                is_async: caps.get(3).is_some(),
249                is_exported: caps.get(2).is_some(),
250                indent: if indent > 0 { 2 } else { 0 },
251                start_line: Some(line_no),
252                end_line: Some(line_no),
253            });
254        } else if let Some(caps) = class_re().captures(line) {
255            sigs.push(Signature {
256                kind: "class",
257                name: caps[4].to_string(),
258                params: String::new(),
259                return_type: String::new(),
260                is_async: false,
261                is_exported: caps.get(2).is_some(),
262                indent: 0,
263                start_line: Some(line_no),
264                end_line: Some(line_no),
265            });
266        } else if let Some(caps) = iface_re().captures(line) {
267            sigs.push(Signature {
268                kind: "interface",
269                name: caps[3].to_string(),
270                params: String::new(),
271                return_type: String::new(),
272                is_async: false,
273                is_exported: caps.get(2).is_some(),
274                indent: 0,
275                start_line: Some(line_no),
276                end_line: Some(line_no),
277            });
278        } else if let Some(caps) = type_re().captures(line) {
279            sigs.push(Signature {
280                kind: "type",
281                name: caps[3].to_string(),
282                params: String::new(),
283                return_type: String::new(),
284                is_async: false,
285                is_exported: caps.get(2).is_some(),
286                indent: 0,
287                start_line: Some(line_no),
288                end_line: Some(line_no),
289            });
290        } else if let Some(caps) = const_re().captures(line) {
291            if caps.get(2).is_some() {
292                sigs.push(Signature {
293                    kind: "const",
294                    name: caps[4].to_string(),
295                    params: String::new(),
296                    return_type: caps
297                        .get(5)
298                        .map_or(String::new(), |m| m.as_str().to_string()),
299                    is_async: false,
300                    is_exported: true,
301                    indent: 0,
302                    start_line: Some(line_no),
303                    end_line: Some(line_no),
304                });
305            }
306        }
307    }
308
309    sigs
310}
311
312fn extract_rust_signatures(content: &str) -> Vec<Signature> {
313    let mut sigs = Vec::new();
314
315    for (line_idx, line) in content.lines().enumerate() {
316        let line_no = line_idx + 1;
317        let trimmed = line.trim();
318        if trimmed.starts_with("//") || trimmed.starts_with("///") {
319            continue;
320        }
321
322        if let Some(caps) = rust_fn_re().captures(line) {
323            let indent = caps.get(1).map_or(0, |m| m.as_str().len());
324            sigs.push(Signature {
325                kind: if indent > 0 { "method" } else { "fn" },
326                name: caps[4].to_string(),
327                params: compact_params(&caps[5]),
328                return_type: caps
329                    .get(6)
330                    .map_or(String::new(), |m| m.as_str().trim().to_string()),
331                is_async: caps.get(3).is_some(),
332                is_exported: caps.get(2).is_some(),
333                indent: if indent > 0 { 2 } else { 0 },
334                start_line: Some(line_no),
335                end_line: Some(line_no),
336            });
337        } else if let Some(caps) = rust_struct_re().captures(line) {
338            sigs.push(Signature {
339                kind: "struct",
340                name: caps[3].to_string(),
341                params: String::new(),
342                return_type: String::new(),
343                is_async: false,
344                is_exported: caps.get(2).is_some(),
345                indent: 0,
346                start_line: Some(line_no),
347                end_line: Some(line_no),
348            });
349        } else if let Some(caps) = rust_enum_re().captures(line) {
350            sigs.push(Signature {
351                kind: "enum",
352                name: caps[3].to_string(),
353                params: String::new(),
354                return_type: String::new(),
355                is_async: false,
356                is_exported: caps.get(2).is_some(),
357                indent: 0,
358                start_line: Some(line_no),
359                end_line: Some(line_no),
360            });
361        } else if let Some(caps) = rust_trait_re().captures(line) {
362            sigs.push(Signature {
363                kind: "trait",
364                name: caps[3].to_string(),
365                params: String::new(),
366                return_type: String::new(),
367                is_async: false,
368                is_exported: caps.get(2).is_some(),
369                indent: 0,
370                start_line: Some(line_no),
371                end_line: Some(line_no),
372            });
373        } else if let Some(caps) = rust_impl_re().captures(line) {
374            let trait_name = caps.get(2).map(|m| m.as_str());
375            let type_name = &caps[3];
376            let name = if let Some(t) = trait_name {
377                format!("{t} for {type_name}")
378            } else {
379                type_name.to_string()
380            };
381            sigs.push(Signature {
382                kind: "class",
383                name,
384                params: String::new(),
385                return_type: String::new(),
386                is_async: false,
387                is_exported: false,
388                indent: 0,
389                start_line: Some(line_no),
390                end_line: Some(line_no),
391            });
392        }
393    }
394
395    sigs
396}
397
398fn extract_python_signatures(content: &str) -> Vec<Signature> {
399    let mut sigs = Vec::new();
400    let py_fn = static_regex!(r"^(\s*)(async\s+)?def\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*(\w+))?");
401    let py_class = static_regex!(r"^(\s*)class\s+(\w+)");
402
403    for (line_idx, line) in content.lines().enumerate() {
404        let line_no = line_idx + 1;
405        if let Some(caps) = py_fn.captures(line) {
406            let indent = caps.get(1).map_or(0, |m| m.as_str().len());
407            sigs.push(Signature {
408                kind: if indent > 0 { "method" } else { "fn" },
409                name: caps[3].to_string(),
410                params: compact_params(&caps[4]),
411                return_type: caps
412                    .get(5)
413                    .map_or(String::new(), |m| m.as_str().to_string()),
414                is_async: caps.get(2).is_some(),
415                is_exported: !caps[3].starts_with('_'),
416                indent: if indent > 0 { 2 } else { 0 },
417                start_line: Some(line_no),
418                end_line: Some(line_no),
419            });
420        } else if let Some(caps) = py_class.captures(line) {
421            sigs.push(Signature {
422                kind: "class",
423                name: caps[2].to_string(),
424                params: String::new(),
425                return_type: String::new(),
426                is_async: false,
427                is_exported: !caps[2].starts_with('_'),
428                indent: 0,
429                start_line: Some(line_no),
430                end_line: Some(line_no),
431            });
432        }
433    }
434
435    sigs
436}
437
438fn extract_go_signatures(content: &str) -> Vec<Signature> {
439    let mut sigs = Vec::new();
440    let go_fn = static_regex!(
441        r"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?(\w+)\s*\(([^)]*)\)(?:\s*(?:\(([^)]*)\)|(\w+)))?\s*\{"
442    );
443    let go_type = static_regex!(r"^type\s+(\w+)\s+(struct|interface)");
444
445    for (line_idx, line) in content.lines().enumerate() {
446        let line_no = line_idx + 1;
447        if let Some(caps) = go_fn.captures(line) {
448            let is_method = caps.get(2).is_some();
449            sigs.push(Signature {
450                kind: if is_method { "method" } else { "fn" },
451                name: caps[3].to_string(),
452                params: compact_params(&caps[4]),
453                return_type: caps
454                    .get(5)
455                    .or(caps.get(6))
456                    .map_or(String::new(), |m| m.as_str().to_string()),
457                is_async: false,
458                is_exported: caps[3].starts_with(char::is_uppercase),
459                indent: if is_method { 2 } else { 0 },
460                start_line: Some(line_no),
461                end_line: Some(line_no),
462            });
463        } else if let Some(caps) = go_type.captures(line) {
464            sigs.push(Signature {
465                kind: if &caps[2] == "struct" {
466                    "struct"
467                } else {
468                    "interface"
469                },
470                name: caps[1].to_string(),
471                params: String::new(),
472                return_type: String::new(),
473                is_async: false,
474                is_exported: caps[1].starts_with(char::is_uppercase),
475                indent: 0,
476                start_line: Some(line_no),
477                end_line: Some(line_no),
478            });
479        }
480    }
481
482    sigs
483}
484
485pub(crate) fn compact_params(params: &str) -> String {
486    if params.trim().is_empty() {
487        return String::new();
488    }
489    params
490        .split(',')
491        .map(|p| {
492            let p = p.trim();
493            if let Some((name, ty)) = p.split_once(':') {
494                let name = name.trim();
495                let ty = ty.trim();
496                let short = match ty {
497                    "string" | "String" | "&str" | "str" => ":s",
498                    "number" | "i32" | "i64" | "u32" | "u64" | "usize" | "f32" | "f64" => ":n",
499                    "boolean" | "bool" => ":b",
500                    _ => return format!("{name}:{ty}"),
501                };
502                format!("{name}{short}")
503            } else {
504                p.to_string()
505            }
506        })
507        .collect::<Vec<_>>()
508        .join(", ")
509}
510
511fn compact_type(ty: &str) -> String {
512    match ty.trim() {
513        "String" | "string" | "&str" | "str" => "s".to_string(),
514        "bool" | "boolean" => "b".to_string(),
515        "i32" | "i64" | "u32" | "u64" | "usize" | "f32" | "f64" | "number" => "n".to_string(),
516        "void" | "()" => "∅".to_string(),
517        other => {
518            if other.starts_with("Vec<") || other.starts_with("Array<") {
519                let inner = other
520                    .trim_start_matches("Vec<")
521                    .trim_start_matches("Array<")
522                    .trim_end_matches('>');
523                format!("[{}]", compact_type(inner))
524            } else if other.starts_with("Option<") || other.starts_with("Maybe<") {
525                let inner = other
526                    .trim_start_matches("Option<")
527                    .trim_start_matches("Maybe<")
528                    .trim_end_matches('>');
529                format!("?{}", compact_type(inner))
530            } else if other.starts_with("Result<") {
531                "R".to_string()
532            } else if other.starts_with("impl ") {
533                other.trim_start_matches("impl ").to_string()
534            } else {
535                other.to_string()
536            }
537        }
538    }
539}
540
541fn tdd_params(params: &str) -> String {
542    if params.trim().is_empty() {
543        return String::new();
544    }
545    params
546        .split(',')
547        .map(|p| {
548            let p = p.trim();
549            if p.starts_with('&') {
550                let rest = p.trim_start_matches("&mut ").trim_start_matches('&');
551                if let Some((name, ty)) = rest.split_once(':') {
552                    format!("&{}:{}", name.trim(), compact_type(ty))
553                } else {
554                    p.to_string()
555                }
556            } else if let Some((name, ty)) = p.split_once(':') {
557                format!("{}:{}", name.trim(), compact_type(ty))
558            } else if p == "self" || p == "&self" || p == "&mut self" {
559                "⊕".to_string()
560            } else {
561                p.to_string()
562            }
563        })
564        .collect::<Vec<_>>()
565        .join(",")
566}
567
568fn extract_generic_signatures(content: &str) -> Vec<Signature> {
569    let re_func = static_regex!(
570        r"^\s*(?:(?:public|private|protected|static|async|abstract|virtual|override|final|def|func|fun|fn)\s+)+(\w+)\s*\("
571    );
572    let re_class = static_regex!(
573        r"^\s*(?:(?:public|private|protected|abstract|final|sealed|partial)\s+)*(?:class|struct|enum|interface|trait|module|object|record)\s+(\w+)"
574    );
575
576    let mut sigs = Vec::new();
577    for (line_idx, line) in content.lines().enumerate() {
578        let line_no = line_idx + 1;
579        let trimmed = line.trim();
580        if trimmed.is_empty()
581            || trimmed.starts_with("//")
582            || trimmed.starts_with('#')
583            || trimmed.starts_with("/*")
584            || trimmed.starts_with('*')
585        {
586            continue;
587        }
588        if let Some(caps) = re_class.captures(trimmed) {
589            sigs.push(Signature {
590                kind: "type",
591                name: caps[1].to_string(),
592                params: String::new(),
593                return_type: String::new(),
594                is_async: false,
595                is_exported: true,
596                indent: 0,
597                start_line: Some(line_no),
598                end_line: Some(line_no),
599            });
600        } else if let Some(caps) = re_func.captures(trimmed) {
601            sigs.push(Signature {
602                kind: "fn",
603                name: caps[1].to_string(),
604                params: String::new(),
605                return_type: String::new(),
606                is_async: trimmed.contains("async"),
607                is_exported: true,
608                indent: 0,
609                start_line: Some(line_no),
610                end_line: Some(line_no),
611            });
612        }
613    }
614    sigs
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    fn sample_fn() -> Signature {
622        Signature {
623            kind: "fn",
624            name: "run".to_string(),
625            params: "id:usize".to_string(),
626            return_type: "bool".to_string(),
627            is_async: false,
628            is_exported: true,
629            indent: 0,
630            start_line: None,
631            end_line: None,
632        }
633    }
634
635    #[test]
636    fn line_suffix_formats_known_spans() {
637        let mut sig = sample_fn();
638        assert_eq!(sig.line_suffix(), "");
639
640        sig.start_line = Some(42);
641        sig.end_line = Some(42);
642        assert_eq!(sig.line_suffix(), " @L42");
643
644        sig.end_line = Some(57);
645        assert_eq!(sig.line_suffix(), " @L42-57");
646    }
647
648    #[test]
649    fn base_renderers_stay_suffix_free() {
650        // Compression-first modes must never pay for line ranges, even when
651        // the span is known.
652        let mut sig = sample_fn();
653        sig.start_line = Some(3);
654        sig.end_line = Some(9);
655        assert_eq!(sig.to_compact(), "fn ⊛ run(id:usize) → bool");
656        assert_eq!(sig.to_tdd(), "λ+run(id:n)→b");
657    }
658
659    #[test]
660    fn located_renderers_append_line_suffix() {
661        let mut sig = sample_fn();
662        // Unknown span → identical to the base renderer.
663        assert_eq!(sig.to_compact_located(), "fn ⊛ run(id:usize) → bool");
664        assert_eq!(sig.to_tdd_located(), "λ+run(id:n)→b");
665
666        sig.start_line = Some(3);
667        sig.end_line = Some(5);
668        assert_eq!(sig.to_compact_located(), "fn ⊛ run(id:usize) → bool @L3-5");
669        assert_eq!(sig.to_tdd_located(), "λ+run(id:n)→b @L3-5");
670    }
671
672    #[test]
673    fn regex_fallback_assigns_declaration_line_spans() {
674        let src = "\npublic class Service {}\n\npublic fn run() {\n}\n";
675        let sigs = extract_generic_signatures(src);
676
677        let service = sigs.iter().find(|s| s.name == "Service").unwrap();
678        assert_eq!(service.start_line, Some(2));
679        assert_eq!(service.end_line, Some(2));
680
681        let run = sigs.iter().find(|s| s.name == "run").unwrap();
682        assert_eq!(run.start_line, Some(4));
683        assert_eq!(run.end_line, Some(4));
684    }
685}