Skip to main content

lean_ctx/core/
signatures.rs

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