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