1use regex::Regex;
16
17macro_rules! static_regex {
18 ($pattern:expr_2021) => {{
19 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
20 RE.get_or_init(|| {
21 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
22 })
23 }};
24}
25
26#[derive(Debug, Clone)]
27pub struct Signature {
28 pub kind: &'static str,
29 pub name: String,
30 pub params: String,
31 pub return_type: String,
32 pub is_async: bool,
33 pub is_exported: bool,
34 pub indent: usize,
35 pub start_line: Option<usize>,
36 pub end_line: Option<usize>,
37}
38
39#[must_use]
46pub fn exports_not_in_signatures<'a>(
47 exports: &'a [String],
48 key_sigs: &[&Signature],
49) -> Vec<&'a str> {
50 let api_names: std::collections::HashSet<&str> =
51 key_sigs.iter().map(|s| s.name.as_str()).collect();
52 exports
53 .iter()
54 .map(String::as_str)
55 .filter(|e| !api_names.contains(e))
56 .collect()
57}
58
59impl Signature {
60 pub fn no_span() -> Self {
61 Self {
62 kind: "",
63 name: String::new(),
64 params: String::new(),
65 return_type: String::new(),
66 is_async: false,
67 is_exported: false,
68 indent: 0,
69 start_line: None,
70 end_line: None,
71 }
72 }
73
74 pub fn to_compact(&self) -> String {
79 let export = if self.is_exported { "pub " } else { "" };
80 let async_prefix = if self.is_async { "async " } else { "" };
81
82 match self.kind {
83 "fn" | "method" => {
84 let ret = if self.return_type.is_empty() {
85 String::new()
86 } else {
87 format!(" → {}", self.return_type)
88 };
89 let indent = " ".repeat(self.indent);
90 format!(
91 "{indent}fn {async_prefix}{export}{}({}){}",
92 self.name, self.params, ret
93 )
94 }
95 "const" | "let" | "var" => {
96 let ty = if self.return_type.is_empty() {
97 String::new()
98 } else {
99 format!(":{}", self.return_type)
100 };
101 format!("{} {export}{}{ty}", self.kind, self.name)
102 }
103 _ => format!("{} {export}{}", self.kind, self.name),
106 }
107 }
108
109 pub fn to_tdd(&self) -> String {
110 let vis = if self.is_exported { "+" } else { "-" };
111 let a = if self.is_async { "~" } else { "" };
112
113 match self.kind {
114 "fn" | "method" => {
115 let ret = if self.return_type.is_empty() {
116 String::new()
117 } else {
118 format!("→{}", compact_type(&self.return_type))
119 };
120 let params = tdd_params(&self.params);
121 let indent = if self.indent > 0 { " " } else { "" };
122 format!("{indent}{a}λ{vis}{}({params}){ret}", self.name)
123 }
124 "class" | "struct" => format!("§{vis}{}", self.name),
125 "interface" | "trait" => format!("∂{vis}{}", self.name),
126 "type" => format!("τ{vis}{}", self.name),
127 "enum" => format!("ε{vis}{}", self.name),
128 "const" | "let" | "var" => {
129 let ty = if self.return_type.is_empty() {
130 String::new()
131 } else {
132 format!(":{}", compact_type(&self.return_type))
133 };
134 format!("ν{vis}{}{ty}", self.name)
135 }
136 _ => format!(
137 "{}{vis}{}",
138 self.kind.chars().next().unwrap_or('?'),
139 self.name
140 ),
141 }
142 }
143
144 pub fn line_suffix(&self) -> String {
148 match (self.start_line, self.end_line) {
149 (Some(start), Some(end)) if start > 0 && end > start => format!(" @L{start}-{end}"),
150 (Some(start), _) if start > 0 => format!(" @L{start}"),
151 _ => String::new(),
152 }
153 }
154
155 pub fn to_compact_located(&self) -> String {
158 format!("{}{}", self.to_compact(), self.line_suffix())
159 }
160
161 pub fn to_tdd_located(&self) -> String {
163 format!("{}{}", self.to_tdd(), self.line_suffix())
164 }
165}
166
167pub fn tdd_legend<'a>(sigs: &[&'a Signature]) -> String {
172 if sigs.is_empty() {
173 return String::new();
174 }
175 let mut parts: Vec<&str> = Vec::new();
176 let has = |pred: &dyn Fn(&'a Signature) -> bool| sigs.iter().any(|s| pred(s));
177
178 if has(&|s| matches!(s.kind, "fn" | "method")) {
179 parts.push("λ=fn");
180 }
181 if has(&|s| matches!(s.kind, "class" | "struct")) {
182 parts.push("§=class");
183 }
184 if has(&|s| matches!(s.kind, "interface" | "trait")) {
185 parts.push("∂=trait");
186 }
187 if has(&|s| s.kind == "type") {
188 parts.push("τ=type");
189 }
190 if has(&|s| s.kind == "enum") {
191 parts.push("ε=enum");
192 }
193 if has(&|s| matches!(s.kind, "const" | "let" | "var")) {
194 parts.push("ν=val");
195 }
196 if has(&|s| s.is_exported) {
197 parts.push("+=pub");
198 }
199 if has(&|s| s.is_async) {
200 parts.push("~=async");
201 }
202 if parts.is_empty() {
203 String::new()
204 } else {
205 format!("[{}]", parts.join(" "))
206 }
207}
208
209fn fn_re() -> &'static Regex {
210 static_regex!(
211 r"^(\s*)(export\s+)?(async\s+)?function\s+(\w+)\s*(?:<[^>]*>)?\s*\(([^)]*)\)(?:\s*:\s*([^\{]+))?\s*\{?"
212 )
213}
214
215fn class_re() -> &'static Regex {
216 static_regex!(r"^(\s*)(export\s+)?(abstract\s+)?class\s+(\w+)")
217}
218
219fn iface_re() -> &'static Regex {
220 static_regex!(r"^(\s*)(export\s+)?interface\s+(\w+)")
221}
222
223fn type_re() -> &'static Regex {
224 static_regex!(r"^(\s*)(export\s+)?type\s+(\w+)")
225}
226
227fn const_re() -> &'static Regex {
228 static_regex!(r"^(\s*)(export\s+)?(const|let|var)\s+(\w+)(?:\s*:\s*(\w+))?")
229}
230
231fn rust_fn_re() -> &'static Regex {
232 static_regex!(
233 r"^(\s*)(pub\s+)?(async\s+)?fn\s+(\w+)\s*(?:<[^>]*>)?\s*\(([^)]*)\)(?:\s*->\s*([^\{]+))?\s*\{?"
234 )
235}
236
237fn rust_struct_re() -> &'static Regex {
238 static_regex!(r"^(\s*)(pub\s+)?struct\s+(\w+)")
239}
240
241fn rust_enum_re() -> &'static Regex {
242 static_regex!(r"^(\s*)(pub\s+)?enum\s+(\w+)")
243}
244
245fn rust_trait_re() -> &'static Regex {
246 static_regex!(r"^(\s*)(pub\s+)?trait\s+(\w+)")
247}
248
249fn rust_impl_re() -> &'static Regex {
250 static_regex!(r"^(\s*)impl\s+(?:(\w+)\s+for\s+)?(\w+)")
251}
252
253use std::sync::atomic::{AtomicU64, Ordering};
254
255static TREE_SITTER_HITS: AtomicU64 = AtomicU64::new(0);
256static REGEX_FALLBACK_HITS: AtomicU64 = AtomicU64::new(0);
257
258pub fn signature_backend_stats() -> (u64, u64) {
260 (
261 TREE_SITTER_HITS.load(Ordering::Relaxed),
262 REGEX_FALLBACK_HITS.load(Ordering::Relaxed),
263 )
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum SigBackend {
273 TreeSitter,
274 Regex,
275}
276
277impl SigBackend {
278 #[must_use]
279 pub fn as_str(self) -> &'static str {
280 match self {
281 SigBackend::TreeSitter => "tree-sitter",
282 SigBackend::Regex => "regex",
283 }
284 }
285}
286
287#[cfg(feature = "tree-sitter")]
291mod ts_blocklist {
292 use std::collections::HashSet;
293 use std::sync::Mutex;
294
295 static BLOCKED: Mutex<Option<HashSet<String>>> = Mutex::new(None);
296
297 fn lock() -> std::sync::MutexGuard<'static, Option<HashSet<String>>> {
298 match BLOCKED.lock() {
299 Ok(g) => g,
300 Err(poisoned) => poisoned.into_inner(),
301 }
302 }
303
304 pub(super) fn is_blocked(ext: &str) -> bool {
305 lock().as_ref().is_some_and(|set| set.contains(ext))
306 }
307
308 pub(super) fn block(ext: &str) {
309 lock()
310 .get_or_insert_with(HashSet::new)
311 .insert(ext.to_string());
312 }
313}
314
315#[cfg(feature = "tree-sitter")]
316use ts_blocklist::{block as block_ts_language, is_blocked as is_ts_language_blocked};
317
318pub fn extract_signatures(content: &str, file_ext: &str) -> Vec<Signature> {
319 let (sigs, backend) = extract_signatures_with_backend(content, file_ext);
320 let is_tree_sitter = matches!(backend, SigBackend::TreeSitter);
321 match backend {
322 SigBackend::TreeSitter => TREE_SITTER_HITS.fetch_add(1, Ordering::Relaxed),
323 SigBackend::Regex => REGEX_FALLBACK_HITS.fetch_add(1, Ordering::Relaxed),
324 };
325 crate::core::grammar_usage::record(file_ext, is_tree_sitter);
329 sigs
330}
331
332pub fn extract_signatures_with_backend(
337 content: &str,
338 file_ext: &str,
339) -> (Vec<Signature>, SigBackend) {
340 #[cfg(feature = "tree-sitter")]
341 {
342 if !is_ts_language_blocked(file_ext) {
353 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
354 super::signatures_ts::extract_signatures_ts(content, file_ext)
355 })) {
356 Ok(Some(sigs)) if !sigs.is_empty() => {
357 return (sigs, SigBackend::TreeSitter);
358 }
359 Err(_) => {
360 block_ts_language(file_ext);
361 eprintln!(
362 "[lean-ctx] tree-sitter panicked for .{file_ext} — using regex fallback"
363 );
364 }
365 _ => {}
366 }
367 }
368 }
369
370 let sigs = match file_ext {
371 "rs" => extract_rust_signatures(content),
372 "ts" | "tsx" | "js" | "jsx" | "svelte" | "vue" => extract_ts_signatures(content),
373 "py" => extract_python_signatures(content),
374 "go" => extract_go_signatures(content),
375 _ => extract_generic_signatures(content),
376 };
377 (sigs, SigBackend::Regex)
378}
379
380pub fn extract_file_map(path: &str, content: &str) -> String {
381 let ext = std::path::Path::new(path)
382 .extension()
383 .and_then(|e| e.to_str())
384 .unwrap_or("rs");
385 let dep_info = super::deps::extract_deps(content, ext);
386 let sigs = extract_signatures(content, ext);
387 let mut parts = Vec::new();
388 if !dep_info.imports.is_empty() {
389 parts.push(dep_info.imports.join(","));
390 }
391 let key_sigs: Vec<String> = sigs
392 .iter()
393 .filter(|s| s.is_exported || s.indent == 0)
394 .map(Signature::to_compact_located)
395 .collect();
396 if !key_sigs.is_empty() {
397 parts.push(key_sigs.join("\n"));
398 }
399 parts.join("\n")
400}
401
402fn extract_ts_signatures(content: &str) -> Vec<Signature> {
403 let mut sigs = Vec::new();
404
405 for (line_idx, line) in content.lines().enumerate() {
406 let line_no = line_idx + 1;
407 let trimmed = line.trim();
408 if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') {
409 continue;
410 }
411
412 if let Some(caps) = fn_re().captures(line) {
413 let indent = caps.get(1).map_or(0, |m| m.as_str().len());
414 sigs.push(Signature {
415 kind: if indent > 0 { "method" } else { "fn" },
416 name: caps[4].to_string(),
417 params: compact_params(&caps[5]),
418 return_type: caps
419 .get(6)
420 .map_or(String::new(), |m| m.as_str().trim().to_string()),
421 is_async: caps.get(3).is_some(),
422 is_exported: caps.get(2).is_some(),
423 indent: if indent > 0 { 2 } else { 0 },
424 start_line: Some(line_no),
425 end_line: Some(line_no),
426 });
427 } else if let Some(caps) = class_re().captures(line) {
428 sigs.push(Signature {
429 kind: "class",
430 name: caps[4].to_string(),
431 params: String::new(),
432 return_type: String::new(),
433 is_async: false,
434 is_exported: caps.get(2).is_some(),
435 indent: 0,
436 start_line: Some(line_no),
437 end_line: Some(line_no),
438 });
439 } else if let Some(caps) = iface_re().captures(line) {
440 sigs.push(Signature {
441 kind: "interface",
442 name: caps[3].to_string(),
443 params: String::new(),
444 return_type: String::new(),
445 is_async: false,
446 is_exported: caps.get(2).is_some(),
447 indent: 0,
448 start_line: Some(line_no),
449 end_line: Some(line_no),
450 });
451 } else if let Some(caps) = type_re().captures(line) {
452 sigs.push(Signature {
453 kind: "type",
454 name: caps[3].to_string(),
455 params: String::new(),
456 return_type: String::new(),
457 is_async: false,
458 is_exported: caps.get(2).is_some(),
459 indent: 0,
460 start_line: Some(line_no),
461 end_line: Some(line_no),
462 });
463 } else if let Some(caps) = const_re().captures(line)
464 && caps.get(2).is_some()
465 {
466 sigs.push(Signature {
467 kind: "const",
468 name: caps[4].to_string(),
469 params: String::new(),
470 return_type: caps
471 .get(5)
472 .map_or(String::new(), |m| m.as_str().to_string()),
473 is_async: false,
474 is_exported: true,
475 indent: 0,
476 start_line: Some(line_no),
477 end_line: Some(line_no),
478 });
479 }
480 }
481
482 sigs
483}
484
485fn extract_rust_signatures(content: &str) -> Vec<Signature> {
486 let mut sigs = Vec::new();
487
488 for (line_idx, line) in content.lines().enumerate() {
489 let line_no = line_idx + 1;
490 let trimmed = line.trim();
491 if trimmed.starts_with("//") || trimmed.starts_with("///") {
492 continue;
493 }
494
495 if let Some(caps) = rust_fn_re().captures(line) {
496 let indent = caps.get(1).map_or(0, |m| m.as_str().len());
497 sigs.push(Signature {
498 kind: if indent > 0 { "method" } else { "fn" },
499 name: caps[4].to_string(),
500 params: compact_params(&caps[5]),
501 return_type: caps
502 .get(6)
503 .map_or(String::new(), |m| m.as_str().trim().to_string()),
504 is_async: caps.get(3).is_some(),
505 is_exported: caps.get(2).is_some(),
506 indent: if indent > 0 { 2 } else { 0 },
507 start_line: Some(line_no),
508 end_line: Some(line_no),
509 });
510 } else if let Some(caps) = rust_struct_re().captures(line) {
511 sigs.push(Signature {
512 kind: "struct",
513 name: caps[3].to_string(),
514 params: String::new(),
515 return_type: String::new(),
516 is_async: false,
517 is_exported: caps.get(2).is_some(),
518 indent: 0,
519 start_line: Some(line_no),
520 end_line: Some(line_no),
521 });
522 } else if let Some(caps) = rust_enum_re().captures(line) {
523 sigs.push(Signature {
524 kind: "enum",
525 name: caps[3].to_string(),
526 params: String::new(),
527 return_type: String::new(),
528 is_async: false,
529 is_exported: caps.get(2).is_some(),
530 indent: 0,
531 start_line: Some(line_no),
532 end_line: Some(line_no),
533 });
534 } else if let Some(caps) = rust_trait_re().captures(line) {
535 sigs.push(Signature {
536 kind: "trait",
537 name: caps[3].to_string(),
538 params: String::new(),
539 return_type: String::new(),
540 is_async: false,
541 is_exported: caps.get(2).is_some(),
542 indent: 0,
543 start_line: Some(line_no),
544 end_line: Some(line_no),
545 });
546 } else if let Some(caps) = rust_impl_re().captures(line) {
547 let trait_name = caps.get(2).map(|m| m.as_str());
548 let type_name = &caps[3];
549 let name = if let Some(t) = trait_name {
550 format!("{t} for {type_name}")
551 } else {
552 type_name.to_string()
553 };
554 sigs.push(Signature {
555 kind: "impl",
556 name,
557 params: String::new(),
558 return_type: String::new(),
559 is_async: false,
560 is_exported: false,
561 indent: 0,
562 start_line: Some(line_no),
563 end_line: Some(line_no),
564 });
565 }
566 }
567
568 sigs
569}
570
571fn extract_python_signatures(content: &str) -> Vec<Signature> {
572 let mut sigs = Vec::new();
573 let py_fn = static_regex!(r"^(\s*)(async\s+)?def\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*(\w+))?");
574 let py_class = static_regex!(r"^(\s*)class\s+(\w+)");
575
576 for (line_idx, line) in content.lines().enumerate() {
577 let line_no = line_idx + 1;
578 if let Some(caps) = py_fn.captures(line) {
579 let indent = caps.get(1).map_or(0, |m| m.as_str().len());
580 sigs.push(Signature {
581 kind: if indent > 0 { "method" } else { "fn" },
582 name: caps[3].to_string(),
583 params: compact_params(&caps[4]),
584 return_type: caps
585 .get(5)
586 .map_or(String::new(), |m| m.as_str().to_string()),
587 is_async: caps.get(2).is_some(),
588 is_exported: !caps[3].starts_with('_'),
589 indent: if indent > 0 { 2 } else { 0 },
590 start_line: Some(line_no),
591 end_line: Some(line_no),
592 });
593 } else if let Some(caps) = py_class.captures(line) {
594 sigs.push(Signature {
595 kind: "class",
596 name: caps[2].to_string(),
597 params: String::new(),
598 return_type: String::new(),
599 is_async: false,
600 is_exported: !caps[2].starts_with('_'),
601 indent: 0,
602 start_line: Some(line_no),
603 end_line: Some(line_no),
604 });
605 }
606 }
607
608 sigs
609}
610
611fn extract_go_signatures(content: &str) -> Vec<Signature> {
612 let mut sigs = Vec::new();
613 let go_fn = static_regex!(
614 r"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?(\w+)\s*\(([^)]*)\)(?:\s*(?:\(([^)]*)\)|(\w+)))?\s*\{"
615 );
616 let go_type = static_regex!(r"^type\s+(\w+)\s+(struct|interface)");
617
618 for (line_idx, line) in content.lines().enumerate() {
619 let line_no = line_idx + 1;
620 if let Some(caps) = go_fn.captures(line) {
621 let is_method = caps.get(2).is_some();
622 sigs.push(Signature {
623 kind: if is_method { "method" } else { "fn" },
624 name: caps[3].to_string(),
625 params: compact_params(&caps[4]),
626 return_type: caps
627 .get(5)
628 .or(caps.get(6))
629 .map_or(String::new(), |m| m.as_str().to_string()),
630 is_async: false,
631 is_exported: caps[3].starts_with(char::is_uppercase),
632 indent: if is_method { 2 } else { 0 },
633 start_line: Some(line_no),
634 end_line: Some(line_no),
635 });
636 } else if let Some(caps) = go_type.captures(line) {
637 sigs.push(Signature {
638 kind: if &caps[2] == "struct" {
639 "struct"
640 } else {
641 "interface"
642 },
643 name: caps[1].to_string(),
644 params: String::new(),
645 return_type: String::new(),
646 is_async: false,
647 is_exported: caps[1].starts_with(char::is_uppercase),
648 indent: 0,
649 start_line: Some(line_no),
650 end_line: Some(line_no),
651 });
652 }
653 }
654
655 sigs
656}
657
658pub(crate) fn compact_params(params: &str) -> String {
659 if params.trim().is_empty() {
660 return String::new();
661 }
662 params
663 .split(',')
664 .map(|p| {
665 let p = p.trim();
666 if let Some((name, ty)) = p.split_once(':') {
667 let name = name.trim();
668 let ty = ty.trim();
669 let short = match ty {
670 "string" | "String" | "&str" | "str" => ":s",
671 "number" | "i32" | "i64" | "u32" | "u64" | "usize" | "f32" | "f64" => ":n",
672 "boolean" | "bool" => ":b",
673 _ => return format!("{name}:{ty}"),
674 };
675 format!("{name}{short}")
676 } else {
677 p.to_string()
678 }
679 })
680 .collect::<Vec<_>>()
681 .join(", ")
682}
683
684fn compact_type(ty: &str) -> String {
685 match ty.trim() {
686 "String" | "string" | "&str" | "str" => "s".to_string(),
687 "bool" | "boolean" => "b".to_string(),
688 "i32" | "i64" | "u32" | "u64" | "usize" | "f32" | "f64" | "number" => "n".to_string(),
689 "void" | "()" => "∅".to_string(),
690 other => {
691 if other.starts_with("Vec<") || other.starts_with("Array<") {
692 let inner = other
693 .trim_start_matches("Vec<")
694 .trim_start_matches("Array<")
695 .trim_end_matches('>');
696 format!("[{}]", compact_type(inner))
697 } else if other.starts_with("Option<") || other.starts_with("Maybe<") {
698 let inner = other
699 .trim_start_matches("Option<")
700 .trim_start_matches("Maybe<")
701 .trim_end_matches('>');
702 format!("?{}", compact_type(inner))
703 } else if other.starts_with("Result<") {
704 "R".to_string()
705 } else if other.starts_with("impl ") {
706 other.trim_start_matches("impl ").to_string()
707 } else {
708 other.to_string()
709 }
710 }
711 }
712}
713
714fn tdd_params(params: &str) -> String {
715 if params.trim().is_empty() {
716 return String::new();
717 }
718 params
719 .split(',')
720 .map(|p| {
721 let p = p.trim();
722 if p.starts_with('&') {
723 let rest = p.trim_start_matches("&mut ").trim_start_matches('&');
724 if let Some((name, ty)) = rest.split_once(':') {
725 format!("&{}:{}", name.trim(), compact_type(ty))
726 } else {
727 p.to_string()
728 }
729 } else if let Some((name, ty)) = p.split_once(':') {
730 format!("{}:{}", name.trim(), compact_type(ty))
731 } else if p == "self" || p == "&self" || p == "&mut self" {
732 "⊕".to_string()
733 } else {
734 p.to_string()
735 }
736 })
737 .collect::<Vec<_>>()
738 .join(",")
739}
740
741fn extract_generic_signatures(content: &str) -> Vec<Signature> {
742 let re_func = static_regex!(
743 r"^\s*(?:(?:public|private|protected|static|async|abstract|virtual|override|final|def|func|fun|fn)\s+)+(\w+)\s*\("
744 );
745 let re_ps_func = static_regex!(r"^\s*function\s+([\w-]+)");
749 let re_class = static_regex!(
750 r"^\s*(?:(?:public|private|protected|abstract|final|sealed|partial)\s+)*(?:class|struct|enum|interface|trait|module|object|record)\s+(\w+)"
751 );
752
753 let mut sigs = Vec::new();
754 for (line_idx, line) in content.lines().enumerate() {
755 let line_no = line_idx + 1;
756 let trimmed = line.trim();
757 if trimmed.is_empty()
758 || trimmed.starts_with("//")
759 || trimmed.starts_with('#')
760 || trimmed.starts_with("/*")
761 || trimmed.starts_with('*')
762 {
763 continue;
764 }
765 if let Some(caps) = re_class.captures(trimmed) {
766 sigs.push(Signature {
767 kind: "type",
768 name: caps[1].to_string(),
769 params: String::new(),
770 return_type: String::new(),
771 is_async: false,
772 is_exported: true,
773 indent: 0,
774 start_line: Some(line_no),
775 end_line: Some(line_no),
776 });
777 } else if let Some(caps) = re_func.captures(trimmed) {
778 sigs.push(Signature {
779 kind: "fn",
780 name: caps[1].to_string(),
781 params: String::new(),
782 return_type: String::new(),
783 is_async: trimmed.contains("async"),
784 is_exported: true,
785 indent: 0,
786 start_line: Some(line_no),
787 end_line: Some(line_no),
788 });
789 } else if let Some(caps) = re_ps_func.captures(trimmed) {
790 sigs.push(Signature {
791 kind: "fn",
792 name: caps[1].to_string(),
793 params: String::new(),
794 return_type: String::new(),
795 is_async: false,
796 is_exported: true,
797 indent: 0,
798 start_line: Some(line_no),
799 end_line: Some(line_no),
800 });
801 }
802 }
803 sigs
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809
810 fn sample_fn() -> Signature {
811 Signature {
812 kind: "fn",
813 name: "run".to_string(),
814 params: "id:usize".to_string(),
815 return_type: "bool".to_string(),
816 is_async: false,
817 is_exported: true,
818 indent: 0,
819 start_line: None,
820 end_line: None,
821 }
822 }
823
824 #[test]
825 fn line_suffix_formats_known_spans() {
826 let mut sig = sample_fn();
827 assert_eq!(sig.line_suffix(), "");
828
829 sig.start_line = Some(42);
830 sig.end_line = Some(42);
831 assert_eq!(sig.line_suffix(), " @L42");
832
833 sig.end_line = Some(57);
834 assert_eq!(sig.line_suffix(), " @L42-57");
835 }
836
837 #[test]
838 fn base_renderers_stay_suffix_free() {
839 let mut sig = sample_fn();
842 sig.start_line = Some(3);
843 sig.end_line = Some(9);
844 assert_eq!(sig.to_compact(), "fn pub run(id:usize) → bool");
845 assert_eq!(sig.to_tdd(), "λ+run(id:n)→b");
846 }
847
848 #[test]
849 fn located_renderers_append_line_suffix() {
850 let mut sig = sample_fn();
851 assert_eq!(sig.to_compact_located(), "fn pub run(id:usize) → bool");
853 assert_eq!(sig.to_tdd_located(), "λ+run(id:n)→b");
854
855 sig.start_line = Some(3);
856 sig.end_line = Some(5);
857 assert_eq!(
858 sig.to_compact_located(),
859 "fn pub run(id:usize) → bool @L3-5"
860 );
861 assert_eq!(sig.to_tdd_located(), "λ+run(id:n)→b @L3-5");
862 }
863
864 #[test]
865 fn plain_notation_is_self_describing() {
866 let mut sig = sample_fn();
868 sig.kind = "struct";
869 assert_eq!(sig.to_compact(), "struct pub run");
870 sig.kind = "trait";
871 assert_eq!(sig.to_compact(), "trait pub run");
872 sig.kind = "enum";
873 sig.is_exported = false;
874 assert_eq!(sig.to_compact(), "enum run");
875 sig.kind = "const";
876 sig.return_type = "u32".to_string();
877 assert_eq!(sig.to_compact(), "const run:u32");
878 }
879
880 #[test]
881 fn tdd_legend_explains_only_present_symbols() {
882 let f = sample_fn();
884 let mut s = sample_fn();
885 s.kind = "struct";
886 s.is_exported = false;
887
888 let legend = tdd_legend(&[&f, &s]);
889 assert_eq!(legend, "[λ=fn §=class +=pub]");
890 assert!(crate::core::tokens::count_tokens(&legend) <= 15, "{legend}");
892
893 assert_eq!(tdd_legend(&[]), "");
894 }
895
896 #[test]
897 fn regex_fallback_assigns_declaration_line_spans() {
898 let src = "\npublic class Service {}\n\npublic fn run() {\n}\n";
899 let sigs = extract_generic_signatures(src);
900
901 let service = sigs.iter().find(|s| s.name == "Service").unwrap();
902 assert_eq!(service.start_line, Some(2));
903 assert_eq!(service.end_line, Some(2));
904
905 let run = sigs.iter().find(|s| s.name == "run").unwrap();
906 assert_eq!(run.start_line, Some(4));
907 assert_eq!(run.end_line, Some(4));
908 }
909
910 #[test]
914 fn regex_fallback_matches_powershell_functions() {
915 let src = "function Get-CargoBinDir {\n}\nfunction Install($x) {\n}\n# function Commented-Out {\n";
916 let sigs = extract_generic_signatures(src);
917 let names: Vec<&str> = sigs.iter().map(|s| s.name.as_str()).collect();
918 assert!(names.contains(&"Get-CargoBinDir"), "got {names:?}");
919 assert!(names.contains(&"Install"), "got {names:?}");
920 assert!(
921 !names.contains(&"Commented-Out"),
922 "comment must be skipped; got {names:?}"
923 );
924 }
925}