Skip to main content

tokmd_analysis_api_surface/
lib.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::Result;
5use tokmd_analysis_types::{ApiExportItem, ApiSurfaceReport, LangApiSurface, ModuleApiRow};
6use tokmd_types::{ExportData, FileKind, FileRow};
7
8use tokmd_analysis_util::{AnalysisLimits, normalize_path};
9
10const DEFAULT_MAX_FILE_BYTES: u64 = 128 * 1024;
11const MAX_TOP_EXPORTERS: usize = 20;
12const MAX_BY_MODULE: usize = 50;
13
14/// Languages supported for API surface analysis.
15fn is_api_surface_lang(lang: &str) -> bool {
16    matches!(
17        lang.to_lowercase().as_str(),
18        "rust" | "javascript" | "typescript" | "python" | "go" | "java"
19    )
20}
21
22/// Represents a single discovered symbol.
23#[derive(Debug)]
24struct Symbol {
25    is_public: bool,
26    is_documented: bool,
27}
28
29/// Scan a file for public/internal symbols and documentation.
30fn extract_symbols(lang: &str, text: &str) -> Vec<Symbol> {
31    let lines: Vec<&str> = text.lines().collect();
32    match lang.to_lowercase().as_str() {
33        "rust" => extract_rust_symbols(&lines),
34        "javascript" | "typescript" => extract_js_ts_symbols(&lines),
35        "python" => extract_python_symbols(&lines),
36        "go" => extract_go_symbols(&lines),
37        "java" => extract_java_symbols(&lines),
38        _ => Vec::new(),
39    }
40}
41
42/// Check whether the line preceding a symbol looks like a doc comment.
43fn has_doc_comment(lines: &[&str], idx: usize) -> bool {
44    if idx == 0 {
45        return false;
46    }
47    let prev = lines[idx - 1].trim();
48    // Rust: /// or //! or #[doc
49    // JS/TS/Java: /** or //
50    // Python: """ or ''' (handled separately)
51    // Go: // directly before declaration
52    prev.starts_with("///")
53        || prev.starts_with("//!")
54        || prev.starts_with("/**")
55        || prev.starts_with("#[doc")
56        || prev.starts_with("/// ")
57        || prev.starts_with("// ")
58        || prev.starts_with("\"\"\"")
59        || prev.starts_with("'''")
60}
61
62// -------
63// Rust
64// -------
65
66fn extract_rust_symbols(lines: &[&str]) -> Vec<Symbol> {
67    let mut symbols = Vec::new();
68
69    for (i, line) in lines.iter().enumerate() {
70        let trimmed = line.trim();
71        // Skip lines inside string literals or comments (simple heuristic)
72        if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") {
73            continue;
74        }
75
76        let is_public = is_rust_pub_item(trimmed);
77        let is_internal = is_rust_internal_item(trimmed);
78
79        if is_public || is_internal {
80            symbols.push(Symbol {
81                is_public,
82                is_documented: has_doc_comment(lines, i),
83            });
84        }
85    }
86
87    symbols
88}
89
90fn is_rust_pub_item(trimmed: &str) -> bool {
91    // Match pub items, including pub(crate), pub(super), pub(in ...)
92    if !trimmed.starts_with("pub ") && !trimmed.starts_with("pub(") {
93        return false;
94    }
95
96    // Find the part after the pub qualifier
97    let after_pub = if trimmed.starts_with("pub(") {
98        // Find matching close paren
99        if let Some(close) = trimmed.find(')') {
100            trimmed[close + 1..].trim_start()
101        } else {
102            return false;
103        }
104    } else {
105        // "pub " prefix
106        &trimmed[4..]
107    };
108
109    // Now check for item keywords
110    after_pub.starts_with("fn ")
111        || after_pub.starts_with("struct ")
112        || after_pub.starts_with("enum ")
113        || after_pub.starts_with("trait ")
114        || after_pub.starts_with("type ")
115        || after_pub.starts_with("const ")
116        || after_pub.starts_with("static ")
117        || after_pub.starts_with("mod ")
118        || after_pub.starts_with("async fn ")
119        || after_pub.starts_with("unsafe fn ")
120        || after_pub.starts_with("unsafe trait ")
121}
122
123fn is_rust_internal_item(trimmed: &str) -> bool {
124    // Non-pub items at start of line (no leading whitespace for top-level heuristic
125    // but we keep it simple: any fn/struct/etc. without pub)
126    if trimmed.starts_with("pub ") || trimmed.starts_with("pub(") {
127        return false;
128    }
129
130    trimmed.starts_with("fn ")
131        || trimmed.starts_with("struct ")
132        || trimmed.starts_with("enum ")
133        || trimmed.starts_with("trait ")
134        || trimmed.starts_with("type ")
135        || trimmed.starts_with("const ")
136        || trimmed.starts_with("static ")
137        || trimmed.starts_with("mod ")
138        || trimmed.starts_with("async fn ")
139        || trimmed.starts_with("unsafe fn ")
140        || trimmed.starts_with("unsafe trait ")
141}
142
143// -------
144// JS/TS
145// -------
146
147fn extract_js_ts_symbols(lines: &[&str]) -> Vec<Symbol> {
148    let mut symbols = Vec::new();
149
150    for (i, line) in lines.iter().enumerate() {
151        let trimmed = line.trim();
152
153        if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") {
154            continue;
155        }
156
157        let is_public = is_js_export(trimmed);
158        let is_internal = !is_public && is_js_internal(trimmed);
159
160        if is_public || is_internal {
161            symbols.push(Symbol {
162                is_public,
163                is_documented: has_doc_comment(lines, i),
164            });
165        }
166    }
167
168    symbols
169}
170
171fn is_js_export(trimmed: &str) -> bool {
172    trimmed.starts_with("export function ")
173        || trimmed.starts_with("export async function ")
174        || trimmed.starts_with("export class ")
175        || trimmed.starts_with("export const ")
176        || trimmed.starts_with("export let ")
177        || trimmed.starts_with("export default ")
178        || trimmed.starts_with("export interface ")
179        || trimmed.starts_with("export type ")
180        || trimmed.starts_with("export enum ")
181        || trimmed.starts_with("export abstract class ")
182}
183
184fn is_js_internal(trimmed: &str) -> bool {
185    trimmed.starts_with("function ")
186        || trimmed.starts_with("async function ")
187        || trimmed.starts_with("class ")
188        || trimmed.starts_with("const ")
189        || trimmed.starts_with("let ")
190        || trimmed.starts_with("interface ")
191        || trimmed.starts_with("type ")
192        || trimmed.starts_with("enum ")
193}
194
195// -------
196// Python
197// -------
198
199fn extract_python_symbols(lines: &[&str]) -> Vec<Symbol> {
200    let mut symbols = Vec::new();
201
202    for (i, line) in lines.iter().enumerate() {
203        let trimmed = line.trim();
204
205        // Only consider top-level items (no leading whitespace)
206        if line.starts_with(' ') || line.starts_with('\t') {
207            continue;
208        }
209        if trimmed.starts_with('#') || trimmed.is_empty() {
210            continue;
211        }
212
213        let is_symbol = trimmed.starts_with("def ")
214            || trimmed.starts_with("async def ")
215            || trimmed.starts_with("class ");
216
217        if is_symbol {
218            let name = extract_python_name(trimmed);
219            let is_public = !name.starts_with('_');
220            let documented = has_python_docstring(lines, i);
221            symbols.push(Symbol {
222                is_public,
223                is_documented: documented || has_doc_comment(lines, i),
224            });
225        }
226    }
227
228    symbols
229}
230
231fn extract_python_name(trimmed: &str) -> String {
232    let rest = if let Some(r) = trimmed.strip_prefix("async def ") {
233        r
234    } else if let Some(r) = trimmed.strip_prefix("def ") {
235        r
236    } else if let Some(r) = trimmed.strip_prefix("class ") {
237        r
238    } else {
239        return String::new();
240    };
241
242    rest.chars()
243        .take_while(|c| c.is_alphanumeric() || *c == '_')
244        .collect()
245}
246
247/// Check if the line after the def/class has a docstring.
248fn has_python_docstring(lines: &[&str], idx: usize) -> bool {
249    // Look for a docstring in the lines following the definition
250    for line in lines.iter().take((idx + 3).min(lines.len())).skip(idx + 1) {
251        let t = line.trim();
252        if t.is_empty() {
253            continue;
254        }
255        return t.starts_with("\"\"\"") || t.starts_with("'''") || t.starts_with("r\"\"\"");
256    }
257    false
258}
259
260// -------
261// Go
262// -------
263
264fn extract_go_symbols(lines: &[&str]) -> Vec<Symbol> {
265    let mut symbols = Vec::new();
266
267    for (i, line) in lines.iter().enumerate() {
268        let trimmed = line.trim();
269
270        if trimmed.starts_with("//") || trimmed.starts_with("/*") {
271            continue;
272        }
273
274        if let Some(name) = extract_go_item_name(trimmed) {
275            // In Go, items starting with uppercase are public
276            let first_char = name.chars().next().unwrap_or('_');
277            let is_public = first_char.is_uppercase();
278            symbols.push(Symbol {
279                is_public,
280                is_documented: has_doc_comment(lines, i),
281            });
282        }
283    }
284
285    symbols
286}
287
288fn extract_go_item_name(trimmed: &str) -> Option<String> {
289    // func Name or func (receiver) Name
290    if let Some(rest) = trimmed.strip_prefix("func ") {
291        let rest = if rest.starts_with('(') {
292            // Method receiver: skip to closing paren
293            if let Some(close) = rest.find(')') {
294                rest[close + 1..].trim_start()
295            } else {
296                return None;
297            }
298        } else {
299            rest
300        };
301        let name: String = rest
302            .chars()
303            .take_while(|c| c.is_alphanumeric() || *c == '_')
304            .collect();
305        if !name.is_empty() {
306            return Some(name);
307        }
308    }
309
310    // type Name struct/interface
311    if let Some(rest) = trimmed.strip_prefix("type ") {
312        let name: String = rest
313            .chars()
314            .take_while(|c| c.is_alphanumeric() || *c == '_')
315            .collect();
316        if !name.is_empty() {
317            return Some(name);
318        }
319    }
320
321    // var Name or const Name (top-level)
322    for prefix in &["var ", "const "] {
323        if let Some(rest) = trimmed.strip_prefix(prefix) {
324            let name: String = rest
325                .chars()
326                .take_while(|c| c.is_alphanumeric() || *c == '_')
327                .collect();
328            if !name.is_empty() {
329                return Some(name);
330            }
331        }
332    }
333
334    None
335}
336
337// -------
338// Java
339// -------
340
341fn extract_java_symbols(lines: &[&str]) -> Vec<Symbol> {
342    let mut symbols = Vec::new();
343
344    for (i, line) in lines.iter().enumerate() {
345        let trimmed = line.trim();
346
347        if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") {
348            continue;
349        }
350
351        let is_public = is_java_public(trimmed);
352        let is_internal = !is_public && is_java_internal(trimmed);
353
354        if is_public || is_internal {
355            symbols.push(Symbol {
356                is_public,
357                is_documented: has_doc_comment(lines, i),
358            });
359        }
360    }
361
362    symbols
363}
364
365fn is_java_public(trimmed: &str) -> bool {
366    trimmed.starts_with("public class ")
367        || trimmed.starts_with("public interface ")
368        || trimmed.starts_with("public enum ")
369        || trimmed.starts_with("public static ")
370        || trimmed.starts_with("public abstract class ")
371        || trimmed.starts_with("public final class ")
372        || trimmed.starts_with("public record ")
373        || trimmed.starts_with("public sealed ")
374        // public return-type method(
375        || (trimmed.starts_with("public ")
376            && (trimmed.contains('(') || trimmed.contains(" class ") || trimmed.contains(" interface ")))
377}
378
379fn is_java_internal(trimmed: &str) -> bool {
380    // private/protected/package-private items
381    trimmed.starts_with("private ")
382        || trimmed.starts_with("protected ")
383        || trimmed.starts_with("class ")
384        || trimmed.starts_with("interface ")
385        || trimmed.starts_with("enum ")
386        || trimmed.starts_with("abstract class ")
387        || trimmed.starts_with("final class ")
388        || trimmed.starts_with("static ")
389        || trimmed.starts_with("record ")
390}
391
392// -------
393// Main
394// -------
395
396/// Build the API surface report by scanning source files for public/internal symbols.
397pub fn build_api_surface_report(
398    root: &Path,
399    files: &[PathBuf],
400    export: &ExportData,
401    limits: &AnalysisLimits,
402) -> Result<ApiSurfaceReport> {
403    // Build lookup from normalized path -> FileRow
404    let mut row_map: BTreeMap<String, &FileRow> = BTreeMap::new();
405    for row in export.rows.iter().filter(|r| r.kind == FileKind::Parent) {
406        row_map.insert(normalize_path(&row.path, root), row);
407    }
408
409    let per_file_limit = limits.max_file_bytes.unwrap_or(DEFAULT_MAX_FILE_BYTES) as usize;
410    let mut total_bytes = 0u64;
411
412    // Accumulators
413    let mut total_items = 0usize;
414    let mut public_items = 0usize;
415    let mut internal_items = 0usize;
416    let mut documented_public = 0usize;
417
418    // Per-language accumulators
419    let mut lang_totals: BTreeMap<String, (usize, usize, usize)> = BTreeMap::new(); // (total, public, internal)
420
421    // Per-module accumulators
422    let mut module_totals: BTreeMap<String, (usize, usize)> = BTreeMap::new(); // (total, public)
423
424    // Top exporters
425    let mut exporters: Vec<ApiExportItem> = Vec::new();
426
427    for rel in files {
428        if limits.max_bytes.is_some_and(|limit| total_bytes >= limit) {
429            break;
430        }
431
432        let rel_str = normalize_path(&rel.to_string_lossy(), root);
433        let row = match row_map.get(&rel_str) {
434            Some(r) => *r,
435            None => continue,
436        };
437
438        if !is_api_surface_lang(&row.lang) {
439            continue;
440        }
441
442        let path = root.join(rel);
443        let bytes = match tokmd_content::read_head(&path, per_file_limit) {
444            Ok(b) => b,
445            Err(_) => continue,
446        };
447        total_bytes += bytes.len() as u64;
448
449        if !tokmd_content::is_text_like(&bytes) {
450            continue;
451        }
452
453        let text = String::from_utf8_lossy(&bytes);
454        let symbols = extract_symbols(&row.lang, &text);
455
456        if symbols.is_empty() {
457            continue;
458        }
459
460        let file_public: usize = symbols.iter().filter(|s| s.is_public).count();
461        let file_internal: usize = symbols.iter().filter(|s| !s.is_public).count();
462        let file_documented: usize = symbols
463            .iter()
464            .filter(|s| s.is_public && s.is_documented)
465            .count();
466        let file_total = symbols.len();
467
468        total_items += file_total;
469        public_items += file_public;
470        internal_items += file_internal;
471        documented_public += file_documented;
472
473        // Per-language
474        let lang_key = row.lang.clone();
475        let entry = lang_totals.entry(lang_key).or_insert((0, 0, 0));
476        entry.0 += file_total;
477        entry.1 += file_public;
478        entry.2 += file_internal;
479
480        // Per-module
481        let mod_entry = module_totals.entry(row.module.clone()).or_insert((0, 0));
482        mod_entry.0 += file_total;
483        mod_entry.1 += file_public;
484
485        // Track top exporters
486        if file_public > 0 {
487            exporters.push(ApiExportItem {
488                path: rel_str,
489                lang: row.lang.clone(),
490                public_items: file_public,
491                total_items: file_total,
492            });
493        }
494    }
495
496    // Build per-language map
497    let by_language: BTreeMap<String, LangApiSurface> = lang_totals
498        .into_iter()
499        .map(|(lang, (total, public, internal))| {
500            let public_ratio = if total == 0 {
501                0.0
502            } else {
503                round_f64(public as f64 / total as f64, 4)
504            };
505            (
506                lang,
507                LangApiSurface {
508                    total_items: total,
509                    public_items: public,
510                    internal_items: internal,
511                    public_ratio,
512                },
513            )
514        })
515        .collect();
516
517    // Build per-module vec, sorted by total items descending
518    let mut by_module: Vec<ModuleApiRow> = module_totals
519        .into_iter()
520        .map(|(module, (total, public))| {
521            let public_ratio = if total == 0 {
522                0.0
523            } else {
524                round_f64(public as f64 / total as f64, 4)
525            };
526            ModuleApiRow {
527                module,
528                total_items: total,
529                public_items: public,
530                public_ratio,
531            }
532        })
533        .collect();
534    by_module.sort_by(|a, b| {
535        b.total_items
536            .cmp(&a.total_items)
537            .then_with(|| a.module.cmp(&b.module))
538    });
539    by_module.truncate(MAX_BY_MODULE);
540
541    // Sort top exporters by public_items descending, then by path
542    exporters.sort_by(|a, b| {
543        b.public_items
544            .cmp(&a.public_items)
545            .then_with(|| a.path.cmp(&b.path))
546    });
547    exporters.truncate(MAX_TOP_EXPORTERS);
548
549    let public_ratio = if total_items == 0 {
550        0.0
551    } else {
552        round_f64(public_items as f64 / total_items as f64, 4)
553    };
554
555    let documented_ratio = if public_items == 0 {
556        0.0
557    } else {
558        round_f64(documented_public as f64 / public_items as f64, 4)
559    };
560
561    Ok(ApiSurfaceReport {
562        total_items,
563        public_items,
564        internal_items,
565        public_ratio,
566        documented_ratio,
567        by_language,
568        by_module,
569        top_exporters: exporters,
570    })
571}
572
573fn round_f64(val: f64, decimals: u32) -> f64 {
574    let factor = 10f64.powi(decimals as i32);
575    (val * factor).round() / factor
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    // -------
583    // Rust symbol extraction
584    // -------
585
586    #[test]
587    fn rust_pub_fn() {
588        let code = "pub fn foo() {\n}\n";
589        let syms = extract_symbols("rust", code);
590        assert_eq!(syms.len(), 1);
591        assert!(syms[0].is_public);
592    }
593
594    #[test]
595    fn rust_private_fn() {
596        let code = "fn bar() {\n}\n";
597        let syms = extract_symbols("rust", code);
598        assert_eq!(syms.len(), 1);
599        assert!(!syms[0].is_public);
600    }
601
602    #[test]
603    fn rust_pub_struct_enum_trait() {
604        let code = "pub struct Foo;\npub enum Bar {}\npub trait Baz {}\n";
605        let syms = extract_symbols("rust", code);
606        assert_eq!(syms.len(), 3);
607        assert!(syms.iter().all(|s| s.is_public));
608    }
609
610    #[test]
611    fn rust_pub_crate() {
612        let code = "pub(crate) fn internal_fn() {\n}\n";
613        let syms = extract_symbols("rust", code);
614        assert_eq!(syms.len(), 1);
615        // pub(crate) is still considered pub for API surface purposes
616        assert!(syms[0].is_public);
617    }
618
619    #[test]
620    fn rust_internal_items() {
621        let code = "struct Private;\nenum InternalEnum {}\ntrait InternalTrait {}\n";
622        let syms = extract_symbols("rust", code);
623        assert_eq!(syms.len(), 3);
624        assert!(syms.iter().all(|s| !s.is_public));
625    }
626
627    #[test]
628    fn rust_documented_item() {
629        let code = "/// Documentation\npub fn documented() {\n}\n";
630        let syms = extract_symbols("rust", code);
631        assert_eq!(syms.len(), 1);
632        assert!(syms[0].is_public);
633        assert!(syms[0].is_documented);
634    }
635
636    #[test]
637    fn rust_undocumented_item() {
638        let code = "pub fn undocumented() {\n}\n";
639        let syms = extract_symbols("rust", code);
640        assert_eq!(syms.len(), 1);
641        assert!(syms[0].is_public);
642        assert!(!syms[0].is_documented);
643    }
644
645    #[test]
646    fn rust_pub_mod_const_static() {
647        let code = "pub mod mymod;\npub const X: u32 = 1;\npub static Y: &str = \"hi\";\n";
648        let syms = extract_symbols("rust", code);
649        assert_eq!(syms.len(), 3);
650        assert!(syms.iter().all(|s| s.is_public));
651    }
652
653    #[test]
654    fn rust_pub_type_alias() {
655        let code = "pub type MyResult = Result<(), Error>;\n";
656        let syms = extract_symbols("rust", code);
657        assert_eq!(syms.len(), 1);
658        assert!(syms[0].is_public);
659    }
660
661    #[test]
662    fn rust_async_unsafe() {
663        let code = "pub async fn async_pub() {}\npub unsafe fn unsafe_pub() {}\n";
664        let syms = extract_symbols("rust", code);
665        assert_eq!(syms.len(), 2);
666        assert!(syms.iter().all(|s| s.is_public));
667    }
668
669    // -------
670    // JS/TS symbol extraction
671    // -------
672
673    #[test]
674    fn js_export_function() {
675        let code = "export function foo() {\n}\n";
676        let syms = extract_symbols("javascript", code);
677        assert_eq!(syms.len(), 1);
678        assert!(syms[0].is_public);
679    }
680
681    #[test]
682    fn js_export_class() {
683        let code = "export class MyClass {\n}\n";
684        let syms = extract_symbols("typescript", code);
685        assert_eq!(syms.len(), 1);
686        assert!(syms[0].is_public);
687    }
688
689    #[test]
690    fn js_export_const_default() {
691        let code = "export const X = 1;\nexport default function main() {}\n";
692        let syms = extract_symbols("javascript", code);
693        assert_eq!(syms.len(), 2);
694        assert!(syms.iter().all(|s| s.is_public));
695    }
696
697    #[test]
698    fn ts_export_interface_type_enum() {
699        let code =
700            "export interface IFoo {}\nexport type Bar = string;\nexport enum Baz { A, B }\n";
701        let syms = extract_symbols("typescript", code);
702        assert_eq!(syms.len(), 3);
703        assert!(syms.iter().all(|s| s.is_public));
704    }
705
706    #[test]
707    fn js_internal_function() {
708        let code = "function internal() {\n}\n";
709        let syms = extract_symbols("javascript", code);
710        assert_eq!(syms.len(), 1);
711        assert!(!syms[0].is_public);
712    }
713
714    // -------
715    // Python symbol extraction
716    // -------
717
718    #[test]
719    fn python_public_def() {
720        let code = "def public_func():\n    pass\n";
721        let syms = extract_symbols("python", code);
722        assert_eq!(syms.len(), 1);
723        assert!(syms[0].is_public);
724    }
725
726    #[test]
727    fn python_private_def() {
728        let code = "def _private_func():\n    pass\n";
729        let syms = extract_symbols("python", code);
730        assert_eq!(syms.len(), 1);
731        assert!(!syms[0].is_public);
732    }
733
734    #[test]
735    fn python_class() {
736        let code = "class MyClass:\n    pass\n";
737        let syms = extract_symbols("python", code);
738        assert_eq!(syms.len(), 1);
739        assert!(syms[0].is_public);
740    }
741
742    #[test]
743    fn python_private_class() {
744        let code = "class _InternalClass:\n    pass\n";
745        let syms = extract_symbols("python", code);
746        assert_eq!(syms.len(), 1);
747        assert!(!syms[0].is_public);
748    }
749
750    #[test]
751    fn python_indented_def_ignored() {
752        let code = "class Foo:\n    def method(self):\n        pass\n";
753        let syms = extract_symbols("python", code);
754        // Only top-level class, not the method
755        assert_eq!(syms.len(), 1);
756        assert!(syms[0].is_public);
757    }
758
759    #[test]
760    fn python_docstring_detected() {
761        let code = "def documented():\n    \"\"\"This is documented.\"\"\"\n    pass\n";
762        let syms = extract_symbols("python", code);
763        assert_eq!(syms.len(), 1);
764        assert!(syms[0].is_documented);
765    }
766
767    // -------
768    // Go symbol extraction
769    // -------
770
771    #[test]
772    fn go_public_func() {
773        let code = "func PublicFunc() {\n}\n";
774        let syms = extract_symbols("go", code);
775        assert_eq!(syms.len(), 1);
776        assert!(syms[0].is_public);
777    }
778
779    #[test]
780    fn go_private_func() {
781        let code = "func privateFunc() {\n}\n";
782        let syms = extract_symbols("go", code);
783        assert_eq!(syms.len(), 1);
784        assert!(!syms[0].is_public);
785    }
786
787    #[test]
788    fn go_public_type() {
789        let code = "type MyStruct struct {\n}\n";
790        let syms = extract_symbols("go", code);
791        assert_eq!(syms.len(), 1);
792        assert!(syms[0].is_public);
793    }
794
795    #[test]
796    fn go_method_receiver() {
797        let code = "func (s *Server) Handle() {\n}\n";
798        let syms = extract_symbols("go", code);
799        assert_eq!(syms.len(), 1);
800        assert!(syms[0].is_public);
801    }
802
803    #[test]
804    fn go_private_method() {
805        let code = "func (s *Server) handle() {\n}\n";
806        let syms = extract_symbols("go", code);
807        assert_eq!(syms.len(), 1);
808        assert!(!syms[0].is_public);
809    }
810
811    // -------
812    // Java symbol extraction
813    // -------
814
815    #[test]
816    fn java_public_class() {
817        let code = "public class MyClass {\n}\n";
818        let syms = extract_symbols("java", code);
819        assert_eq!(syms.len(), 1);
820        assert!(syms[0].is_public);
821    }
822
823    #[test]
824    fn java_public_interface() {
825        let code = "public interface MyInterface {\n}\n";
826        let syms = extract_symbols("java", code);
827        assert_eq!(syms.len(), 1);
828        assert!(syms[0].is_public);
829    }
830
831    #[test]
832    fn java_public_enum() {
833        let code = "public enum Color {\n    RED, GREEN, BLUE\n}\n";
834        let syms = extract_symbols("java", code);
835        assert_eq!(syms.len(), 1);
836        assert!(syms[0].is_public);
837    }
838
839    #[test]
840    fn java_public_static_method() {
841        let code = "public static void main(String[] args) {\n}\n";
842        let syms = extract_symbols("java", code);
843        assert_eq!(syms.len(), 1);
844        assert!(syms[0].is_public);
845    }
846
847    #[test]
848    fn java_package_private_class() {
849        let code = "class InternalClass {\n}\n";
850        let syms = extract_symbols("java", code);
851        assert_eq!(syms.len(), 1);
852        assert!(!syms[0].is_public);
853    }
854
855    #[test]
856    fn java_private_member() {
857        let code = "private void helper() {\n}\n";
858        let syms = extract_symbols("java", code);
859        assert_eq!(syms.len(), 1);
860        assert!(!syms[0].is_public);
861    }
862
863    #[test]
864    fn java_documented() {
865        let code = "/** Javadoc */\npublic class Documented {\n}\n";
866        let syms = extract_symbols("java", code);
867        assert_eq!(syms.len(), 1);
868        assert!(syms[0].is_documented);
869    }
870
871    // -------
872    // Unsupported language
873    // -------
874
875    #[test]
876    fn unsupported_lang_returns_empty() {
877        let code = "some code here\n";
878        let syms = extract_symbols("markdown", code);
879        assert!(syms.is_empty());
880    }
881
882    #[test]
883    fn empty_input_returns_empty() {
884        for lang in &["rust", "javascript", "typescript", "python", "go", "java"] {
885            let syms = extract_symbols(lang, "");
886            assert!(
887                syms.is_empty(),
888                "empty input for {lang} should yield no symbols"
889            );
890        }
891    }
892
893    // -------
894    // is_api_surface_lang
895    // -------
896
897    #[test]
898    fn supported_langs() {
899        assert!(is_api_surface_lang("Rust"));
900        assert!(is_api_surface_lang("JavaScript"));
901        assert!(is_api_surface_lang("TypeScript"));
902        assert!(is_api_surface_lang("Python"));
903        assert!(is_api_surface_lang("Go"));
904        assert!(is_api_surface_lang("Java"));
905    }
906
907    #[test]
908    fn supported_langs_case_insensitive() {
909        assert!(is_api_surface_lang("RUST"));
910        assert!(is_api_surface_lang("javascript"));
911        assert!(is_api_surface_lang("gO"));
912    }
913
914    #[test]
915    fn unsupported_langs() {
916        assert!(!is_api_surface_lang("Markdown"));
917        assert!(!is_api_surface_lang("JSON"));
918        assert!(!is_api_surface_lang("CSS"));
919    }
920
921    // -------
922    // has_doc_comment edge cases
923    // -------
924
925    #[test]
926    fn has_doc_comment_at_index_zero_is_false() {
927        let lines = vec!["pub fn foo() {}"];
928        assert!(!has_doc_comment(&lines, 0));
929    }
930
931    #[test]
932    fn has_doc_comment_with_doc_attribute() {
933        let lines = vec!["#[doc = \"documented\"]", "pub fn foo() {}"];
934        assert!(has_doc_comment(&lines, 1));
935    }
936
937    // -------
938    // Go var/const
939    // -------
940
941    #[test]
942    fn go_var_public() {
943        let code = "var PublicVar int = 42\n";
944        let syms = extract_symbols("go", code);
945        assert_eq!(syms.len(), 1);
946        assert!(syms[0].is_public);
947    }
948
949    #[test]
950    fn go_const_private() {
951        let code = "const maxBuffer = 1024\n";
952        let syms = extract_symbols("go", code);
953        assert_eq!(syms.len(), 1);
954        assert!(!syms[0].is_public);
955    }
956
957    // -------
958    // Python async def
959    // -------
960
961    #[test]
962    fn python_async_def() {
963        let code = "async def fetch():\n    pass\n";
964        let syms = extract_symbols("python", code);
965        assert_eq!(syms.len(), 1);
966        assert!(syms[0].is_public);
967    }
968
969    #[test]
970    fn python_async_def_private() {
971        let code = "async def _fetch():\n    pass\n";
972        let syms = extract_symbols("python", code);
973        assert_eq!(syms.len(), 1);
974        assert!(!syms[0].is_public);
975    }
976
977    // -------
978    // Java additional forms
979    // -------
980
981    #[test]
982    fn java_public_record() {
983        let code = "public record Point(int x, int y) {}\n";
984        let syms = extract_symbols("java", code);
985        assert_eq!(syms.len(), 1);
986        assert!(syms[0].is_public);
987    }
988
989    #[test]
990    fn java_protected_member() {
991        let code = "protected void helper() {}\n";
992        let syms = extract_symbols("java", code);
993        assert_eq!(syms.len(), 1);
994        assert!(!syms[0].is_public);
995    }
996
997    // -------
998    // JS/TS export enum
999    // -------
1000
1001    #[test]
1002    fn ts_export_enum() {
1003        let code = "export enum Direction { Up, Down }\n";
1004        let syms = extract_symbols("typescript", code);
1005        assert_eq!(syms.len(), 1);
1006        assert!(syms[0].is_public);
1007    }
1008
1009    #[test]
1010    fn js_async_function_internal() {
1011        let code = "async function doWork() {}\n";
1012        let syms = extract_symbols("javascript", code);
1013        assert_eq!(syms.len(), 1);
1014        assert!(!syms[0].is_public);
1015    }
1016
1017    // -------
1018    // Rust pub with unmatched paren
1019    // -------
1020
1021    #[test]
1022    fn rust_pub_unmatched_paren_no_panic() {
1023        let code = "pub(broken fn foo() {}\n";
1024        let syms = extract_symbols("rust", code);
1025        // Unmatched paren should not match as pub item
1026        assert!(syms.is_empty() || !syms[0].is_public);
1027    }
1028
1029    // -------
1030    // round_f64
1031    // -------
1032
1033    #[test]
1034    fn test_round() {
1035        assert_eq!(round_f64(0.12345, 4), 0.1235);
1036        assert_eq!(round_f64(0.5, 0), 1.0);
1037        assert_eq!(round_f64(1.0, 4), 1.0);
1038    }
1039
1040    #[test]
1041    fn test_round_zero() {
1042        assert_eq!(round_f64(0.0, 4), 0.0);
1043    }
1044
1045    #[test]
1046    fn test_round_small_fraction() {
1047        assert_eq!(round_f64(0.3333, 2), 0.33);
1048        assert_eq!(round_f64(0.6667, 2), 0.67);
1049    }
1050}