Skip to main content

sloc_languages/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4pub mod style;
5pub use style::{IndentStyle, StyleAnalysis, StyleGuideScore, StyleSignal};
6
7use std::collections::{BTreeMap, BTreeSet, HashSet};
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum Language {
15    C,
16    Cpp,
17    CSharp,
18    Go,
19    Java,
20    JavaScript,
21    Python,
22    Rust,
23    Shell,
24    PowerShell,
25    TypeScript,
26    // --- Extended language support ---
27    Assembly,
28    Clojure,
29    Css,
30    Dart,
31    Dockerfile,
32    Elixir,
33    Erlang,
34    FSharp,
35    Groovy,
36    Haskell,
37    Html,
38    Julia,
39    Kotlin,
40    Lua,
41    Makefile,
42    Nim,
43    ObjectiveC,
44    Ocaml,
45    Perl,
46    Php,
47    R,
48    Ruby,
49    Scala,
50    Scss,
51    Sql,
52    Svelte,
53    Swift,
54    Vue,
55    Xml,
56    Zig,
57    // --- Pass 1: modern declarative / smart-contract languages ---
58    Solidity,
59    Protobuf,
60    Hcl,
61    GraphQl,
62    // --- Pass 2: legacy + embedded / hardware-description languages ---
63    Ada,
64    Vhdl,
65    Verilog,
66    Tcl,
67    Pascal,
68    VisualBasic,
69    Lisp,
70    // --- Pass 3: scientific / infra / systems / graphics ---
71    Fortran,
72    Nix,
73    Crystal,
74    D,
75    Glsl,
76    Cmake,
77    Elm,
78    Awk,
79}
80
81impl Language {
82    #[must_use]
83    pub const fn display_name(&self) -> &'static str {
84        match self {
85            Self::C => "C",
86            Self::Cpp => "C++",
87            Self::CSharp => "C#",
88            Self::Go => "Go",
89            Self::Java => "Java",
90            Self::JavaScript => "JavaScript",
91            Self::Python => "Python",
92            Self::Rust => "Rust",
93            Self::Shell => "Shell",
94            Self::PowerShell => "PowerShell",
95            Self::TypeScript => "TypeScript",
96            Self::Assembly => "Assembly",
97            Self::Clojure => "Clojure",
98            Self::Css => "CSS",
99            Self::Dart => "Dart",
100            Self::Dockerfile => "Dockerfile",
101            Self::Elixir => "Elixir",
102            Self::Erlang => "Erlang",
103            Self::FSharp => "F#",
104            Self::Groovy => "Groovy",
105            Self::Haskell => "Haskell",
106            Self::Html => "HTML",
107            Self::Julia => "Julia",
108            Self::Kotlin => "Kotlin",
109            Self::Lua => "Lua",
110            Self::Makefile => "Makefile",
111            Self::Nim => "Nim",
112            Self::ObjectiveC => "Objective-C",
113            Self::Ocaml => "OCaml",
114            Self::Perl => "Perl",
115            Self::Php => "PHP",
116            Self::R => "R",
117            Self::Ruby => "Ruby",
118            Self::Scala => "Scala",
119            Self::Scss => "SCSS",
120            Self::Sql => "SQL",
121            Self::Svelte => "Svelte",
122            Self::Swift => "Swift",
123            Self::Vue => "Vue",
124            Self::Xml => "XML",
125            Self::Zig => "Zig",
126            Self::Solidity => "Solidity",
127            Self::Protobuf => "Protocol Buffers",
128            Self::Hcl => "HCL/Terraform",
129            Self::GraphQl => "GraphQL",
130            Self::Ada => "Ada",
131            Self::Vhdl => "VHDL",
132            Self::Verilog => "Verilog/SystemVerilog",
133            Self::Tcl => "Tcl",
134            Self::Pascal => "Pascal/Delphi",
135            Self::VisualBasic => "Visual Basic",
136            Self::Lisp => "Lisp/Scheme",
137            Self::Fortran => "Fortran",
138            Self::Nix => "Nix",
139            Self::Crystal => "Crystal",
140            Self::D => "D",
141            Self::Glsl => "GLSL/HLSL",
142            Self::Cmake => "CMake",
143            Self::Elm => "Elm",
144            Self::Awk => "Awk",
145        }
146    }
147
148    #[must_use]
149    pub const fn as_slug(&self) -> &'static str {
150        match self {
151            Self::C => "c",
152            Self::Cpp => "cpp",
153            Self::CSharp => "csharp",
154            Self::Go => "go",
155            Self::Java => "java",
156            Self::JavaScript => "javascript",
157            Self::Python => "python",
158            Self::Rust => "rust",
159            Self::Shell => "shell",
160            Self::PowerShell => "powershell",
161            Self::TypeScript => "typescript",
162            Self::Assembly => "assembly",
163            Self::Clojure => "clojure",
164            Self::Css => "css",
165            Self::Dart => "dart",
166            Self::Dockerfile => "dockerfile",
167            Self::Elixir => "elixir",
168            Self::Erlang => "erlang",
169            Self::FSharp => "fsharp",
170            Self::Groovy => "groovy",
171            Self::Haskell => "haskell",
172            Self::Html => "html",
173            Self::Julia => "julia",
174            Self::Kotlin => "kotlin",
175            Self::Lua => "lua",
176            Self::Makefile => "makefile",
177            Self::Nim => "nim",
178            Self::ObjectiveC => "objectivec",
179            Self::Ocaml => "ocaml",
180            Self::Perl => "perl",
181            Self::Php => "php",
182            Self::R => "r",
183            Self::Ruby => "ruby",
184            Self::Scala => "scala",
185            Self::Scss => "scss",
186            Self::Sql => "sql",
187            Self::Svelte => "svelte",
188            Self::Swift => "swift",
189            Self::Vue => "vue",
190            Self::Xml => "xml",
191            Self::Zig => "zig",
192            Self::Solidity => "solidity",
193            Self::Protobuf => "protobuf",
194            Self::Hcl => "hcl",
195            Self::GraphQl => "graphql",
196            Self::Ada => "ada",
197            Self::Vhdl => "vhdl",
198            Self::Verilog => "verilog",
199            Self::Tcl => "tcl",
200            Self::Pascal => "pascal",
201            Self::VisualBasic => "visualbasic",
202            Self::Lisp => "lisp",
203            Self::Fortran => "fortran",
204            Self::Nix => "nix",
205            Self::Crystal => "crystal",
206            Self::D => "d",
207            Self::Glsl => "glsl",
208            Self::Cmake => "cmake",
209            Self::Elm => "elm",
210            Self::Awk => "awk",
211        }
212    }
213
214    #[must_use]
215    pub fn from_name(name: &str) -> Option<Self> {
216        match name.trim().to_ascii_lowercase().as_str() {
217            "c" => Some(Self::C),
218            "cpp" | "c++" | "cplusplus" => Some(Self::Cpp),
219            "csharp" | "c#" | "cs" => Some(Self::CSharp),
220            "go" | "golang" => Some(Self::Go),
221            "java" => Some(Self::Java),
222            "javascript" | "js" => Some(Self::JavaScript),
223            "python" | "py" => Some(Self::Python),
224            "rust" | "rs" => Some(Self::Rust),
225            "shell" | "sh" | "bash" => Some(Self::Shell),
226            "powershell" | "pwsh" | "ps" => Some(Self::PowerShell),
227            "typescript" | "ts" => Some(Self::TypeScript),
228            "assembly" | "asm" => Some(Self::Assembly),
229            "clojure" | "clj" => Some(Self::Clojure),
230            "css" => Some(Self::Css),
231            "dart" => Some(Self::Dart),
232            "dockerfile" | "docker" => Some(Self::Dockerfile),
233            "elixir" | "ex" => Some(Self::Elixir),
234            "erlang" | "erl" => Some(Self::Erlang),
235            "fsharp" | "f#" | "fs" => Some(Self::FSharp),
236            "groovy" => Some(Self::Groovy),
237            "haskell" | "hs" => Some(Self::Haskell),
238            "html" | "htm" => Some(Self::Html),
239            "julia" | "jl" => Some(Self::Julia),
240            "kotlin" | "kt" => Some(Self::Kotlin),
241            "lua" => Some(Self::Lua),
242            "makefile" | "make" | "mk" => Some(Self::Makefile),
243            "nim" => Some(Self::Nim),
244            "objectivec" | "objc" | "objective-c" => Some(Self::ObjectiveC),
245            "ocaml" | "ml" => Some(Self::Ocaml),
246            "perl" | "pl" => Some(Self::Perl),
247            "php" => Some(Self::Php),
248            "r" => Some(Self::R),
249            "ruby" | "rb" => Some(Self::Ruby),
250            "scala" => Some(Self::Scala),
251            "scss" | "sass" => Some(Self::Scss),
252            "sql" => Some(Self::Sql),
253            "svelte" => Some(Self::Svelte),
254            "swift" => Some(Self::Swift),
255            "vue" => Some(Self::Vue),
256            "xml" => Some(Self::Xml),
257            "zig" => Some(Self::Zig),
258            "solidity" | "sol" => Some(Self::Solidity),
259            "protobuf" | "proto" | "protocolbuffers" => Some(Self::Protobuf),
260            "hcl" | "terraform" | "tf" => Some(Self::Hcl),
261            "graphql" | "gql" => Some(Self::GraphQl),
262            "ada" => Some(Self::Ada),
263            "vhdl" => Some(Self::Vhdl),
264            "verilog" | "systemverilog" | "sv" => Some(Self::Verilog),
265            "tcl" => Some(Self::Tcl),
266            "pascal" | "delphi" | "pas" => Some(Self::Pascal),
267            "visualbasic" | "vb" | "vbnet" | "vb.net" => Some(Self::VisualBasic),
268            "lisp" | "scheme" | "racket" | "clisp" | "elisp" => Some(Self::Lisp),
269            "fortran" | "f90" | "f95" => Some(Self::Fortran),
270            "nix" => Some(Self::Nix),
271            "crystal" | "cr" => Some(Self::Crystal),
272            "d" | "dlang" => Some(Self::D),
273            "glsl" | "hlsl" | "shader" | "wgsl" => Some(Self::Glsl),
274            "cmake" => Some(Self::Cmake),
275            "elm" => Some(Self::Elm),
276            "awk" => Some(Self::Awk),
277            _ => None,
278        }
279    }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, Default)]
283pub struct RawLineCounts {
284    pub total_physical_lines: u64,
285    pub blank_only_lines: u64,
286    pub code_only_lines: u64,
287    pub single_comment_only_lines: u64,
288    pub multi_comment_only_lines: u64,
289    pub mixed_code_single_comment_lines: u64,
290    pub mixed_code_multi_comment_lines: u64,
291    pub docstring_comment_lines: u64,
292    pub skipped_unknown_lines: u64,
293    /// Best-effort count of function/method definition lines detected lexically.
294    #[serde(default)]
295    pub functions: u64,
296    /// Best-effort count of class/struct/trait/type definition lines detected lexically.
297    #[serde(default)]
298    pub classes: u64,
299    /// Best-effort count of variable declaration lines detected lexically. Equals the sum of
300    /// `variables_member + variables_local + variables_global` for C/C++ (where scope is tracked);
301    /// for other languages it is the flat total with the breakdown fields left at zero.
302    #[serde(default)]
303    pub variables: u64,
304    /// C/C++ only: variable declarations that are members of a class/struct/union body.
305    #[serde(default)]
306    pub variables_member: u64,
307    /// C/C++ only: variable declarations local to a function or block body.
308    #[serde(default)]
309    pub variables_local: u64,
310    /// C/C++ only: variable declarations at file / namespace scope (globals, file-statics).
311    #[serde(default)]
312    pub variables_global: u64,
313    /// C/C++ only: object-like preprocessor macro definitions (`#define NAME value`) — named
314    /// compile-time constants. Function-like macros (`#define F(x) …`) are excluded.
315    #[serde(default)]
316    pub macro_definitions: u64,
317    /// Best-effort count of import/use/include statement lines detected lexically.
318    #[serde(default)]
319    pub imports: u64,
320    /// Lines consisting solely of preprocessor/compiler directives (e.g. `#include`, `#define`
321    /// in C/C++/Objective-C). Always a subset of `code_only_lines`. Controlled by
322    /// `AnalysisConfig::count_compiler_directives`. IEEE 1045-1992 §4.2.
323    #[serde(default)]
324    pub compiler_directive_lines: u64,
325    /// Best-effort count of test case / test function definition lines detected lexically
326    /// (`GTest`, Catch2, `PyTest`, `JUnit`, etc.). Always a subset of `code_only_lines`.
327    #[serde(default)]
328    pub test_count: u64,
329    /// Best-effort count of test assertion call lines detected lexically
330    /// (`ASSERT_EQ`, `EXPECT_TRUE`, assertEquals, Assert.AreEqual, `assert_eq`!, etc.).
331    #[serde(default)]
332    pub test_assertion_count: u64,
333    /// Best-effort count of test suite / fixture / group declaration lines detected lexically
334    /// (`TEST_GROUP`, `BOOST_AUTO_TEST_SUITE`, [`TestClass`], [`TestFixture`], etc.).
335    #[serde(default)]
336    pub test_suite_count: u64,
337    /// Cyclomatic complexity approximation: total count of branch decision keywords found on
338    /// code lines (e.g. `if`, `for`, `while`, `||`, `&&`). Starts at 0; +1 per keyword hit.
339    #[serde(default)]
340    pub cyclomatic_complexity: u32,
341    /// Logical SLOC estimate: executable statement count using a language-specific strategy.
342    /// `None` when the language does not support lexical LSLOC estimation.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub lsloc: Option<u32>,
345    /// Per-code-line content hashes (trimmed) for ULOC aggregation. Never serialized — only
346    /// populated during an in-process scan and consumed by `sloc-core` during aggregation.
347    #[serde(skip)]
348    pub code_line_hashes: Vec<u64>,
349}
350
351#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
352#[serde(rename_all = "snake_case")]
353pub enum ParseMode {
354    Lexical,
355    LexicalBestEffort,
356    TreeSitter,
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct RawFileAnalysis {
361    pub raw: RawLineCounts,
362    pub parse_mode: ParseMode,
363    pub warnings: Vec<String>,
364    /// Lexical style-guide analysis for supported languages; `None` when no heuristics apply.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub style_analysis: Option<StyleAnalysis>,
367}
368
369/// IEEE 1045-1992 counting options passed from `sloc-core` (built from `AnalysisConfig`).
370///
371/// `analyze_text` accepts this struct so that the caller can control behaviour that the
372/// standard defines as configurable parameters rather than fixed conventions.
373#[derive(Debug, Clone, Copy)]
374pub struct AnalysisOptions {
375    /// When `true` (IEEE 1045-1992 default), blank lines inside block comments count as
376    /// comment lines rather than blank lines.
377    pub blank_in_block_comment_as_comment: bool,
378    /// When `true`, backslash-continued physical lines are collapsed into a single logical
379    /// line for SLOC counting purposes (IEEE logical SLOC mode).
380    pub collapse_continuation_lines: bool,
381    /// When `true` (default), run lexical style-guide heuristics and populate
382    /// `RawFileAnalysis::style_analysis`. Set to `false` to skip style scoring entirely.
383    pub enable_style: bool,
384    /// Restrict style analysis to a specific language family slug (`"all"` or `"c_family"`).
385    /// When `"c_family"`, only C / C++ / Objective-C files are style-analysed.
386    pub style_lang_scope: StyleLangScope,
387}
388
389/// Which language families receive style-guide heuristic analysis.
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum StyleLangScope {
392    All,
393    CFamilyOnly,
394}
395
396/// Strategy for computing Logical SLOC (LSLOC) from a physical-line scan.
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
398pub enum LslocStrategy {
399    /// Count semicolons on code lines (C, C++, Java, C#, Go, Rust, JS/TS, Kotlin, SQL, …).
400    Semicolons,
401    /// Count non-blank code lines whose trimmed content does not end with a continuation
402    /// character (`\`, `,`, `(`, `[`, `{`). Suitable for Python, Ruby, Shell, Elixir, Nim.
403    NonContinuationNewlines,
404    /// Language does not have a well-defined statement boundary detectable by simple
405    /// lexical heuristics; `lsloc` will be `None` for files of this type.
406    Unsupported,
407}
408
409impl Default for AnalysisOptions {
410    fn default() -> Self {
411        Self {
412            blank_in_block_comment_as_comment: true,
413            collapse_continuation_lines: false,
414            enable_style: true,
415            style_lang_scope: StyleLangScope::All,
416        }
417    }
418}
419
420#[must_use]
421pub fn supported_languages() -> BTreeSet<Language> {
422    [
423        Language::Assembly,
424        Language::C,
425        Language::Clojure,
426        Language::Cpp,
427        Language::CSharp,
428        Language::Css,
429        Language::Dart,
430        Language::Dockerfile,
431        Language::Elixir,
432        Language::Erlang,
433        Language::FSharp,
434        Language::Go,
435        Language::Groovy,
436        Language::Haskell,
437        Language::Html,
438        Language::Java,
439        Language::JavaScript,
440        Language::Julia,
441        Language::Kotlin,
442        Language::Lua,
443        Language::Makefile,
444        Language::Nim,
445        Language::ObjectiveC,
446        Language::Ocaml,
447        Language::Perl,
448        Language::Php,
449        Language::PowerShell,
450        Language::Python,
451        Language::R,
452        Language::Ruby,
453        Language::Rust,
454        Language::Scala,
455        Language::Scss,
456        Language::Shell,
457        Language::Sql,
458        Language::Svelte,
459        Language::Swift,
460        Language::TypeScript,
461        Language::Vue,
462        Language::Xml,
463        Language::Zig,
464        Language::Solidity,
465        Language::Protobuf,
466        Language::Hcl,
467        Language::GraphQl,
468        Language::Ada,
469        Language::Vhdl,
470        Language::Verilog,
471        Language::Tcl,
472        Language::Pascal,
473        Language::VisualBasic,
474        Language::Lisp,
475        Language::Fortran,
476        Language::Nix,
477        Language::Crystal,
478        Language::D,
479        Language::Glsl,
480        Language::Cmake,
481        Language::Elm,
482        Language::Awk,
483    ]
484    .into_iter()
485    .collect()
486}
487
488/// Detect language from a shebang line (e.g. `#!/usr/bin/env python3`).
489fn detect_by_shebang(line: &str) -> Option<Language> {
490    let lower = line.to_ascii_lowercase();
491    if !lower.starts_with("#!") {
492        return None;
493    }
494    if lower.contains("python") {
495        return Some(Language::Python);
496    }
497    if lower.contains("pwsh") || lower.contains("powershell") {
498        return Some(Language::PowerShell);
499    }
500    if lower.contains("bash")
501        || lower.contains("/sh")
502        || lower.contains("zsh")
503        || lower.contains("ksh")
504    {
505        return Some(Language::Shell);
506    }
507    if lower.contains("ruby") {
508        return Some(Language::Ruby);
509    }
510    if lower.contains("perl") {
511        return Some(Language::Perl);
512    }
513    if lower.contains("php") {
514        return Some(Language::Php);
515    }
516    if lower.contains("node") || lower.contains("nodejs") {
517        return Some(Language::JavaScript);
518    }
519    None
520}
521
522/// Detect language purely from a (lowercased) file extension.
523#[allow(clippy::too_many_lines)]
524fn detect_by_extension(ext: &str) -> Option<Language> {
525    // Static table avoids a large match statement; each extension maps 1-to-1 to a language.
526    static EXT_MAP: &[(&str, Language)] = &[
527        ("c", Language::C),
528        ("h", Language::C),
529        ("cc", Language::Cpp),
530        ("cp", Language::Cpp),
531        ("cpp", Language::Cpp),
532        ("cxx", Language::Cpp),
533        ("hh", Language::Cpp),
534        ("hpp", Language::Cpp),
535        ("hxx", Language::Cpp),
536        ("cs", Language::CSharp),
537        ("go", Language::Go),
538        ("java", Language::Java),
539        ("js", Language::JavaScript),
540        ("mjs", Language::JavaScript),
541        ("cjs", Language::JavaScript),
542        ("py", Language::Python),
543        ("rs", Language::Rust),
544        ("sh", Language::Shell),
545        ("bash", Language::Shell),
546        ("zsh", Language::Shell),
547        ("ksh", Language::Shell),
548        ("ps1", Language::PowerShell),
549        ("psm1", Language::PowerShell),
550        ("psd1", Language::PowerShell),
551        ("ts", Language::TypeScript),
552        ("mts", Language::TypeScript),
553        ("cts", Language::TypeScript),
554        ("tsx", Language::TypeScript),
555        ("jsx", Language::JavaScript),
556        ("asm", Language::Assembly),
557        ("s", Language::Assembly),
558        ("clj", Language::Clojure),
559        ("cljs", Language::Clojure),
560        ("cljc", Language::Clojure),
561        ("edn", Language::Clojure),
562        ("css", Language::Css),
563        ("dart", Language::Dart),
564        ("ex", Language::Elixir),
565        ("exs", Language::Elixir),
566        ("erl", Language::Erlang),
567        ("hrl", Language::Erlang),
568        ("fs", Language::FSharp),
569        ("fsi", Language::FSharp),
570        ("fsx", Language::FSharp),
571        ("groovy", Language::Groovy),
572        ("gradle", Language::Groovy),
573        ("hs", Language::Haskell),
574        ("lhs", Language::Haskell),
575        ("html", Language::Html),
576        ("htm", Language::Html),
577        ("xhtml", Language::Html),
578        ("jl", Language::Julia),
579        ("kt", Language::Kotlin),
580        ("kts", Language::Kotlin),
581        ("lua", Language::Lua),
582        ("mk", Language::Makefile),
583        ("nim", Language::Nim),
584        ("nims", Language::Nim),
585        ("m", Language::ObjectiveC),
586        ("mm", Language::ObjectiveC),
587        ("ml", Language::Ocaml),
588        ("mli", Language::Ocaml),
589        ("pl", Language::Perl),
590        ("pm", Language::Perl),
591        ("t", Language::Perl),
592        ("php", Language::Php),
593        ("php3", Language::Php),
594        ("php4", Language::Php),
595        ("php5", Language::Php),
596        ("php7", Language::Php),
597        ("phtml", Language::Php),
598        ("r", Language::R),
599        ("rb", Language::Ruby),
600        ("rake", Language::Ruby),
601        ("scala", Language::Scala),
602        ("sc", Language::Scala),
603        ("scss", Language::Scss),
604        ("sass", Language::Scss),
605        ("sql", Language::Sql),
606        ("svelte", Language::Svelte),
607        ("swift", Language::Swift),
608        ("vue", Language::Vue),
609        ("xml", Language::Xml),
610        ("xsd", Language::Xml),
611        ("xsl", Language::Xml),
612        ("xslt", Language::Xml),
613        ("svg", Language::Xml),
614        ("zig", Language::Zig),
615        ("sol", Language::Solidity),
616        ("proto", Language::Protobuf),
617        ("tf", Language::Hcl),
618        ("tfvars", Language::Hcl),
619        ("hcl", Language::Hcl),
620        ("graphql", Language::GraphQl),
621        ("gql", Language::GraphQl),
622        ("adb", Language::Ada),
623        ("ads", Language::Ada),
624        ("ada", Language::Ada),
625        ("vhd", Language::Vhdl),
626        ("vhdl", Language::Vhdl),
627        ("v", Language::Verilog),
628        ("sv", Language::Verilog),
629        ("svh", Language::Verilog),
630        ("vh", Language::Verilog),
631        ("tcl", Language::Tcl),
632        ("pas", Language::Pascal),
633        ("dpr", Language::Pascal),
634        ("vb", Language::VisualBasic),
635        ("bas", Language::VisualBasic),
636        ("lisp", Language::Lisp),
637        ("lsp", Language::Lisp),
638        ("el", Language::Lisp),
639        ("scm", Language::Lisp),
640        ("ss", Language::Lisp),
641        ("rkt", Language::Lisp),
642        ("f90", Language::Fortran),
643        ("f95", Language::Fortran),
644        ("f03", Language::Fortran),
645        ("f08", Language::Fortran),
646        ("f", Language::Fortran),
647        ("for", Language::Fortran),
648        ("nix", Language::Nix),
649        ("cr", Language::Crystal),
650        ("d", Language::D),
651        ("glsl", Language::Glsl),
652        ("vert", Language::Glsl),
653        ("frag", Language::Glsl),
654        ("comp", Language::Glsl),
655        ("geom", Language::Glsl),
656        ("tesc", Language::Glsl),
657        ("tese", Language::Glsl),
658        ("hlsl", Language::Glsl),
659        ("wgsl", Language::Glsl),
660        ("cmake", Language::Cmake),
661        ("elm", Language::Elm),
662        ("awk", Language::Awk),
663    ];
664    EXT_MAP.iter().find_map(|&(e, l)| (e == ext).then_some(l))
665}
666
667/// Detect language from an exact filename (no extension) or well-known filename patterns.
668fn detect_by_filename(filename: &str, filename_lower: &str) -> Option<Language> {
669    // Dockerfile: exact name or Dockerfile.* variant
670    if filename == "Dockerfile"
671        || filename.starts_with("Dockerfile.")
672        || filename_lower == "dockerfile"
673    {
674        return Some(Language::Dockerfile);
675    }
676    // Makefile variants
677    if matches!(
678        filename,
679        "Makefile" | "GNUmakefile" | "makefile" | "BSDmakefile"
680    ) {
681        return Some(Language::Makefile);
682    }
683    // Ruby ecosystem files that have no extension
684    if matches!(
685        filename,
686        "Rakefile" | "Gemfile" | "Guardfile" | "Vagrantfile" | "Fastfile" | "Podfile"
687    ) {
688        return Some(Language::Ruby);
689    }
690    // CMake build scripts: `CMakeLists.txt` has a `.txt` extension, so it must be
691    // matched by exact name before extension-based detection.
692    if filename == "CMakeLists.txt" || filename_lower == "cmakelists.txt" {
693        return Some(Language::Cmake);
694    }
695    None
696}
697
698#[must_use]
699#[allow(clippy::too_many_lines)]
700pub fn detect_language(
701    path: &Path,
702    first_line: Option<&str>,
703    extension_overrides: &BTreeMap<String, String>,
704    shebang_detection: bool,
705) -> Option<Language> {
706    let extension = path
707        .extension()
708        .and_then(|ext| ext.to_str())
709        .map(str::to_ascii_lowercase);
710
711    // Extension override check (user-configured mappings win over everything)
712    if let Some(ext) = extension.as_ref()
713        && let Some(override_name) = extension_overrides.get(ext.as_str())
714        && let Some(lang) = Language::from_name(override_name)
715    {
716        return Some(lang);
717    }
718
719    // Filename-based detection for files that have no extension or use exact names
720    let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
721    let filename_lower = filename.to_ascii_lowercase();
722
723    if let Some(lang) = detect_by_filename(filename, &filename_lower) {
724        return Some(lang);
725    }
726
727    // Extension-based detection
728    if let Some(lang) = extension.as_deref().and_then(detect_by_extension) {
729        return Some(lang);
730    }
731
732    // Shebang detection (last resort when filename/extension detection did not match)
733    if shebang_detection
734        && let Some(line) = first_line
735        && let Some(lang) = detect_by_shebang(line)
736    {
737        return Some(lang);
738    }
739
740    None
741}
742
743/// Best-effort test: does this source text use C++-only constructs?
744///
745/// The `.h` extension is shared by C and C++ headers. When a `.h` file (detected as C by
746/// extension) contains any unambiguously C++ construct, callers should reclassify it as C++ so
747/// namespaces, classes, templates, and class-typed function signatures are counted correctly.
748/// Markers chosen to not appear in valid C: `namespace`, `template`, `class`, access specifiers,
749/// `::` scope resolution, and `std::`.
750#[must_use]
751pub fn looks_like_cpp(text: &str) -> bool {
752    const MARKERS: &[&str] = &[
753        "namespace ",
754        "template<",
755        "template <",
756        "class ",
757        "public:",
758        "private:",
759        "protected:",
760        "std::",
761        "::",
762        "nullptr",
763        "constexpr ",
764        "noexcept",
765    ];
766    MARKERS.iter().any(|m| text.contains(m))
767}
768
769/// tree-sitter fast-path for languages that have an adapter. Returns `Some` only
770/// when an adapter exists AND no IEEE 1045-1992 counting policy is engaged — the
771/// adapters don't implement those policies, so honoring them here would make counts
772/// depend on whether the `tree-sitter` feature is compiled in. Takes
773/// `&AnalysisOptions` to avoid assuming `AnalysisOptions: Copy`.
774#[cfg(feature = "tree-sitter")]
775fn tree_sitter_fast_path(
776    language: Language,
777    text: &str,
778    options: AnalysisOptions,
779) -> Option<RawFileAnalysis> {
780    if options.blank_in_block_comment_as_comment || options.collapse_continuation_lines {
781        return None;
782    }
783    match language {
784        Language::C | Language::Cpp => {
785            let mut result = ts::analyze_c(text)?;
786            if options.enable_style && should_style_analyse(language, options.style_lang_scope) {
787                result.style_analysis = style::analyze_style(language, text);
788            }
789            Some(result)
790        }
791        Language::Python => ts::analyze_python(text),
792        _ => None,
793    }
794}
795
796#[must_use]
797pub fn analyze_text(language: Language, text: &str, options: AnalysisOptions) -> RawFileAnalysis {
798    // tree-sitter fast-path (compiled out when the feature is disabled).
799    #[cfg(feature = "tree-sitter")]
800    if let Some(result) = tree_sitter_fast_path(language, text, options) {
801        return result;
802    }
803
804    let (mut config, has_preprocessor) = language_scan_config(language);
805
806    // Python docstring lines are computed from the text and cannot be a static constant.
807    if language == Language::Python {
808        config.skip_lines = detect_python_docstring_lines(text);
809    }
810
811    // C, C++, and Objective-C have a preprocessor whose directive lines are tracked separately
812    // per IEEE 1045-1992 §4.2; every other language uses base flags.
813    let flags = IeeeFlags {
814        has_preprocessor_directives: has_preprocessor,
815        blank_in_block_comment_as_comment: options.blank_in_block_comment_as_comment,
816        collapse_continuation_lines: options.collapse_continuation_lines,
817    };
818    let mut result = analyze_generic(text, config, flags);
819    if options.enable_style && should_style_analyse(language, options.style_lang_scope) {
820        result.style_analysis = style::analyze_style(language, text);
821    }
822    result
823}
824
825/// Returns `true` when `language` should be style-analysed under `scope`.
826const fn should_style_analyse(language: Language, scope: StyleLangScope) -> bool {
827    match scope {
828        StyleLangScope::CFamilyOnly => {
829            matches!(language, Language::C | Language::Cpp | Language::ObjectiveC)
830        }
831        StyleLangScope::All => true,
832    }
833}
834
835/// Returns the lexical scan configuration for `language` and whether it uses a C preprocessor.
836/// All fields are static constants except `skip_lines`, which is always empty here; callers that
837/// need non-empty skip sets (currently only Python) must populate the field after this call.
838///
839/// The implementation delegates to `LANG_SCAN_TABLE` (a static `&[(Language, StaticLangConfig)]`)
840/// defined below the `SP_*` symbol-pattern constants.  Each language appears exactly once in the
841/// table, so the linear scan is O(|languages|) but avoids a 41-arm `match` statement.
842fn language_scan_config(language: Language) -> (ScanConfig, bool) {
843    let cfg = LANG_SCAN_TABLE
844        .iter()
845        .find_map(|&(l, c)| (l == language).then_some(c))
846        .unwrap_or_else(|| panic!("language_scan_config: no entry for {language:?}"));
847    let (branch_keywords, lsloc_strategy) = language_complexity_config(language);
848    (
849        ScanConfig {
850            line_comments: cfg.line_comments,
851            block_comment: cfg.block_comment,
852            allow_single_quote_strings: cfg.allow_single_quote_strings,
853            allow_double_quote_strings: cfg.allow_double_quote_strings,
854            allow_triple_quote_strings: cfg.allow_triple_quote_strings,
855            allow_csharp_verbatim_strings: cfg.allow_csharp_verbatim_strings,
856            allow_raw_strings: cfg.allow_raw_strings,
857            skip_lines: HashSet::new(),
858            symbol_patterns: cfg.symbol_patterns,
859            branch_keywords,
860            lsloc_strategy,
861        },
862        cfg.has_preprocessor,
863    )
864}
865
866// ── Cyclomatic complexity branch-keyword lists ────────────────────────────────
867// Alphabetic tokens are matched word-bounded; operator tokens (||, &&, ?) are
868// matched as raw substrings.  Each list covers one language family.
869
870const BRANCH_C_FAMILY: &[&str] = &[
871    "if", "else", "for", "while", "switch", "case", "catch", "||", "&&",
872];
873const BRANCH_C_TERNARY: &[&str] = &[
874    "if", "else", "for", "while", "switch", "case", "catch", "||", "&&", "?",
875];
876const BRANCH_GO: &[&str] = &["if", "else", "for", "switch", "case", "select", "||", "&&"];
877const BRANCH_RUST: &[&str] = &["if", "else", "for", "while", "match", "||", "&&"];
878const BRANCH_ZIG: &[&str] = &["if", "else", "for", "while", "switch", "catch", "||", "&&"];
879const BRANCH_FSHARP: &[&str] = &["if", "then", "else", "elif", "match", "when", "||", "&&"];
880const BRANCH_LUA: &[&str] = &[
881    "if", "elseif", "else", "for", "while", "repeat", "and", "or",
882];
883const BRANCH_HASKELL: &[&str] = &["if", "then", "else", "case", "otherwise"];
884const BRANCH_SQL: &[&str] = &["CASE", "WHEN", "IF", "ELSE", "case", "when", "if", "else"];
885const BRANCH_OCAML: &[&str] = &["if", "then", "else", "match", "when", "||", "&&"];
886const BRANCH_CLOJURE: &[&str] = &["if", "when", "cond", "case", "and", "or"];
887const BRANCH_PHP: &[&str] = &[
888    "if", "elseif", "else", "for", "while", "switch", "case", "catch", "match", "||", "&&", "?",
889];
890const BRANCH_JULIA: &[&str] = &["if", "elseif", "else", "for", "while", "catch", "||", "&&"];
891const BRANCH_PYTHON: &[&str] = &["if", "elif", "else", "for", "while", "except", "or", "and"];
892const BRANCH_RUBY: &[&str] = &[
893    "if", "elsif", "else", "unless", "until", "while", "case", "when", "rescue", "||", "&&",
894];
895const BRANCH_SHELL: &[&str] = &["if", "elif", "else", "while", "until", "case", "||", "&&"];
896const BRANCH_ELIXIR: &[&str] = &[
897    "if", "else", "cond", "case", "when", "rescue", "||", "&&", "and", "or",
898];
899const BRANCH_POWERSHELL: &[&str] = &[
900    "if", "elseif", "else", "for", "while", "switch", "foreach", "||", "&&",
901];
902const BRANCH_NIM: &[&str] = &[
903    "if", "elif", "else", "for", "while", "case", "of", "except", "and", "or",
904];
905const BRANCH_PERL: &[&str] = &[
906    "if", "elsif", "else", "unless", "until", "for", "while", "foreach", "||", "&&",
907];
908const BRANCH_R: &[&str] = &["if", "else", "for", "while", "repeat", "||", "&&"];
909// Pass 2 branch-keyword lists (legacy + embedded / HDL).
910const BRANCH_ADA: &[&str] = &[
911    "if", "elsif", "else", "case", "when", "loop", "while", "for", "and", "or",
912];
913const BRANCH_VHDL: &[&str] = &[
914    "if", "elsif", "else", "case", "when", "loop", "while", "for", "and", "or", "nand", "nor",
915    "xor",
916];
917const BRANCH_VERILOG: &[&str] = &[
918    "if", "else", "case", "casex", "casez", "for", "while", "&&", "||",
919];
920const BRANCH_TCL: &[&str] = &["if", "elseif", "else", "switch", "while", "for", "foreach"];
921const BRANCH_PASCAL: &[&str] = &[
922    "if", "then", "else", "case", "while", "for", "repeat", "until", "and", "or",
923];
924const BRANCH_VB: &[&str] = &[
925    "If", "Then", "ElseIf", "Else", "Select", "Case", "While", "For", "Do", "And", "Or",
926];
927const BRANCH_LISP: &[&str] = &["if", "when", "unless", "cond", "case", "and", "or"];
928// Pass 3 branch-keyword lists (scientific / infra / systems / graphics).
929const BRANCH_FORTRAN: &[&str] = &[
930    "if", "then", "else", "elseif", "case", "do", "while", "where",
931];
932const BRANCH_NIX: &[&str] = &["if", "then", "else"];
933const BRANCH_CMAKE: &[&str] = &["if(", "elseif(", "else(", "while(", "foreach("];
934const BRANCH_ELM: &[&str] = &["if", "then", "else", "case", "of"];
935const BRANCH_AWK: &[&str] = &["if", "else", "while", "for", "do"];
936
937/// Returns (`branch_keywords`, `lsloc_strategy`) for the given language.
938/// Kept separate from `LANG_SCAN_TABLE` to avoid touching that large table.
939const fn language_complexity_config(
940    language: Language,
941) -> (&'static [&'static str], LslocStrategy) {
942    match language {
943        // ── C-ternary family (ternary operator counted as branch) ─────────────
944        Language::C
945        | Language::Cpp
946        | Language::ObjectiveC
947        | Language::CSharp
948        | Language::JavaScript
949        | Language::TypeScript
950        | Language::Svelte
951        | Language::Vue
952        | Language::Dart
953        | Language::Groovy
954        | Language::Swift
955        | Language::Solidity => (BRANCH_C_TERNARY, LslocStrategy::Semicolons),
956        // ── C-family (no ternary keyword) ────────────────────────────────────
957        Language::Java | Language::Kotlin | Language::Scala | Language::D | Language::Glsl => {
958            (BRANCH_C_FAMILY, LslocStrategy::Semicolons)
959        }
960        Language::Go => (BRANCH_GO, LslocStrategy::Semicolons),
961        Language::Rust => (BRANCH_RUST, LslocStrategy::Semicolons),
962        Language::Zig => (BRANCH_ZIG, LslocStrategy::Semicolons),
963        Language::FSharp => (BRANCH_FSHARP, LslocStrategy::Unsupported),
964        // ── Hash-comment family ───────────────────────────────────────────────
965        Language::Shell => (BRANCH_SHELL, LslocStrategy::NonContinuationNewlines),
966        Language::Elixir => (BRANCH_ELIXIR, LslocStrategy::NonContinuationNewlines),
967        Language::Perl => (BRANCH_PERL, LslocStrategy::Semicolons),
968        Language::R => (BRANCH_R, LslocStrategy::NonContinuationNewlines),
969        Language::Ruby | Language::Crystal => (BRANCH_RUBY, LslocStrategy::NonContinuationNewlines),
970        Language::Python => (BRANCH_PYTHON, LslocStrategy::NonContinuationNewlines),
971        Language::PowerShell => (BRANCH_POWERSHELL, LslocStrategy::Unsupported),
972        Language::Nim => (BRANCH_NIM, LslocStrategy::NonContinuationNewlines),
973        // ── Unique comment styles ─────────────────────────────────────────────
974        Language::Lua => (BRANCH_LUA, LslocStrategy::Unsupported),
975        Language::Haskell => (BRANCH_HASKELL, LslocStrategy::Unsupported),
976        Language::Sql => (BRANCH_SQL, LslocStrategy::Semicolons),
977        Language::Ocaml => (BRANCH_OCAML, LslocStrategy::Semicolons),
978        Language::Clojure => (BRANCH_CLOJURE, LslocStrategy::Unsupported),
979        Language::Php => (BRANCH_PHP, LslocStrategy::Semicolons),
980        Language::Julia => (BRANCH_JULIA, LslocStrategy::NonContinuationNewlines),
981        Language::Protobuf => (&[], LslocStrategy::Semicolons),
982        Language::Hcl => (&[], LslocStrategy::NonContinuationNewlines),
983        // ── Legacy / embedded / HDL ───────────────────────────────────────────
984        Language::Ada => (BRANCH_ADA, LslocStrategy::Semicolons),
985        Language::Vhdl => (BRANCH_VHDL, LslocStrategy::Semicolons),
986        Language::Verilog => (BRANCH_VERILOG, LslocStrategy::Semicolons),
987        Language::Tcl => (BRANCH_TCL, LslocStrategy::NonContinuationNewlines),
988        Language::Pascal => (BRANCH_PASCAL, LslocStrategy::Semicolons),
989        Language::VisualBasic => (BRANCH_VB, LslocStrategy::NonContinuationNewlines),
990        Language::Lisp => (BRANCH_LISP, LslocStrategy::Unsupported),
991        // ── Scientific / infra / systems / graphics ───────────────────────────
992        Language::Fortran => (BRANCH_FORTRAN, LslocStrategy::NonContinuationNewlines),
993        Language::Nix => (BRANCH_NIX, LslocStrategy::Unsupported),
994        Language::Cmake => (BRANCH_CMAKE, LslocStrategy::Unsupported),
995        Language::Elm => (BRANCH_ELM, LslocStrategy::Unsupported),
996        Language::Awk => (BRANCH_AWK, LslocStrategy::NonContinuationNewlines),
997        // ── No branch detection / syntax unsupported ──────────────────────────
998        Language::Makefile
999        | Language::Dockerfile
1000        | Language::Css
1001        | Language::Html
1002        | Language::Xml
1003        | Language::Assembly
1004        | Language::Erlang
1005        | Language::GraphQl
1006        | Language::Scss => (&[], LslocStrategy::Unsupported),
1007    }
1008}
1009
1010/// Per-language keyword prefixes used for best-effort structural symbol detection.
1011/// Each slice lists line prefixes (after leading whitespace is stripped) that indicate
1012/// a definition of that category. Empty slice = detection disabled for that category.
1013#[derive(Debug, Clone, Copy)]
1014struct SymbolPatterns {
1015    functions: &'static [&'static str],
1016    /// Line prefixes that classify as a function only when the line ALSO contains `(`
1017    /// AND there is no `=` between the prefix and the first `(`.  Used for C/C++ where
1018    /// function definitions are led by the return type (`void`, `int`, `bool`, …) with
1019    /// no dedicated keyword, so the paren guard distinguishes `void f(x)` from
1020    /// `void* p = malloc(n)`.
1021    functions_prefix_paren: &'static [&'static str],
1022    classes: &'static [&'static str],
1023    variables: &'static [&'static str],
1024    imports: &'static [&'static str],
1025    /// Line prefixes (after stripping leading whitespace) that indicate a test case or test
1026    /// function definition. Matched against code lines only, same as other symbol categories.
1027    tests: &'static [&'static str],
1028    /// Line prefixes that indicate a test assertion call (`ASSERT_EQ`, assertEquals, `assert_eq`!,
1029    /// Assert.AreEqual, etc.). Matched against code lines only.
1030    assertions: &'static [&'static str],
1031    /// Line prefixes that indicate a test suite / fixture / group declaration
1032    /// (`TEST_GROUP`, `BOOST_AUTO_TEST_SUITE`, [`TestClass`], [`TestFixture`], etc.).
1033    test_suites: &'static [&'static str],
1034    /// Type-keyword prefixes (e.g. `"int "`, `"const "`) that classify a line as a
1035    /// variable declaration when the line ALSO satisfies the complement of the
1036    /// `functions_prefix_paren` condition: either no `(` is present, or a `=` appears
1037    /// before the first `(`.  Used for C/C++ where both functions and variables are
1038    /// led by the same return / value type keywords; the paren guard splits them.
1039    variables_prefix_no_paren: &'static [&'static str],
1040}
1041
1042impl SymbolPatterns {
1043    const fn none() -> Self {
1044        Self {
1045            functions: &[],
1046            functions_prefix_paren: &[],
1047            classes: &[],
1048            variables: &[],
1049            imports: &[],
1050            tests: &[],
1051            assertions: &[],
1052            test_suites: &[],
1053            variables_prefix_no_paren: &[],
1054        }
1055    }
1056}
1057
1058const SP_NONE: SymbolPatterns = SymbolPatterns::none(); // all fields are &[]
1059
1060// Solidity: `function`/`modifier`/`constructor` definitions; `contract`/`interface`/
1061// `library` are the structural units (mapped to classes alongside struct/enum).
1062const SP_SOLIDITY: SymbolPatterns = SymbolPatterns {
1063    functions: &[
1064        "function ",
1065        "modifier ",
1066        "constructor",
1067        "receive ",
1068        "fallback ",
1069    ],
1070    functions_prefix_paren: &[],
1071    classes: &["contract ", "interface ", "library ", "struct ", "enum "],
1072    variables: &[],
1073    imports: &["import "],
1074    // Foundry / DSTest / Forge-std: test functions are `function test...`, fuzz
1075    // tests `function testFuzz...`, and assertions are the `assert*`/`expect*` cheats.
1076    tests: &["function test", "function testFuzz", "function invariant"],
1077    assertions: &[
1078        "assertEq(",
1079        "assertEq0(",
1080        "assertTrue(",
1081        "assertFalse(",
1082        "assertGt(",
1083        "assertLt(",
1084        "assertGe(",
1085        "assertLe(",
1086        "assertApproxEq",
1087        "vm.expectRevert(",
1088        "vm.expectEmit(",
1089    ],
1090    test_suites: &[],
1091    variables_prefix_no_paren: &[],
1092};
1093
1094// Protocol Buffers: `message`/`service`/`enum` declarations are the structural units;
1095// `rpc` entries are the closest thing to functions.
1096const SP_PROTOBUF: SymbolPatterns = SymbolPatterns {
1097    functions: &["rpc "],
1098    functions_prefix_paren: &[],
1099    classes: &["message ", "service ", "enum "],
1100    variables: &[],
1101    imports: &["import "],
1102    tests: &[],
1103    assertions: &[],
1104    test_suites: &[],
1105    variables_prefix_no_paren: &[],
1106};
1107
1108// ── Pass 2 symbol patterns (legacy + embedded / HDL) ──────────────────────────
1109const SP_ADA: SymbolPatterns = SymbolPatterns {
1110    functions: &["procedure ", "function "],
1111    functions_prefix_paren: &[],
1112    classes: &["package ", "type ", "task ", "protected "],
1113    variables: &[],
1114    imports: &["with ", "use "],
1115    tests: &[],
1116    assertions: &[],
1117    test_suites: &[],
1118    variables_prefix_no_paren: &[],
1119};
1120
1121const SP_VHDL: SymbolPatterns = SymbolPatterns {
1122    functions: &["function ", "procedure ", "process "],
1123    functions_prefix_paren: &[],
1124    classes: &["entity ", "architecture ", "package ", "component "],
1125    variables: &[],
1126    imports: &["library ", "use "],
1127    tests: &[],
1128    assertions: &[],
1129    test_suites: &[],
1130    variables_prefix_no_paren: &[],
1131};
1132
1133const SP_VERILOG: SymbolPatterns = SymbolPatterns {
1134    functions: &["function ", "task "],
1135    functions_prefix_paren: &[],
1136    classes: &["module ", "interface ", "class ", "package "],
1137    variables: &[],
1138    imports: &["import ", "`include"],
1139    tests: &[],
1140    assertions: &[],
1141    test_suites: &[],
1142    variables_prefix_no_paren: &[],
1143};
1144
1145const SP_TCL: SymbolPatterns = SymbolPatterns {
1146    functions: &["proc "],
1147    functions_prefix_paren: &[],
1148    classes: &[],
1149    variables: &[],
1150    imports: &["source ", "package require "],
1151    // tcltest: each case is introduced by the `test` command.
1152    tests: &["test "],
1153    assertions: &[],
1154    test_suites: &[],
1155    variables_prefix_no_paren: &[],
1156};
1157
1158const SP_PASCAL: SymbolPatterns = SymbolPatterns {
1159    functions: &["procedure ", "function "],
1160    functions_prefix_paren: &[],
1161    classes: &["type ", "class ", "record "],
1162    variables: &[],
1163    imports: &["uses "],
1164    // DUnit / FPCUnit: test methods are `procedure Test...`; checks are the assertions.
1165    tests: &["procedure Test"],
1166    assertions: &[
1167        "Check(",
1168        "CheckEquals(",
1169        "CheckTrue(",
1170        "CheckFalse(",
1171        "CheckNotNull(",
1172    ],
1173    test_suites: &[],
1174    variables_prefix_no_paren: &[],
1175};
1176
1177const SP_VB: SymbolPatterns = SymbolPatterns {
1178    functions: &[
1179        "Sub ",
1180        "Function ",
1181        "Private Sub ",
1182        "Public Sub ",
1183        "Private Function ",
1184        "Public Function ",
1185    ],
1186    functions_prefix_paren: &[],
1187    classes: &["Class ", "Module ", "Structure "],
1188    variables: &[],
1189    imports: &["Imports "],
1190    // MSTest attributes on their own line; Assert.* calls for assertions.
1191    tests: &["<TestMethod>", "<TestMethod("],
1192    assertions: &["Assert.", "CollectionAssert.", "StringAssert."],
1193    test_suites: &["<TestClass>", "<TestClass("],
1194    variables_prefix_no_paren: &[],
1195};
1196
1197const SP_LISP: SymbolPatterns = SymbolPatterns {
1198    functions: &["(defun ", "(defmacro ", "(define ", "(defmethod ", "(defn "],
1199    functions_prefix_paren: &[],
1200    classes: &["(defclass ", "(defstruct "],
1201    variables: &[],
1202    imports: &["(require ", "(import ", "(use-package "],
1203    // FiveAM (Common Lisp): `(test name ...)` cases with `(is ...)` checks.
1204    tests: &["(test ", "(deftest "],
1205    assertions: &["(is ", "(is-true ", "(is-false ", "(signals "],
1206    test_suites: &[],
1207    variables_prefix_no_paren: &[],
1208};
1209
1210// ── Pass 3 symbol patterns (scientific / infra / systems / graphics) ──────────
1211const SP_FORTRAN: SymbolPatterns = SymbolPatterns {
1212    functions: &["subroutine ", "function "],
1213    functions_prefix_paren: &[],
1214    classes: &["module ", "program ", "type "],
1215    variables: &[],
1216    imports: &["use ", "include "],
1217    tests: &[],
1218    assertions: &[],
1219    test_suites: &[],
1220    variables_prefix_no_paren: &[],
1221};
1222
1223const SP_CRYSTAL: SymbolPatterns = SymbolPatterns {
1224    functions: &["def "],
1225    functions_prefix_paren: &[],
1226    classes: &["class ", "module ", "struct ", "enum "],
1227    variables: &[],
1228    imports: &["require "],
1229    // Crystal Spec (RSpec-style): describe/it/context groups, pending stubs.
1230    tests: &["it ", "it(", "describe ", "context ", "pending "],
1231    assertions: &[],
1232    test_suites: &[],
1233    variables_prefix_no_paren: &[],
1234};
1235
1236const SP_D: SymbolPatterns = SymbolPatterns {
1237    functions: &[],
1238    functions_prefix_paren: &[],
1239    classes: &["class ", "struct ", "interface ", "enum ", "template "],
1240    variables: &[],
1241    imports: &["import "],
1242    // D built-in unittest blocks; `assert` is the in-language check.
1243    tests: &["unittest"],
1244    assertions: &["assert(", "assertThrown", "assertNotThrown"],
1245    test_suites: &[],
1246    variables_prefix_no_paren: &[],
1247};
1248
1249const SP_CMAKE: SymbolPatterns = SymbolPatterns {
1250    functions: &["function(", "macro("],
1251    functions_prefix_paren: &[],
1252    classes: &[],
1253    variables: &[],
1254    imports: &["include(", "add_subdirectory("],
1255    tests: &[],
1256    assertions: &[],
1257    test_suites: &[],
1258    variables_prefix_no_paren: &[],
1259};
1260
1261const SP_ELM: SymbolPatterns = SymbolPatterns {
1262    functions: &[],
1263    functions_prefix_paren: &[],
1264    classes: &["type "],
1265    variables: &[],
1266    imports: &["import "],
1267    // elm-test: test/describe/fuzz cases, with `Expect.*` checks.
1268    tests: &["test ", "describe ", "fuzz "],
1269    assertions: &["Expect."],
1270    test_suites: &[],
1271    variables_prefix_no_paren: &[],
1272};
1273
1274const SP_AWK: SymbolPatterns = SymbolPatterns {
1275    functions: &["function "],
1276    functions_prefix_paren: &[],
1277    classes: &[],
1278    variables: &[],
1279    imports: &[],
1280    tests: &[],
1281    assertions: &[],
1282    test_suites: &[],
1283    variables_prefix_no_paren: &[],
1284};
1285
1286const SP_RUST: SymbolPatterns = SymbolPatterns {
1287    functions: &[
1288        "fn ",
1289        "pub fn ",
1290        "pub(crate) fn ",
1291        "pub(super) fn ",
1292        "async fn ",
1293        "pub async fn ",
1294        "pub(crate) async fn ",
1295        "unsafe fn ",
1296        "pub unsafe fn ",
1297        "pub(crate) unsafe fn ",
1298        "const fn ",
1299        "pub const fn ",
1300        "pub(crate) const fn ",
1301        "extern fn ",
1302        "pub extern fn ",
1303    ],
1304    functions_prefix_paren: &[],
1305    classes: &[
1306        "struct ",
1307        "pub struct ",
1308        "pub(crate) struct ",
1309        "enum ",
1310        "pub enum ",
1311        "pub(crate) enum ",
1312        "trait ",
1313        "pub trait ",
1314        "pub(crate) trait ",
1315        "impl ",
1316        "impl<",
1317        "type ",
1318        "pub type ",
1319        "pub(crate) type ",
1320    ],
1321    variables: &["let ", "let mut "],
1322    imports: &["use ", "pub use ", "pub(crate) use ", "extern crate "],
1323    // Built-in #[test], tokio/actix async test attributes, rstest
1324    tests: &[
1325        "#[test]",
1326        "#[tokio::test]",
1327        "#[actix_web::test]",
1328        "#[rstest]",
1329        "#[test_case",
1330    ],
1331    assertions: &[
1332        "assert_eq!(",
1333        "assert_ne!(",
1334        "assert!(",
1335        "assert_matches!(",
1336        "assert_err!(",
1337        "assert_ok!(",
1338    ],
1339    test_suites: &[],
1340    variables_prefix_no_paren: &[],
1341};
1342
1343const SP_PYTHON: SymbolPatterns = SymbolPatterns {
1344    functions: &["def ", "async def "],
1345    functions_prefix_paren: &[],
1346    classes: &["class "],
1347    variables: &[],
1348    imports: &["import ", "from "],
1349    // pytest: test_ prefix functions and Test* classes; unittest: test_ methods
1350    tests: &["def test_", "async def test_", "class Test"],
1351    assertions: &[
1352        "self.assertEqual(",
1353        "self.assertNotEqual(",
1354        "self.assertTrue(",
1355        "self.assertFalse(",
1356        "self.assertIsNone(",
1357        "self.assertIsNotNone(",
1358        "self.assertIn(",
1359        "self.assertNotIn(",
1360        "self.assertRaises(",
1361        "self.assertAlmostEqual(",
1362    ],
1363    test_suites: &[],
1364    variables_prefix_no_paren: &[],
1365};
1366
1367const SP_JS: SymbolPatterns = SymbolPatterns {
1368    functions: &[
1369        "function ",
1370        "async function ",
1371        "export function ",
1372        "export async function ",
1373        "export default function ",
1374    ],
1375    functions_prefix_paren: &[],
1376    classes: &["class ", "export class ", "export default class "],
1377    variables: &[
1378        "var ",
1379        "let ",
1380        "const ",
1381        "export var ",
1382        "export let ",
1383        "export const ",
1384    ],
1385    imports: &["import "],
1386    // Jest/Mocha/Jasmine: describe/it/test block openers
1387    tests: &[
1388        "describe(",
1389        "it(",
1390        "test(",
1391        "it.each(",
1392        "test.each(",
1393        "describe.each(",
1394    ],
1395    assertions: &["expect("],
1396    test_suites: &[],
1397    variables_prefix_no_paren: &[],
1398};
1399
1400const SP_TS: SymbolPatterns = SymbolPatterns {
1401    functions: &[
1402        "function ",
1403        "async function ",
1404        "export function ",
1405        "export async function ",
1406        "export default function ",
1407    ],
1408    functions_prefix_paren: &[],
1409    classes: &[
1410        "class ",
1411        "export class ",
1412        "export default class ",
1413        "abstract class ",
1414        "export abstract class ",
1415        "interface ",
1416        "export interface ",
1417        "declare class ",
1418        "declare interface ",
1419    ],
1420    variables: &[
1421        "var ",
1422        "let ",
1423        "const ",
1424        "export var ",
1425        "export let ",
1426        "export const ",
1427    ],
1428    imports: &["import "],
1429    // Jest/Mocha/Jasmine/Vitest: describe/it/test block openers
1430    tests: &[
1431        "describe(",
1432        "it(",
1433        "test(",
1434        "it.each(",
1435        "test.each(",
1436        "describe.each(",
1437    ],
1438    assertions: &["expect("],
1439    test_suites: &[],
1440    variables_prefix_no_paren: &[],
1441};
1442
1443const SP_GO: SymbolPatterns = SymbolPatterns {
1444    functions: &["func "],
1445    functions_prefix_paren: &[],
1446    classes: &["type "],
1447    variables: &["var "],
1448    imports: &["import "],
1449    // Go standard testing: Test* functions (convention is practically exclusive to _test.go files)
1450    tests: &["func Test", "func Benchmark", "func Fuzz"],
1451    assertions: &[],
1452    test_suites: &[],
1453    variables_prefix_no_paren: &[],
1454};
1455
1456const SP_JAVA: SymbolPatterns = SymbolPatterns {
1457    functions: &[],
1458    functions_prefix_paren: &[],
1459    classes: &[
1460        "class ",
1461        "public class ",
1462        "private class ",
1463        "protected class ",
1464        "abstract class ",
1465        "final class ",
1466        "public abstract class ",
1467        "public final class ",
1468        "interface ",
1469        "public interface ",
1470        "enum ",
1471        "public enum ",
1472        "record ",
1473        "public record ",
1474        "@interface ",
1475    ],
1476    variables: &[],
1477    imports: &["import "],
1478    // JUnit 4 & 5, TestNG — annotations appear on their own line before the method
1479    tests: &[
1480        "@Test",
1481        "@ParameterizedTest",
1482        "@RepeatedTest",
1483        "@TestFactory",
1484        "@TestTemplate",
1485    ],
1486    assertions: &[
1487        "assertEquals(",
1488        "assertNotEquals(",
1489        "assertTrue(",
1490        "assertFalse(",
1491        "assertNull(",
1492        "assertNotNull(",
1493        "assertThat(",
1494        "assertThrows(",
1495        "assertAll(",
1496        "assertArrayEquals(",
1497        "assertIterableEquals(",
1498        "assertLinesMatch(",
1499    ],
1500    test_suites: &[],
1501    variables_prefix_no_paren: &[],
1502};
1503
1504const SP_CSHARP: SymbolPatterns = SymbolPatterns {
1505    functions: &[],
1506    functions_prefix_paren: &[],
1507    classes: &[
1508        "class ",
1509        "public class ",
1510        "private class ",
1511        "protected class ",
1512        "internal class ",
1513        "abstract class ",
1514        "sealed class ",
1515        "static class ",
1516        "partial class ",
1517        "public abstract class ",
1518        "public sealed class ",
1519        "public static class ",
1520        "interface ",
1521        "public interface ",
1522        "internal interface ",
1523        "enum ",
1524        "public enum ",
1525        "struct ",
1526        "public struct ",
1527        "record ",
1528        "public record ",
1529    ],
1530    variables: &["var "],
1531    imports: &["using "],
1532    // MSTest, NUnit, xUnit — attributes on their own line before the method
1533    tests: &[
1534        "[TestMethod]",
1535        "[Test]",
1536        "[Fact]",
1537        "[Theory]",
1538        "[TestCase(",
1539        "[DataRow(",
1540        "[InlineData(",
1541        "[MemberData(",
1542    ],
1543    assertions: &[
1544        "Assert.AreEqual(",
1545        "Assert.AreNotEqual(",
1546        "Assert.IsTrue(",
1547        "Assert.IsFalse(",
1548        "Assert.IsNull(",
1549        "Assert.IsNotNull(",
1550        "Assert.Equal(",
1551        "Assert.NotEqual(",
1552        "Assert.True(",
1553        "Assert.False(",
1554        "Assert.That(",
1555        "Assert.Contains(",
1556        "Assert.Throws(",
1557        "Assert.ThrowsAsync(",
1558        "Assert.IsInstanceOfType(",
1559    ],
1560    test_suites: &["[TestClass]", "[TestFixture]", "[SetUpFixture]"],
1561    variables_prefix_no_paren: &[],
1562};
1563
1564// GTest, Catch2/doctest, Boost.Test, Unity, Check, CMocka, CppUTest patterns for C and C++.
1565const TEST_PATTERNS_C_CPP: &[&str] = &[
1566    // Google Test
1567    "TEST(",
1568    "TEST_F(",
1569    "TEST_P(",
1570    "TYPED_TEST(",
1571    "TYPED_TEST_P(",
1572    "INSTANTIATE_TEST_SUITE_P(",
1573    "INSTANTIATE_TYPED_TEST_SUITE_P(",
1574    // Catch2 / doctest
1575    "TEST_CASE(",
1576    "SECTION(",
1577    "SCENARIO(",
1578    "SCENARIO_METHOD(",
1579    "TEST_CASE_METHOD(",
1580    // Boost.Test
1581    "BOOST_AUTO_TEST_CASE(",
1582    "BOOST_FIXTURE_TEST_CASE(",
1583    "BOOST_AUTO_TEST_SUITE(",
1584    "BOOST_PARAM_TEST_CASE(",
1585    // CppUnit
1586    "CPPUNIT_TEST(",
1587    "CPPUNIT_TEST_SUITE(",
1588    // Unity (embedded C)
1589    "RUN_TEST(",
1590    "TEST_IGNORE(",
1591    "TEST_FAIL(",
1592    // Check (libcheck — embedded C)
1593    "START_TEST(",
1594    "tcase_add_test(",
1595    "suite_create(",
1596    // CMocka (embedded C)
1597    "cmocka_unit_test(",
1598    "cmocka_run_group_tests(",
1599    // CppUTest
1600    "IGNORE_TEST(",
1601    "TEST_GROUP(",
1602    "TEST_GROUP_BASE(",
1603];
1604
1605// Test assertion patterns shared by C and C++.
1606const ASSERT_PATTERNS_C_CPP: &[&str] = &[
1607    // Google Test ASSERT_* (test-stopping failures)
1608    "ASSERT_EQ(",
1609    "ASSERT_NE(",
1610    "ASSERT_LT(",
1611    "ASSERT_LE(",
1612    "ASSERT_GT(",
1613    "ASSERT_GE(",
1614    "ASSERT_TRUE(",
1615    "ASSERT_FALSE(",
1616    "ASSERT_STREQ(",
1617    "ASSERT_STRNE(",
1618    "ASSERT_FLOAT_EQ(",
1619    "ASSERT_DOUBLE_EQ(",
1620    "ASSERT_NEAR(",
1621    "ASSERT_THROW(",
1622    "ASSERT_NO_THROW(",
1623    "ASSERT_ANY_THROW(",
1624    // Google Test EXPECT_* (non-stopping failures)
1625    "EXPECT_EQ(",
1626    "EXPECT_NE(",
1627    "EXPECT_LT(",
1628    "EXPECT_LE(",
1629    "EXPECT_GT(",
1630    "EXPECT_GE(",
1631    "EXPECT_TRUE(",
1632    "EXPECT_FALSE(",
1633    "EXPECT_STREQ(",
1634    "EXPECT_STRNE(",
1635    "EXPECT_FLOAT_EQ(",
1636    "EXPECT_DOUBLE_EQ(",
1637    "EXPECT_NEAR(",
1638    "EXPECT_THROW(",
1639    "EXPECT_NO_THROW(",
1640    "EXPECT_ANY_THROW(",
1641    // Catch2 / doctest assertions
1642    "REQUIRE(",
1643    "CHECK(",
1644    "REQUIRE_FALSE(",
1645    "CHECK_FALSE(",
1646    "REQUIRE_NOTHROW(",
1647    "CHECK_NOTHROW(",
1648    "REQUIRE_THROWS(",
1649    "CHECK_THROWS(",
1650    "REQUIRE_THAT(",
1651    "CHECK_THAT(",
1652    // Unity assertions (embedded C)
1653    "TEST_ASSERT_EQUAL(",
1654    "TEST_ASSERT_EQUAL_INT(",
1655    "TEST_ASSERT_EQUAL_STRING(",
1656    "TEST_ASSERT_EQUAL_FLOAT(",
1657    "TEST_ASSERT_EQUAL_DOUBLE(",
1658    "TEST_ASSERT_EQUAL_PTR(",
1659    "TEST_ASSERT_TRUE(",
1660    "TEST_ASSERT_FALSE(",
1661    "TEST_ASSERT_NULL(",
1662    "TEST_ASSERT_NOT_NULL(",
1663    "TEST_ASSERT_BITS_HIGH(",
1664    "TEST_ASSERT_BITS_LOW(",
1665    // CMocka assertions (embedded C)
1666    "assert_int_equal(",
1667    "assert_int_not_equal(",
1668    "assert_string_equal(",
1669    "assert_string_not_equal(",
1670    "assert_true(",
1671    "assert_false(",
1672    "assert_null(",
1673    "assert_non_null(",
1674    "assert_ptr_equal(",
1675    "assert_memory_equal(",
1676    "assert_return_code(",
1677];
1678
1679// Test suite/group declaration patterns for C and C++.
1680const SUITE_PATTERNS_C_CPP: &[&str] = &[
1681    "TEST_GROUP(",
1682    "TEST_GROUP_BASE(",
1683    "BOOST_AUTO_TEST_SUITE(",
1684    "CPPUNIT_TEST_SUITE(",
1685    "CPPUNIT_TEST_SUITE_END(",
1686];
1687
1688const SP_C: SymbolPatterns = SymbolPatterns {
1689    // C has no function keyword; detect by common return types that precede `(` with no `=`.
1690    functions: &[],
1691    functions_prefix_paren: &[
1692        "void ",
1693        "int ",
1694        "char ",
1695        "float ",
1696        "double ",
1697        "long ",
1698        "unsigned ",
1699        "size_t ",
1700        "static ",
1701        "inline ",
1702        "const ",
1703        "extern ",
1704    ],
1705    classes: &[
1706        "struct ",
1707        "typedef struct ",
1708        "union ",
1709        "typedef union ",
1710        "typedef enum ",
1711    ],
1712    variables: &[],
1713    imports: &["#include "],
1714    tests: TEST_PATTERNS_C_CPP,
1715    assertions: ASSERT_PATTERNS_C_CPP,
1716    test_suites: SUITE_PATTERNS_C_CPP,
1717    // Same type keywords as functions_prefix_paren; the complement paren guard (no unguarded `(`
1718    // in the line) distinguishes `int x;` / `int x = 5;` (variable) from `int foo()` (function).
1719    variables_prefix_no_paren: &[
1720        "void ",
1721        "int ",
1722        "char ",
1723        "float ",
1724        "double ",
1725        "long ",
1726        "unsigned ",
1727        "size_t ",
1728        "static ",
1729        "inline ",
1730        "const ",
1731        "extern ",
1732    ],
1733};
1734
1735const SP_CPP: SymbolPatterns = SymbolPatterns {
1736    // C++ specific function keyword-prefixes; return-type-led patterns use functions_prefix_paren.
1737    functions: &[
1738        "virtual ",  // virtual method declaration/definition
1739        "explicit ", // explicit constructor modifier
1740        "~",         // destructor (e.g. ~MyClass())
1741        "operator",  // operator overload (operator==, operator+, …)
1742    ],
1743    functions_prefix_paren: &[
1744        "void ",
1745        "bool ",
1746        "int ",
1747        "char ",
1748        "float ",
1749        "double ",
1750        "long ",
1751        "unsigned ",
1752        "size_t ",
1753        "auto ",
1754        "static ",
1755        "inline ",
1756        "constexpr ",
1757        "const ",
1758        "extern ",
1759    ],
1760    // `template<` (no space) is the dominant modern style alongside `template ` (with space).
1761    classes: &["class ", "struct ", "namespace ", "template ", "template<"],
1762    variables: &[],
1763    imports: &["#include "],
1764    tests: TEST_PATTERNS_C_CPP,
1765    assertions: ASSERT_PATTERNS_C_CPP,
1766    test_suites: SUITE_PATTERNS_C_CPP,
1767    // Mirror of functions_prefix_paren; complement paren guard splits variables from functions.
1768    variables_prefix_no_paren: &[
1769        "void ",
1770        "bool ",
1771        "int ",
1772        "char ",
1773        "float ",
1774        "double ",
1775        "long ",
1776        "unsigned ",
1777        "size_t ",
1778        "auto ",
1779        "static ",
1780        "inline ",
1781        "constexpr ",
1782        "const ",
1783        "extern ",
1784    ],
1785};
1786
1787const SP_SHELL: SymbolPatterns = SymbolPatterns {
1788    functions: &["function "],
1789    functions_prefix_paren: &[],
1790    classes: &[],
1791    variables: &["declare ", "local ", "export "],
1792    imports: &["source ", ". "],
1793    // Bats (Bash Automated Testing System): each case is a `@test "name" {` block.
1794    tests: &["@test "],
1795    assertions: &[],
1796    test_suites: &[],
1797    variables_prefix_no_paren: &[],
1798};
1799
1800const SP_POWERSHELL: SymbolPatterns = SymbolPatterns {
1801    functions: &["function ", "Function "],
1802    functions_prefix_paren: &[],
1803    classes: &["class "],
1804    variables: &[],
1805    imports: &["Import-Module ", "using "],
1806    // Pester test framework
1807    tests: &["Describe ", "It ", "Context "],
1808    assertions: &[],
1809    test_suites: &[],
1810    variables_prefix_no_paren: &[],
1811};
1812
1813const SP_KOTLIN: SymbolPatterns = SymbolPatterns {
1814    functions: &[
1815        "fun ",
1816        "private fun ",
1817        "public fun ",
1818        "protected fun ",
1819        "internal fun ",
1820        "override fun ",
1821        "suspend fun ",
1822        "abstract fun ",
1823        "open fun ",
1824        "private suspend fun ",
1825        "public suspend fun ",
1826    ],
1827    functions_prefix_paren: &[],
1828    classes: &[
1829        "class ",
1830        "data class ",
1831        "sealed class ",
1832        "abstract class ",
1833        "open class ",
1834        "object ",
1835        "companion object",
1836        "interface ",
1837        "enum class ",
1838        "annotation class ",
1839    ],
1840    variables: &["val ", "var ", "private val ", "private var ", "const val "],
1841    imports: &["import "],
1842    // JUnit 4/5, KotlinTest, Kotest
1843    tests: &[
1844        "@Test",
1845        "@ParameterizedTest",
1846        "@RepeatedTest",
1847        "\"should ",
1848        "\"it ",
1849    ],
1850    assertions: &[
1851        "assertEquals(",
1852        "assertNotEquals(",
1853        "assertTrue(",
1854        "assertFalse(",
1855        "assertNull(",
1856        "assertNotNull(",
1857        "assertThat(",
1858        "assertThrows(",
1859        "shouldBe(",
1860        "shouldNotBe(",
1861        "shouldThrow(",
1862    ],
1863    test_suites: &[],
1864    variables_prefix_no_paren: &[],
1865};
1866
1867const SP_SWIFT: SymbolPatterns = SymbolPatterns {
1868    functions: &[
1869        "func ",
1870        "private func ",
1871        "public func ",
1872        "internal func ",
1873        "override func ",
1874        "open func ",
1875        "static func ",
1876        "class func ",
1877        "mutating func ",
1878        "private static func ",
1879        "public static func ",
1880    ],
1881    functions_prefix_paren: &[],
1882    classes: &[
1883        "class ",
1884        "struct ",
1885        "protocol ",
1886        "enum ",
1887        "extension ",
1888        "actor ",
1889        "public class ",
1890        "private class ",
1891        "open class ",
1892        "final class ",
1893        "public struct ",
1894        "private struct ",
1895        "public protocol ",
1896    ],
1897    variables: &[
1898        "var ",
1899        "let ",
1900        "private var ",
1901        "private let ",
1902        "static var ",
1903        "static let ",
1904    ],
1905    imports: &["import "],
1906    // XCTest: test functions are named test* by convention; Swift Testing: @Test attribute
1907    tests: &["func test", "func Test", "@Test"],
1908    assertions: &[
1909        "XCTAssertEqual(",
1910        "XCTAssertNotEqual(",
1911        "XCTAssertTrue(",
1912        "XCTAssertFalse(",
1913        "XCTAssertNil(",
1914        "XCTAssertNotNil(",
1915        "XCTAssertGreaterThan(",
1916        "XCTAssertLessThan(",
1917        "XCTAssertThrowsError(",
1918        "XCTAssertNoThrow(",
1919        "#expect(",
1920    ],
1921    test_suites: &[],
1922    variables_prefix_no_paren: &[],
1923};
1924
1925const SP_RUBY: SymbolPatterns = SymbolPatterns {
1926    functions: &["def ", "private def ", "protected def "],
1927    functions_prefix_paren: &[],
1928    classes: &["class ", "module "],
1929    variables: &[],
1930    imports: &["require ", "require_relative "],
1931    // RSpec / minitest
1932    tests: &["it ", "it(", "describe ", "context ", "test "],
1933    assertions: &[],
1934    test_suites: &[],
1935    variables_prefix_no_paren: &[],
1936};
1937
1938const SP_SCALA: SymbolPatterns = SymbolPatterns {
1939    functions: &["def ", "private def ", "protected def ", "override def "],
1940    functions_prefix_paren: &[],
1941    classes: &[
1942        "class ",
1943        "case class ",
1944        "abstract class ",
1945        "sealed class ",
1946        "object ",
1947        "trait ",
1948    ],
1949    variables: &["val ", "var ", "lazy val "],
1950    imports: &["import "],
1951    // ScalaTest / MUnit: FunSuite test("..."), FlatSpec it("..."), AnyWordSpec "..." should
1952    tests: &["test(", "it(", "describe("],
1953    assertions: &[],
1954    test_suites: &[],
1955    variables_prefix_no_paren: &[],
1956};
1957
1958const SP_PHP: SymbolPatterns = SymbolPatterns {
1959    functions: &[
1960        "function ",
1961        "public function ",
1962        "private function ",
1963        "protected function ",
1964        "static function ",
1965        "abstract function ",
1966        "final function ",
1967        "public static function ",
1968        "private static function ",
1969        "protected static function ",
1970    ],
1971    functions_prefix_paren: &[],
1972    classes: &[
1973        "class ",
1974        "abstract class ",
1975        "final class ",
1976        "interface ",
1977        "trait ",
1978        "enum ",
1979    ],
1980    variables: &[],
1981    imports: &[
1982        "use ",
1983        "require ",
1984        "require_once ",
1985        "include ",
1986        "include_once ",
1987    ],
1988    // PHPUnit: test methods start with test, or use @test annotation
1989    tests: &[
1990        "public function test",
1991        "function test",
1992        "#[Test]",
1993        "#[DataProvider(",
1994    ],
1995    assertions: &[],
1996    test_suites: &[],
1997    variables_prefix_no_paren: &[],
1998};
1999
2000const SP_ELIXIR: SymbolPatterns = SymbolPatterns {
2001    functions: &[
2002        "def ",
2003        "defp ",
2004        "defmacro ",
2005        "defmacrop ",
2006        "defguard ",
2007        "defguardp ",
2008    ],
2009    functions_prefix_paren: &[],
2010    classes: &["defmodule ", "defprotocol ", "defimpl "],
2011    variables: &[],
2012    imports: &["import ", "alias ", "use ", "require "],
2013    // ExUnit
2014    tests: &["test ", "describe "],
2015    assertions: &[],
2016    test_suites: &[],
2017    variables_prefix_no_paren: &[],
2018};
2019
2020const SP_ERLANG: SymbolPatterns = SymbolPatterns {
2021    functions: &[],
2022    functions_prefix_paren: &[],
2023    classes: &["-module("],
2024    variables: &[],
2025    imports: &["-import(", "-include(", "-include_lib("],
2026    // EUnit: test names end in `_test`/`_test_` (suffix — not prefix-matchable), so we
2027    // only count the `?assert*` macro family, which is line-prefixable.
2028    tests: &[],
2029    assertions: &[
2030        "?assert(",
2031        "?assertEqual(",
2032        "?assertNotEqual(",
2033        "?assertMatch(",
2034        "?assertError(",
2035        "?assertThrow(",
2036        "?assertException(",
2037    ],
2038    test_suites: &[],
2039    variables_prefix_no_paren: &[],
2040};
2041
2042const SP_FSHARP: SymbolPatterns = SymbolPatterns {
2043    functions: &[
2044        "let ",
2045        "let rec ",
2046        "member ",
2047        "override ",
2048        "abstract member ",
2049    ],
2050    functions_prefix_paren: &[],
2051    classes: &["type "],
2052    variables: &["let mutable "],
2053    imports: &["open "],
2054    // NUnit / xUnit attributes on their own line; FsUnit uses [<Test>] / [<Fact>]
2055    tests: &["[<Test>]", "[<Fact>]", "[<Theory>]", "[<TestCase("],
2056    assertions: &[],
2057    test_suites: &[],
2058    variables_prefix_no_paren: &[],
2059};
2060
2061const SP_GROOVY: SymbolPatterns = SymbolPatterns {
2062    functions: &["def ", "private def ", "public def ", "protected def "],
2063    functions_prefix_paren: &[],
2064    classes: &["class ", "abstract class ", "interface ", "enum ", "trait "],
2065    variables: &[],
2066    imports: &["import "],
2067    // Spock framework: feature methods; JUnit annotations
2068    tests: &["def \"", "@Test", "given:", "when:", "then:", "expect:"],
2069    assertions: &[],
2070    test_suites: &[],
2071    variables_prefix_no_paren: &[],
2072};
2073
2074const SP_HASKELL: SymbolPatterns = SymbolPatterns {
2075    functions: &[],
2076    functions_prefix_paren: &[],
2077    classes: &["class ", "data ", "newtype ", "type "],
2078    variables: &[],
2079    imports: &["import "],
2080    // Hspec (describe/it) and QuickCheck (prop_) conventions. Hspec expectations
2081    // (`x `shouldBe` y`) are infix/mid-line, so they are not prefix-countable here.
2082    tests: &["describe ", "it ", "prop_"],
2083    assertions: &[],
2084    test_suites: &[],
2085    variables_prefix_no_paren: &[],
2086};
2087
2088const SP_LUA: SymbolPatterns = SymbolPatterns {
2089    functions: &["function ", "local function "],
2090    functions_prefix_paren: &[],
2091    classes: &[],
2092    variables: &["local "],
2093    imports: &[],
2094    // busted test framework
2095    tests: &["it(", "describe(", "pending("],
2096    assertions: &[],
2097    test_suites: &[],
2098    variables_prefix_no_paren: &[],
2099};
2100
2101const SP_NIM: SymbolPatterns = SymbolPatterns {
2102    functions: &[
2103        "proc ",
2104        "func ",
2105        "method ",
2106        "iterator ",
2107        "converter ",
2108        "template ",
2109        "macro ",
2110    ],
2111    functions_prefix_paren: &[],
2112    classes: &["type "],
2113    variables: &["var ", "let ", "const "],
2114    imports: &["import ", "from "],
2115    // unittest module
2116    tests: &["test "],
2117    assertions: &[],
2118    test_suites: &[],
2119    variables_prefix_no_paren: &[],
2120};
2121
2122const SP_OBJECTIVEC: SymbolPatterns = SymbolPatterns {
2123    functions: &["- (", "+ ("],
2124    functions_prefix_paren: &[],
2125    classes: &["@interface ", "@implementation ", "@protocol "],
2126    variables: &[],
2127    imports: &["#import ", "#include "],
2128    // XCTest: test methods start with - (void)test
2129    tests: &["- (void)test"],
2130    assertions: &[
2131        "XCTAssertEqual(",
2132        "XCTAssertNotEqual(",
2133        "XCTAssertTrue(",
2134        "XCTAssertFalse(",
2135        "XCTAssertNil(",
2136        "XCTAssertNotNil(",
2137        "XCTAssertGreaterThan(",
2138        "XCTAssertLessThan(",
2139        "XCTAssertThrowsError(",
2140        "XCTAssertNoThrow(",
2141    ],
2142    test_suites: &[],
2143    variables_prefix_no_paren: &[],
2144};
2145
2146const SP_OCAML: SymbolPatterns = SymbolPatterns {
2147    functions: &["let ", "let rec "],
2148    functions_prefix_paren: &[],
2149    classes: &["type ", "module ", "class "],
2150    variables: &[],
2151    imports: &["open "],
2152    // OUnit (`let test_... >:: `, `assert_*`) and Alcotest (`test_case`) conventions.
2153    tests: &["let test_", "test_case "],
2154    assertions: &[
2155        "assert_equal ",
2156        "assert_bool ",
2157        "assert_raises ",
2158        "assert_failure ",
2159        "OUnit.assert",
2160    ],
2161    test_suites: &[],
2162    variables_prefix_no_paren: &[],
2163};
2164
2165const SP_PERL: SymbolPatterns = SymbolPatterns {
2166    functions: &["sub "],
2167    functions_prefix_paren: &[],
2168    classes: &["package "],
2169    variables: &["my ", "our ", "local "],
2170    imports: &["use ", "require "],
2171    // Test::More / Test2: subtests group cases; ok/is/like/etc. are the assertions.
2172    tests: &["subtest "],
2173    assertions: &[
2174        "ok(",
2175        "is(",
2176        "isnt(",
2177        "like(",
2178        "unlike(",
2179        "cmp_ok(",
2180        "is_deeply(",
2181        "isa_ok(",
2182        "can_ok(",
2183    ],
2184    test_suites: &[],
2185    variables_prefix_no_paren: &[],
2186};
2187
2188const SP_CLOJURE: SymbolPatterns = SymbolPatterns {
2189    functions: &["(defn ", "(defn- ", "(defmacro ", "(defmulti "],
2190    functions_prefix_paren: &[],
2191    classes: &[
2192        "(defrecord ",
2193        "(defprotocol ",
2194        "(deftype ",
2195        "(definterface ",
2196    ],
2197    variables: &["(def ", "(defonce "],
2198    imports: &["(ns ", "(require "],
2199    // clojure.test
2200    tests: &["(deftest ", "(testing "],
2201    assertions: &[],
2202    test_suites: &[],
2203    variables_prefix_no_paren: &[],
2204};
2205
2206const SP_JULIA: SymbolPatterns = SymbolPatterns {
2207    functions: &["function ", "macro "],
2208    functions_prefix_paren: &[],
2209    classes: &[
2210        "struct ",
2211        "mutable struct ",
2212        "abstract type ",
2213        "primitive type ",
2214    ],
2215    variables: &["const "],
2216    imports: &["import ", "using "],
2217    // Test.jl standard library
2218    tests: &["@test ", "@testset "],
2219    assertions: &[],
2220    test_suites: &[],
2221    variables_prefix_no_paren: &[],
2222};
2223
2224const SP_DART: SymbolPatterns = SymbolPatterns {
2225    functions: &[],
2226    functions_prefix_paren: &[],
2227    classes: &["class ", "abstract class ", "mixin ", "extension ", "enum "],
2228    variables: &["var ", "final ", "const ", "late "],
2229    imports: &["import "],
2230    // flutter_test / test package
2231    tests: &["test(", "testWidgets(", "group("],
2232    assertions: &[],
2233    test_suites: &[],
2234    variables_prefix_no_paren: &[],
2235};
2236
2237const SP_R: SymbolPatterns = SymbolPatterns {
2238    functions: &[],
2239    functions_prefix_paren: &[],
2240    classes: &[],
2241    variables: &[],
2242    imports: &["library(", "source("],
2243    // testthat
2244    tests: &["test_that(", "it(", "describe(", "expect_"],
2245    assertions: &[],
2246    test_suites: &[],
2247    variables_prefix_no_paren: &[],
2248};
2249
2250const SP_SQL: SymbolPatterns = SymbolPatterns {
2251    functions: &[
2252        "create function ",
2253        "create or replace function ",
2254        "create procedure ",
2255        "create or replace procedure ",
2256        "CREATE FUNCTION ",
2257        "CREATE OR REPLACE FUNCTION ",
2258        "CREATE PROCEDURE ",
2259        "CREATE OR REPLACE PROCEDURE ",
2260    ],
2261    functions_prefix_paren: &[],
2262    classes: &[
2263        "create table ",
2264        "create view ",
2265        "create schema ",
2266        "CREATE TABLE ",
2267        "CREATE VIEW ",
2268        "CREATE SCHEMA ",
2269    ],
2270    variables: &["declare ", "DECLARE "],
2271    imports: &[],
2272    tests: &[],
2273    assertions: &[],
2274    test_suites: &[],
2275    variables_prefix_no_paren: &[],
2276};
2277
2278const SP_ASSEMBLY: SymbolPatterns = SymbolPatterns {
2279    functions: &["proc ", "PROC "],
2280    functions_prefix_paren: &[],
2281    classes: &[],
2282    variables: &[],
2283    imports: &["include ", "INCLUDE ", "%include "],
2284    tests: &[],
2285    assertions: &[],
2286    test_suites: &[],
2287    variables_prefix_no_paren: &[],
2288};
2289
2290const SP_ZIG: SymbolPatterns = SymbolPatterns {
2291    functions: &[
2292        "fn ",
2293        "pub fn ",
2294        "export fn ",
2295        "inline fn ",
2296        "pub inline fn ",
2297    ],
2298    functions_prefix_paren: &[],
2299    classes: &[],
2300    variables: &["var ", "pub var "],
2301    imports: &[],
2302    // Zig built-in test blocks
2303    tests: &["test \"", "test{"],
2304    assertions: &[],
2305    test_suites: &[],
2306    variables_prefix_no_paren: &[],
2307};
2308
2309/// Static (non-heap) language scanning parameters.  All fields are `'static` so this struct
2310/// can be stored in a `static` array.  The dynamic `skip_lines` set (used only for Python
2311/// docstring detection) is kept in `ScanConfig` and populated by the caller after lookup.
2312#[allow(clippy::struct_excessive_bools)]
2313#[derive(Clone, Copy)]
2314struct StaticLangConfig {
2315    line_comments: &'static [&'static str],
2316    block_comment: Option<(&'static str, &'static str)>,
2317    allow_single_quote_strings: bool,
2318    allow_double_quote_strings: bool,
2319    allow_triple_quote_strings: bool,
2320    allow_csharp_verbatim_strings: bool,
2321    /// `true` for Rust: `r"…"`, `r#"…"#`, `br#"…"#` raw strings where inner `"` do not close
2322    /// the literal. Prevents branch keywords in embedded templates (HTML/JS) from being counted.
2323    allow_raw_strings: bool,
2324    symbol_patterns: SymbolPatterns,
2325    /// `true` for C, C++, and Objective-C (languages that have a C preprocessor).
2326    has_preprocessor: bool,
2327}
2328
2329#[allow(clippy::struct_excessive_bools)]
2330#[derive(Debug, Clone)]
2331struct ScanConfig {
2332    line_comments: &'static [&'static str],
2333    block_comment: Option<(&'static str, &'static str)>,
2334    allow_single_quote_strings: bool,
2335    allow_double_quote_strings: bool,
2336    allow_triple_quote_strings: bool,
2337    allow_csharp_verbatim_strings: bool,
2338    allow_raw_strings: bool,
2339    skip_lines: HashSet<usize>,
2340    symbol_patterns: SymbolPatterns,
2341    /// Branch keywords used to approximate cyclomatic complexity.
2342    branch_keywords: &'static [&'static str],
2343    /// Strategy for computing Logical SLOC.
2344    lsloc_strategy: LslocStrategy,
2345}
2346
2347// ── Per-family base configurations ───────────────────────────────────────────
2348//
2349// Most languages share one of two comment styles.  Define a base `const` for
2350// each family; table entries override only the fields that differ (symbol
2351// patterns, preprocessor flag, verbatim-string flag, etc.).
2352//
2353// C-slash family: `//` line, `/* */` block, single + double quotes.
2354// Covers C, C++, Obj-C, C#, Go, Java, JS/TS/Svelte/Vue, Dart, Groovy, Kotlin,
2355// Scala, SCSS, Swift, Rust, and Zig (Zig has no block comment → overridden).
2356const C_SLASH_BASE: StaticLangConfig = StaticLangConfig {
2357    line_comments: &["//"],
2358    block_comment: Some(("/*", "*/")),
2359    allow_single_quote_strings: true,
2360    allow_double_quote_strings: true,
2361    allow_triple_quote_strings: false,
2362    allow_csharp_verbatim_strings: false,
2363    allow_raw_strings: false,
2364    symbol_patterns: SP_NONE,
2365    has_preprocessor: false,
2366};
2367
2368// Hash-comment family: `#` line comment, no block comment, single + double
2369// quotes.  Covers Shell, Ruby, R, Perl, Elixir (each overrides only SP_*);
2370// Python overrides triple-quote; PowerShell and Nim override block_comment.
2371const HASH_BASE: StaticLangConfig = StaticLangConfig {
2372    line_comments: &["#"],
2373    block_comment: None,
2374    allow_single_quote_strings: true,
2375    allow_double_quote_strings: true,
2376    allow_triple_quote_strings: false,
2377    allow_csharp_verbatim_strings: false,
2378    allow_raw_strings: false,
2379    symbol_patterns: SP_NONE,
2380    has_preprocessor: false,
2381};
2382
2383/// Static language-scan configuration table — one entry per supported language.
2384/// Used by `language_scan_config` to avoid a 41-arm match.  All `SP_*` constants
2385/// referenced here are defined above in the same module.
2386static LANG_SCAN_TABLE: &[(Language, StaticLangConfig)] = &[
2387    // ── C preprocessor family ─────────────────────────────────────────────────
2388    (
2389        Language::C,
2390        StaticLangConfig {
2391            symbol_patterns: SP_C,
2392            has_preprocessor: true,
2393            ..C_SLASH_BASE
2394        },
2395    ),
2396    (
2397        Language::Cpp,
2398        StaticLangConfig {
2399            symbol_patterns: SP_CPP,
2400            has_preprocessor: true,
2401            ..C_SLASH_BASE
2402        },
2403    ),
2404    (
2405        Language::ObjectiveC,
2406        StaticLangConfig {
2407            symbol_patterns: SP_OBJECTIVEC,
2408            has_preprocessor: true,
2409            ..C_SLASH_BASE
2410        },
2411    ),
2412    // ── C-slash family ────────────────────────────────────────────────────────
2413    (
2414        Language::CSharp,
2415        StaticLangConfig {
2416            symbol_patterns: SP_CSHARP,
2417            allow_csharp_verbatim_strings: true,
2418            ..C_SLASH_BASE
2419        },
2420    ),
2421    (
2422        Language::Go,
2423        StaticLangConfig {
2424            symbol_patterns: SP_GO,
2425            ..C_SLASH_BASE
2426        },
2427    ),
2428    (
2429        Language::Java,
2430        StaticLangConfig {
2431            symbol_patterns: SP_JAVA,
2432            ..C_SLASH_BASE
2433        },
2434    ),
2435    (
2436        Language::JavaScript,
2437        StaticLangConfig {
2438            symbol_patterns: SP_JS,
2439            ..C_SLASH_BASE
2440        },
2441    ),
2442    (
2443        Language::TypeScript,
2444        StaticLangConfig {
2445            symbol_patterns: SP_TS,
2446            ..C_SLASH_BASE
2447        },
2448    ),
2449    (
2450        Language::Svelte,
2451        StaticLangConfig {
2452            symbol_patterns: SP_JS,
2453            ..C_SLASH_BASE
2454        },
2455    ),
2456    (
2457        Language::Vue,
2458        StaticLangConfig {
2459            symbol_patterns: SP_JS,
2460            ..C_SLASH_BASE
2461        },
2462    ),
2463    (
2464        Language::Dart,
2465        StaticLangConfig {
2466            symbol_patterns: SP_DART,
2467            ..C_SLASH_BASE
2468        },
2469    ),
2470    (
2471        Language::Groovy,
2472        StaticLangConfig {
2473            symbol_patterns: SP_GROOVY,
2474            ..C_SLASH_BASE
2475        },
2476    ),
2477    (
2478        Language::Kotlin,
2479        StaticLangConfig {
2480            symbol_patterns: SP_KOTLIN,
2481            ..C_SLASH_BASE
2482        },
2483    ),
2484    (
2485        Language::Scala,
2486        StaticLangConfig {
2487            symbol_patterns: SP_SCALA,
2488            ..C_SLASH_BASE
2489        },
2490    ),
2491    (
2492        Language::Scss,
2493        StaticLangConfig {
2494            symbol_patterns: SP_NONE,
2495            ..C_SLASH_BASE
2496        },
2497    ),
2498    // Rust: no single-quote char literals (they're lifetime annotations)
2499    (
2500        Language::Rust,
2501        StaticLangConfig {
2502            symbol_patterns: SP_RUST,
2503            allow_single_quote_strings: false,
2504            allow_raw_strings: true,
2505            ..C_SLASH_BASE
2506        },
2507    ),
2508    // Swift: no single-quote strings
2509    (
2510        Language::Swift,
2511        StaticLangConfig {
2512            symbol_patterns: SP_SWIFT,
2513            allow_single_quote_strings: false,
2514            ..C_SLASH_BASE
2515        },
2516    ),
2517    // Zig: no block comment
2518    (
2519        Language::Zig,
2520        StaticLangConfig {
2521            symbol_patterns: SP_ZIG,
2522            block_comment: None,
2523            ..C_SLASH_BASE
2524        },
2525    ),
2526    // F#: `(*` … `*)` block comment, no single-quote strings
2527    (
2528        Language::FSharp,
2529        StaticLangConfig {
2530            line_comments: &["//"],
2531            block_comment: Some(("(*", "*)")),
2532            allow_single_quote_strings: false,
2533            allow_double_quote_strings: true,
2534            symbol_patterns: SP_FSHARP,
2535            ..C_SLASH_BASE
2536        },
2537    ),
2538    // ── Hash-comment family ───────────────────────────────────────────────────
2539    (
2540        Language::Shell,
2541        StaticLangConfig {
2542            symbol_patterns: SP_SHELL,
2543            ..HASH_BASE
2544        },
2545    ),
2546    (
2547        Language::Elixir,
2548        StaticLangConfig {
2549            symbol_patterns: SP_ELIXIR,
2550            ..HASH_BASE
2551        },
2552    ),
2553    (
2554        Language::Perl,
2555        StaticLangConfig {
2556            symbol_patterns: SP_PERL,
2557            ..HASH_BASE
2558        },
2559    ),
2560    (
2561        Language::R,
2562        StaticLangConfig {
2563            symbol_patterns: SP_R,
2564            ..HASH_BASE
2565        },
2566    ),
2567    (
2568        Language::Ruby,
2569        StaticLangConfig {
2570            symbol_patterns: SP_RUBY,
2571            ..HASH_BASE
2572        },
2573    ),
2574    // Python: triple-quote string literals
2575    (
2576        Language::Python,
2577        StaticLangConfig {
2578            symbol_patterns: SP_PYTHON,
2579            allow_triple_quote_strings: true,
2580            ..HASH_BASE
2581        },
2582    ),
2583    // PowerShell: `<# … #>` block comment
2584    (
2585        Language::PowerShell,
2586        StaticLangConfig {
2587            symbol_patterns: SP_POWERSHELL,
2588            block_comment: Some(("<#", "#>")),
2589            ..HASH_BASE
2590        },
2591    ),
2592    // Nim: `#[` … `]#` block comment
2593    (
2594        Language::Nim,
2595        StaticLangConfig {
2596            symbol_patterns: SP_NIM,
2597            block_comment: Some(("#[", "]#")),
2598            ..HASH_BASE
2599        },
2600    ),
2601    // Makefile / Dockerfile: `#` only, no string literals
2602    (
2603        Language::Makefile,
2604        StaticLangConfig {
2605            symbol_patterns: SP_NONE,
2606            allow_single_quote_strings: false,
2607            allow_double_quote_strings: false,
2608            ..HASH_BASE
2609        },
2610    ),
2611    (
2612        Language::Dockerfile,
2613        StaticLangConfig {
2614            symbol_patterns: SP_NONE,
2615            allow_single_quote_strings: false,
2616            allow_double_quote_strings: false,
2617            ..HASH_BASE
2618        },
2619    ),
2620    // ── Other unique comment styles ───────────────────────────────────────────
2621    // CSS / SCSS: only `/* */` block, no line comment
2622    (
2623        Language::Css,
2624        StaticLangConfig {
2625            line_comments: &[],
2626            block_comment: Some(("/*", "*/")),
2627            symbol_patterns: SP_NONE,
2628            ..C_SLASH_BASE
2629        },
2630    ),
2631    // HTML / XML: `<!-- -->` block, no line comment, no string literals
2632    (
2633        Language::Html,
2634        StaticLangConfig {
2635            line_comments: &[],
2636            block_comment: Some(("<!--", "-->")),
2637            allow_single_quote_strings: false,
2638            allow_double_quote_strings: false,
2639            symbol_patterns: SP_NONE,
2640            ..C_SLASH_BASE
2641        },
2642    ),
2643    (
2644        Language::Xml,
2645        StaticLangConfig {
2646            line_comments: &[],
2647            block_comment: Some(("<!--", "-->")),
2648            allow_single_quote_strings: false,
2649            allow_double_quote_strings: false,
2650            symbol_patterns: SP_NONE,
2651            ..C_SLASH_BASE
2652        },
2653    ),
2654    // Lua: `--` line, `--[[ ]]` block
2655    (
2656        Language::Lua,
2657        StaticLangConfig {
2658            line_comments: &["--"],
2659            block_comment: Some(("--[[", "]]")),
2660            symbol_patterns: SP_LUA,
2661            ..C_SLASH_BASE
2662        },
2663    ),
2664    // Haskell: `--` line, `{- -}` block
2665    (
2666        Language::Haskell,
2667        StaticLangConfig {
2668            line_comments: &["--"],
2669            block_comment: Some(("{-", "-}")),
2670            symbol_patterns: SP_HASKELL,
2671            ..C_SLASH_BASE
2672        },
2673    ),
2674    // SQL: `--` line, `/* */` block, single quote only
2675    (
2676        Language::Sql,
2677        StaticLangConfig {
2678            line_comments: &["--"],
2679            block_comment: Some(("/*", "*/")),
2680            allow_single_quote_strings: true,
2681            allow_double_quote_strings: false,
2682            symbol_patterns: SP_SQL,
2683            ..C_SLASH_BASE
2684        },
2685    ),
2686    // OCaml: `(*` … `*)` only, no line comment, no single-quote strings
2687    (
2688        Language::Ocaml,
2689        StaticLangConfig {
2690            line_comments: &[],
2691            block_comment: Some(("(*", "*)")),
2692            allow_single_quote_strings: false,
2693            symbol_patterns: SP_OCAML,
2694            ..C_SLASH_BASE
2695        },
2696    ),
2697    // Assembly: `;` line comment (NASM/MASM) + `/* */` block (GAS), double-quote
2698    // strings for `.ascii`/`.string` directives. `#` (GAS x86) and `@` (ARM) line
2699    // comments are intentionally NOT added: `#` is an immediate prefix in ARM
2700    // (`mov r0, #5`) and `@` appears in x86 symbol versioning (`memcpy@plt`), so a
2701    // universal superset would mis-count one dialect or the other.
2702    (
2703        Language::Assembly,
2704        StaticLangConfig {
2705            line_comments: &[";"],
2706            block_comment: Some(("/*", "*/")),
2707            allow_single_quote_strings: false,
2708            allow_double_quote_strings: true,
2709            symbol_patterns: SP_ASSEMBLY,
2710            ..C_SLASH_BASE
2711        },
2712    ),
2713    (
2714        Language::Clojure,
2715        StaticLangConfig {
2716            line_comments: &[";"],
2717            block_comment: None,
2718            allow_single_quote_strings: false,
2719            symbol_patterns: SP_CLOJURE,
2720            ..C_SLASH_BASE
2721        },
2722    ),
2723    // Erlang: `%` line comment, no block, no single-quote strings
2724    (
2725        Language::Erlang,
2726        StaticLangConfig {
2727            line_comments: &["%"],
2728            block_comment: None,
2729            allow_single_quote_strings: false,
2730            symbol_patterns: SP_ERLANG,
2731            ..C_SLASH_BASE
2732        },
2733    ),
2734    // PHP: `//` or `#` line, `/* */` block
2735    (
2736        Language::Php,
2737        StaticLangConfig {
2738            line_comments: &["//", "#"],
2739            block_comment: Some(("/*", "*/")),
2740            symbol_patterns: SP_PHP,
2741            ..C_SLASH_BASE
2742        },
2743    ),
2744    // Julia: `#` line, `#= =#` block, double + triple quotes, no single
2745    (
2746        Language::Julia,
2747        StaticLangConfig {
2748            line_comments: &["#"],
2749            block_comment: Some(("#=", "=#")),
2750            allow_single_quote_strings: false,
2751            allow_triple_quote_strings: true,
2752            symbol_patterns: SP_JULIA,
2753            ..C_SLASH_BASE
2754        },
2755    ),
2756    // ── Pass 1 additions ──────────────────────────────────────────────────────
2757    // Solidity: C-slash family (`//`, `/* */`, single + double quotes).
2758    (
2759        Language::Solidity,
2760        StaticLangConfig {
2761            symbol_patterns: SP_SOLIDITY,
2762            ..C_SLASH_BASE
2763        },
2764    ),
2765    // Protocol Buffers: C-slash family, statements terminated by `;`.
2766    (
2767        Language::Protobuf,
2768        StaticLangConfig {
2769            symbol_patterns: SP_PROTOBUF,
2770            ..C_SLASH_BASE
2771        },
2772    ),
2773    // HCL / Terraform: `#` or `//` line, `/* */` block, double-quote strings only.
2774    (
2775        Language::Hcl,
2776        StaticLangConfig {
2777            line_comments: &["#", "//"],
2778            allow_single_quote_strings: false,
2779            symbol_patterns: SP_NONE,
2780            ..C_SLASH_BASE
2781        },
2782    ),
2783    // GraphQL: `#` line comment, no block; `"""` block-string descriptions, no single quotes.
2784    (
2785        Language::GraphQl,
2786        StaticLangConfig {
2787            allow_single_quote_strings: false,
2788            allow_triple_quote_strings: true,
2789            symbol_patterns: SP_NONE,
2790            ..HASH_BASE
2791        },
2792    ),
2793    // ── Pass 2 additions (legacy + embedded / HDL) ────────────────────────────
2794    // Ada: `--` line comment, no block; `'` is a char/attribute tick, not a string.
2795    (
2796        Language::Ada,
2797        StaticLangConfig {
2798            line_comments: &["--"],
2799            block_comment: None,
2800            allow_single_quote_strings: false,
2801            symbol_patterns: SP_ADA,
2802            ..C_SLASH_BASE
2803        },
2804    ),
2805    // VHDL: `--` line comment, no block; `'` is a bit/char literal, not a string.
2806    (
2807        Language::Vhdl,
2808        StaticLangConfig {
2809            line_comments: &["--"],
2810            block_comment: None,
2811            allow_single_quote_strings: false,
2812            symbol_patterns: SP_VHDL,
2813            ..C_SLASH_BASE
2814        },
2815    ),
2816    // Verilog / SystemVerilog: C-slash family; `'` is a sized-literal base, not a string.
2817    (
2818        Language::Verilog,
2819        StaticLangConfig {
2820            allow_single_quote_strings: false,
2821            symbol_patterns: SP_VERILOG,
2822            ..C_SLASH_BASE
2823        },
2824    ),
2825    // Tcl: `#` line comment, no block; `"` strings only.
2826    (
2827        Language::Tcl,
2828        StaticLangConfig {
2829            allow_single_quote_strings: false,
2830            symbol_patterns: SP_TCL,
2831            ..HASH_BASE
2832        },
2833    ),
2834    // Pascal / Delphi: `//` line, `{ }` block; strings are single-quoted.
2835    (
2836        Language::Pascal,
2837        StaticLangConfig {
2838            line_comments: &["//"],
2839            block_comment: Some(("{", "}")),
2840            allow_single_quote_strings: true,
2841            allow_double_quote_strings: false,
2842            symbol_patterns: SP_PASCAL,
2843            ..C_SLASH_BASE
2844        },
2845    ),
2846    // Visual Basic: `'` line comment, no block; `"` strings only.
2847    (
2848        Language::VisualBasic,
2849        StaticLangConfig {
2850            line_comments: &["'"],
2851            block_comment: None,
2852            allow_single_quote_strings: false,
2853            allow_double_quote_strings: true,
2854            symbol_patterns: SP_VB,
2855            ..C_SLASH_BASE
2856        },
2857    ),
2858    // Lisp / Scheme: `;` line comment, `#| |#` block; `"` strings, `'` is the quote operator.
2859    (
2860        Language::Lisp,
2861        StaticLangConfig {
2862            line_comments: &[";"],
2863            block_comment: Some(("#|", "|#")),
2864            allow_single_quote_strings: false,
2865            symbol_patterns: SP_LISP,
2866            ..C_SLASH_BASE
2867        },
2868    ),
2869    // ── Pass 3 additions (scientific / infra / systems / graphics) ────────────
2870    // Fortran: `!` line comment (free-form), no block; single + double strings.
2871    (
2872        Language::Fortran,
2873        StaticLangConfig {
2874            line_comments: &["!"],
2875            block_comment: None,
2876            symbol_patterns: SP_FORTRAN,
2877            ..C_SLASH_BASE
2878        },
2879    ),
2880    // Nix: `#` line, `/* */` block; double-quote strings (and `''` multi-line).
2881    (
2882        Language::Nix,
2883        StaticLangConfig {
2884            block_comment: Some(("/*", "*/")),
2885            allow_single_quote_strings: false,
2886            symbol_patterns: SP_NONE,
2887            ..HASH_BASE
2888        },
2889    ),
2890    // Crystal: `#` line comment, no block; Ruby-like single + double strings.
2891    (
2892        Language::Crystal,
2893        StaticLangConfig {
2894            symbol_patterns: SP_CRYSTAL,
2895            ..HASH_BASE
2896        },
2897    ),
2898    // D: C-slash family (`//`, `/* */`); single-quote char literals + double strings.
2899    (
2900        Language::D,
2901        StaticLangConfig {
2902            symbol_patterns: SP_D,
2903            ..C_SLASH_BASE
2904        },
2905    ),
2906    // GLSL / HLSL / WGSL shaders: C-slash family; no char literals.
2907    (
2908        Language::Glsl,
2909        StaticLangConfig {
2910            allow_single_quote_strings: false,
2911            symbol_patterns: SP_NONE,
2912            ..C_SLASH_BASE
2913        },
2914    ),
2915    // CMake: `#` line, `#[[ ]]` block; double-quote strings only.
2916    (
2917        Language::Cmake,
2918        StaticLangConfig {
2919            block_comment: Some(("#[[", "]]")),
2920            allow_single_quote_strings: false,
2921            symbol_patterns: SP_CMAKE,
2922            ..HASH_BASE
2923        },
2924    ),
2925    // Elm: `--` line, `{- -}` block; double-quote strings only.
2926    (
2927        Language::Elm,
2928        StaticLangConfig {
2929            line_comments: &["--"],
2930            block_comment: Some(("{-", "-}")),
2931            allow_single_quote_strings: false,
2932            symbol_patterns: SP_ELM,
2933            ..C_SLASH_BASE
2934        },
2935    ),
2936    // Awk: `#` line comment, no block; double-quote strings only.
2937    (
2938        Language::Awk,
2939        StaticLangConfig {
2940            allow_single_quote_strings: false,
2941            symbol_patterns: SP_AWK,
2942            ..HASH_BASE
2943        },
2944    ),
2945];
2946
2947/// Per-call IEEE 1045-1992 flags derived from `AnalysisOptions` plus per-language properties.
2948/// Private to this crate; constructed inside `analyze_text`.
2949#[derive(Debug, Clone, Copy)]
2950struct IeeeFlags {
2951    /// True for C, C++, and Objective-C — languages with a C preprocessor.
2952    has_preprocessor_directives: bool,
2953    /// Mirrors `AnalysisOptions::blank_in_block_comment_as_comment`.
2954    blank_in_block_comment_as_comment: bool,
2955    /// Mirrors `AnalysisOptions::collapse_continuation_lines`.
2956    collapse_continuation_lines: bool,
2957}
2958
2959#[derive(Debug, Clone, Copy)]
2960enum StringState {
2961    Single(char),
2962    Triple(&'static str),
2963    VerbatimDouble,
2964    /// Rust raw string `r#…"…"#…` with the given number of `#` hashes. Closed only by a `"`
2965    /// followed by exactly that many `#`; inner `"` and `\` are literal (no escaping).
2966    RawHash(usize),
2967}
2968
2969#[allow(clippy::struct_excessive_bools)]
2970#[derive(Debug, Default)]
2971struct LineFacts {
2972    has_code: bool,
2973    has_single_comment: bool,
2974    has_multi_comment: bool,
2975    has_docstring: bool,
2976}
2977
2978/// Process one character while the lexer is inside a string literal.
2979///
2980/// Returns `(new_string_state, advance)` where `advance` is the number of chars to skip.
2981fn process_string_char(
2982    state: StringState,
2983    chars: &[char],
2984    i: usize,
2985) -> (Option<StringState>, usize) {
2986    match state {
2987        StringState::Single(delim) => step_single(state, delim, chars, i),
2988        StringState::Triple(delim) => step_triple(state, delim, chars, i),
2989        StringState::VerbatimDouble => step_verbatim(state, chars, i),
2990        StringState::RawHash(hashes) => step_raw_hash(state, hashes, chars, i),
2991    }
2992}
2993
2994/// One step inside a single-char-delimited string (`'…'` or `"…"`), honouring `\` escapes.
2995fn step_single(
2996    state: StringState,
2997    delim: char,
2998    chars: &[char],
2999    i: usize,
3000) -> (Option<StringState>, usize) {
3001    if chars[i] == '\\' {
3002        return (Some(state), 2); // skip escaped character
3003    }
3004    if chars[i] == delim {
3005        (None, 1)
3006    } else {
3007        (Some(state), 1)
3008    }
3009}
3010
3011/// One step inside a triple-quoted string (`"""…"""` / `'''…'''`).
3012fn step_triple(
3013    state: StringState,
3014    delim: &'static str,
3015    chars: &[char],
3016    i: usize,
3017) -> (Option<StringState>, usize) {
3018    if starts_with(chars, i, delim) {
3019        (None, delim.len())
3020    } else {
3021        (Some(state), 1)
3022    }
3023}
3024
3025/// One step inside a C# verbatim string (`@"…"`), where `""` is an escaped quote.
3026fn step_verbatim(state: StringState, chars: &[char], i: usize) -> (Option<StringState>, usize) {
3027    if starts_with(chars, i, "\"\"") {
3028        return (Some(state), 2); // escaped quote-quote inside verbatim string
3029    }
3030    if chars[i] == '"' {
3031        (None, 1)
3032    } else {
3033        (Some(state), 1)
3034    }
3035}
3036
3037/// One step inside a Rust raw string (`r#"…"#`); closes on `"` + at least `hashes` `#`, no escapes.
3038fn step_raw_hash(
3039    state: StringState,
3040    hashes: usize,
3041    chars: &[char],
3042    i: usize,
3043) -> (Option<StringState>, usize) {
3044    if chars[i] == '"' && count_leading_hashes(chars, i + 1) >= hashes {
3045        (None, 1 + hashes)
3046    } else {
3047        (Some(state), 1)
3048    }
3049}
3050
3051/// Count consecutive `#` characters starting at `index`.
3052fn count_leading_hashes(chars: &[char], index: usize) -> usize {
3053    let mut n = 0;
3054    while chars.get(index + n) == Some(&'#') {
3055        n += 1;
3056    }
3057    n
3058}
3059
3060/// Detect a Rust raw-string opener at `i`: optional `b`, `r`, zero or more `#`, then `"`.
3061///
3062/// Returns `Some((hashes, advance))` where `advance` is the opener length. Requires a preceding
3063/// non-word boundary so `r`/`br` inside an identifier is not misread as a raw string.
3064fn try_open_raw_string(chars: &[char], i: usize) -> Option<(usize, usize)> {
3065    let word_before = i
3066        .checked_sub(1)
3067        .and_then(|p| chars.get(p))
3068        .is_some_and(|c| c.is_alphanumeric() || *c == '_');
3069    if word_before {
3070        return None;
3071    }
3072    let mut j = i;
3073    if chars.get(j) == Some(&'b') {
3074        j += 1; // byte raw string: br"…"
3075    }
3076    if chars.get(j) != Some(&'r') {
3077        return None;
3078    }
3079    j += 1;
3080    let hashes = count_leading_hashes(chars, j);
3081    j += hashes;
3082    if chars.get(j) != Some(&'"') {
3083        return None;
3084    }
3085    Some((hashes, j + 1 - i))
3086}
3087
3088/// Process one character while the lexer is inside a block comment.
3089///
3090/// Returns `(still_in_block_comment, advance)`.
3091fn process_block_comment_char(chars: &[char], i: usize, close: &str) -> (bool, usize) {
3092    if starts_with(chars, i, close) {
3093        (false, close.len())
3094    } else {
3095        (true, 1)
3096    }
3097}
3098
3099/// Attempt to begin a new string literal at position `i`.
3100///
3101/// Returns `Some((new_state, advance))` when a string opener is detected, else `None`.
3102fn try_open_string(chars: &[char], i: usize, config: &ScanConfig) -> Option<(StringState, usize)> {
3103    if config.allow_raw_strings
3104        && let Some((hashes, advance)) = try_open_raw_string(chars, i)
3105    {
3106        return Some((StringState::RawHash(hashes), advance));
3107    }
3108    if config.allow_csharp_verbatim_strings && starts_with(chars, i, "@\"") {
3109        return Some((StringState::VerbatimDouble, 2));
3110    }
3111    if config.allow_triple_quote_strings {
3112        if starts_with(chars, i, "\"\"\"") {
3113            return Some((StringState::Triple("\"\"\""), 3));
3114        }
3115        if starts_with(chars, i, "'''") {
3116            return Some((StringState::Triple("'''"), 3));
3117        }
3118    }
3119    if config.allow_single_quote_strings && chars[i] == '\'' {
3120        return Some((StringState::Single('\''), 1));
3121    }
3122    if config.allow_double_quote_strings && chars[i] == '"' {
3123        return Some((StringState::Single('"'), 1));
3124    }
3125    None
3126}
3127
3128/// Advance past one character position while inside a block comment.
3129///
3130/// Updates `in_block_comment` if the closing delimiter is found and returns the
3131/// number of characters consumed. Returns 0 when no block-comment config is set
3132/// (preserving the caller's `continue`-without-advance behaviour for that impossible state).
3133fn step_through_block_comment(
3134    chars: &[char],
3135    i: usize,
3136    block_comment: Option<(&'static str, &'static str)>,
3137    in_block_comment: &mut bool,
3138) -> usize {
3139    if let Some((_, close)) = block_comment {
3140        let (still_in, advance) = process_block_comment_char(chars, i, close);
3141        *in_block_comment = still_in;
3142        return advance;
3143    }
3144    0
3145}
3146
3147/// If the character at `i` starts a block comment, return the length of the opening
3148/// delimiter so the caller can advance past it. Returns `None` if no match.
3149fn try_open_block_comment(
3150    chars: &[char],
3151    i: usize,
3152    block_comment: Option<(&'static str, &'static str)>,
3153) -> Option<usize> {
3154    let (open, _) = block_comment?;
3155    starts_with(chars, i, open).then_some(open.len())
3156}
3157
3158/// When the scanner is already inside a string literal or block comment, consume the character at
3159/// `i`, update the running state/mask, and return how many chars were advanced. Returns `None`
3160/// when the scanner is not currently inside any such span.
3161fn advance_inside_span(
3162    chars: &[char],
3163    i: usize,
3164    config: &ScanConfig,
3165    facts: &mut LineFacts,
3166    in_block_comment: &mut bool,
3167    string_state: &mut Option<StringState>,
3168    code: &mut Vec<u8>,
3169) -> Option<usize> {
3170    // Inside a string literal — string content is not code, so blank it out of the mask.
3171    if let Some(state) = *string_state {
3172        facts.has_code = true;
3173        let (new_state, advance) = process_string_char(state, chars, i);
3174        *string_state = new_state;
3175        blank_mask(code, advance);
3176        return Some(advance);
3177    }
3178
3179    // Inside a block comment — advance until the closing delimiter.
3180    if *in_block_comment {
3181        facts.has_multi_comment = true;
3182        let advance = step_through_block_comment(chars, i, config.block_comment, in_block_comment);
3183        blank_mask(code, advance);
3184        return Some(advance);
3185    }
3186
3187    None
3188}
3189
3190/// Scan a single physical line and update `facts`, `in_block_comment`, and `string_state`.
3191///
3192/// Returns `true` when the caller should break out of the per-line loop early (line comment hit).
3193fn scan_line(
3194    chars: &[char],
3195    config: &ScanConfig,
3196    facts: &mut LineFacts,
3197    in_block_comment: &mut bool,
3198    string_state: &mut Option<StringState>,
3199    code: &mut Vec<u8>,
3200) {
3201    let mut i = 0usize;
3202    while i < chars.len() {
3203        // Already inside a string literal or block comment — advance until its closing delimiter.
3204        if let Some(advance) = advance_inside_span(
3205            chars,
3206            i,
3207            config,
3208            facts,
3209            in_block_comment,
3210            string_state,
3211            code,
3212        ) {
3213            i += advance;
3214            continue;
3215        }
3216
3217        // Whitespace outside any string/comment — preserve as a boundary in the mask.
3218        if chars[i].is_whitespace() {
3219            code.push(b' ');
3220            i += 1;
3221            continue;
3222        }
3223
3224        // Attempt to open a string literal — the opening delimiter is not code.
3225        if let Some((new_state, advance)) = try_open_string(chars, i, config) {
3226            facts.has_code = true;
3227            *string_state = Some(new_state);
3228            blank_mask(code, advance);
3229            i += advance;
3230            continue;
3231        }
3232
3233        // Attempt to open a block comment.
3234        if let Some(advance) = try_open_block_comment(chars, i, config.block_comment) {
3235            facts.has_multi_comment = true;
3236            *in_block_comment = true;
3237            blank_mask(code, advance);
3238            i += advance;
3239            continue;
3240        }
3241
3242        // Line comment — rest of the line is a comment; stop scanning.
3243        if config
3244            .line_comments
3245            .iter()
3246            .any(|prefix| starts_with(chars, i, prefix))
3247        {
3248            facts.has_single_comment = true;
3249            break;
3250        }
3251
3252        // Plain code character — copy it into the mask (ASCII bytes only; branch keywords are
3253        // ASCII, so non-ASCII code is blanked without affecting the count).
3254        facts.has_code = true;
3255        let ch = chars[i];
3256        code.push(if ch.is_ascii() { ch as u8 } else { b' ' });
3257        i += 1;
3258    }
3259}
3260
3261/// Append `n` blank (space) bytes to the code mask, preserving positions/word boundaries while
3262/// excluding non-code (string/comment) regions from branch counting.
3263fn blank_mask(code: &mut Vec<u8>, n: usize) {
3264    code.resize(code.len() + n, b' ');
3265}
3266
3267/// Apply IEEE 1045-1992 §4.2 preprocessor-directive tracking and continuation-line merging,
3268/// then emit the finalized `LineFacts` for this physical line.
3269///
3270/// Returns `None` when the line is part of a continuation sequence and should be deferred.
3271fn finalize_line_facts(
3272    facts: LineFacts,
3273    trimmed: &str,
3274    raw: &mut RawLineCounts,
3275    ieee: IeeeFlags,
3276    in_block_comment: bool,
3277    string_state: Option<StringState>,
3278    pending_continuation: &mut Option<LineFacts>,
3279) -> Option<LineFacts> {
3280    // IEEE 1045-1992 §4.2: track preprocessor/compiler directive lines (C/C++/ObjC).
3281    // A directive line is a pure code line (no comment on the same physical line) whose
3282    // trimmed content starts with '#'.
3283    if ieee.has_preprocessor_directives
3284        && facts.has_code
3285        && !facts.has_single_comment
3286        && !facts.has_multi_comment
3287        && trimmed.starts_with('#')
3288    {
3289        raw.compiler_directive_lines += 1;
3290    }
3291
3292    // IEEE 1045-1992 continuation-line handling.
3293    // A line is a continuation starter when it ends with '\' outside any comment or string.
3294    let is_continuation = ieee.collapse_continuation_lines
3295        && !in_block_comment
3296        && string_state.is_none()
3297        && trimmed.ends_with('\\');
3298
3299    if is_continuation {
3300        let pending = pending_continuation.get_or_insert_with(LineFacts::default);
3301        pending.has_code |= facts.has_code;
3302        pending.has_single_comment |= facts.has_single_comment;
3303        pending.has_multi_comment |= facts.has_multi_comment;
3304        pending.has_docstring |= facts.has_docstring;
3305        return None; // defer classification until the sequence ends
3306    }
3307
3308    // Merge any accumulated continuation facts into the final line.
3309    let emit = if let Some(pending) = pending_continuation.take() {
3310        LineFacts {
3311            has_code: pending.has_code | facts.has_code,
3312            has_single_comment: pending.has_single_comment | facts.has_single_comment,
3313            has_multi_comment: pending.has_multi_comment | facts.has_multi_comment,
3314            has_docstring: pending.has_docstring | facts.has_docstring,
3315        }
3316    } else {
3317        facts
3318    };
3319    Some(emit)
3320}
3321
3322/// Scan and classify one physical line, updating all running state in place.
3323///
3324/// Pre-classified lines (present in `config.skip_lines`) are counted as docstring-comment
3325/// lines and returned early without further analysis.
3326#[allow(clippy::needless_pass_by_value)]
3327#[allow(clippy::too_many_arguments)]
3328#[allow(clippy::many_single_char_names)] // destructuring return from count_symbols; names match field roles
3329fn process_physical_line(
3330    line: &str,
3331    line_idx: usize,
3332    config: &ScanConfig,
3333    raw: &mut RawLineCounts,
3334    in_block_comment: &mut bool,
3335    string_state: &mut Option<StringState>,
3336    pending_continuation: &mut Option<LineFacts>,
3337    ieee: IeeeFlags,
3338    scope: &mut CScopeState,
3339) {
3340    raw.total_physical_lines += 1;
3341
3342    if config.skip_lines.contains(&line_idx) {
3343        raw.docstring_comment_lines += 1;
3344        return;
3345    }
3346
3347    let trimmed = line.trim();
3348    let mut facts = LineFacts::default();
3349
3350    // IEEE 1045-1992: blank lines inside block comments are comment lines by default.
3351    // When blank_in_block_comment_as_comment is false, blank lines keep their blank
3352    // classification even while inside a block comment.
3353    if *in_block_comment && (ieee.blank_in_block_comment_as_comment || !trimmed.is_empty()) {
3354        facts.has_multi_comment = true;
3355    }
3356
3357    let chars: Vec<char> = line.chars().collect();
3358    // `code_mask` receives only the line's actual code bytes; string-literal and comment
3359    // regions are blanked to spaces (positions preserved for word-boundary matching) so that
3360    // branch keywords embedded in string constants — e.g. `&&`, `||`, `?`, `=>` inside an
3361    // HTML/JS template literal — are not miscounted as control-flow branches.
3362    let mut code_mask: Vec<u8> = Vec::with_capacity(chars.len());
3363    scan_line(
3364        &chars,
3365        config,
3366        &mut facts,
3367        in_block_comment,
3368        string_state,
3369        &mut code_mask,
3370    );
3371
3372    let Some(emit) = finalize_line_facts(
3373        facts,
3374        trimmed,
3375        raw,
3376        ieee,
3377        *in_block_comment,
3378        *string_state,
3379        pending_continuation,
3380    ) else {
3381        return;
3382    };
3383
3384    classify_line(raw, &emit, trimmed);
3385
3386    if emit.has_code {
3387        accumulate_code_line(raw, config, trimmed, scope, &code_mask);
3388    }
3389}
3390
3391/// Accumulate all per-line code metrics (symbols, C/C++ scope breakdown, cyclomatic complexity,
3392/// logical SLOC, and the ULOC hash) for a physical line already classified as containing code.
3393#[allow(clippy::many_single_char_names)] // destructuring return from count_symbols; names match roles
3394fn accumulate_code_line(
3395    raw: &mut RawLineCounts,
3396    config: &ScanConfig,
3397    trimmed: &str,
3398    scope: &mut CScopeState,
3399    code_mask: &[u8],
3400) {
3401    use std::hash::{DefaultHasher, Hash, Hasher};
3402    let (f, c, v, i, t, a, s) = count_symbols(&config.symbol_patterns, trimmed);
3403    raw.functions += f;
3404    raw.classes += c;
3405    raw.variables += v;
3406    raw.imports += i;
3407    raw.test_count += t;
3408    raw.test_assertion_count += a;
3409    raw.test_suite_count += s;
3410
3411    // C/C++ only: split variables by scope (member/local/global), count object-like macro
3412    // constants, and advance the brace-scope tracker. Gated on the C/C++ marker (non-empty
3413    // `functions_prefix_paren`); other languages leave the breakdown fields at zero.
3414    if !config.symbol_patterns.functions_prefix_paren.is_empty() {
3415        accumulate_c_family(raw, v, trimmed, scope);
3416    }
3417
3418    // Cyclomatic complexity: count branch decision keywords in real code only (the
3419    // masked line excludes string-literal and comment content).
3420    raw.cyclomatic_complexity += count_branch_in_line(code_mask, config.branch_keywords);
3421
3422    // Logical SLOC (language-specific strategy).
3423    accumulate_lsloc(raw, trimmed, config.lsloc_strategy);
3424
3425    // ULOC: hash each trimmed code line for cross-file unique-line counting.
3426    let mut h = DefaultHasher::new();
3427    trimmed.hash(&mut h);
3428    raw.code_line_hashes.push(h.finish());
3429}
3430
3431/// C/C++ per-line breakdown: bucket a single variable declaration by enclosing scope, count
3432/// object-like macro constants, and advance the brace-scope tracker.
3433fn accumulate_c_family(
3434    raw: &mut RawLineCounts,
3435    var_count: u64,
3436    trimmed: &str,
3437    scope: &mut CScopeState,
3438) {
3439    if var_count == 1 {
3440        match scope.current_var_kind() {
3441            VarKind::Member => raw.variables_member += 1,
3442            VarKind::Local => raw.variables_local += 1,
3443            VarKind::Global => raw.variables_global += 1,
3444        }
3445    }
3446    if is_object_like_macro(trimmed) {
3447        raw.macro_definitions += 1;
3448    }
3449    scope.update(trimmed);
3450}
3451
3452/// Apply the language-specific logical-SLOC counting strategy for one code line.
3453fn accumulate_lsloc(raw: &mut RawLineCounts, trimmed: &str, strategy: LslocStrategy) {
3454    match strategy {
3455        LslocStrategy::Semicolons => {
3456            let semi =
3457                u32::try_from(trimmed.bytes().filter(|&b| b == b';').count()).unwrap_or(u32::MAX);
3458            *raw.lsloc.get_or_insert(0) += semi;
3459        }
3460        LslocStrategy::NonContinuationNewlines => {
3461            let cont = trimmed.ends_with('\\')
3462                || trimmed.ends_with(',')
3463                || trimmed.ends_with('(')
3464                || trimmed.ends_with('[')
3465                || trimmed.ends_with('{');
3466            if !cont {
3467                *raw.lsloc.get_or_insert(0) += 1;
3468            }
3469        }
3470        LslocStrategy::Unsupported => {}
3471    }
3472}
3473
3474#[allow(clippy::needless_pass_by_value)]
3475fn analyze_generic(text: &str, config: ScanConfig, ieee: IeeeFlags) -> RawFileAnalysis {
3476    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
3477    let lines: Vec<&str> = normalized.split_terminator('\n').collect();
3478
3479    let mut raw = RawLineCounts::default();
3480    let mut warnings = Vec::new();
3481
3482    let mut in_block_comment = false;
3483    let mut string_state: Option<StringState> = None;
3484    // IEEE continuation-line state: accumulates facts across a backslash-continued sequence.
3485    let mut pending_continuation: Option<LineFacts> = None;
3486    // C/C++ brace-scope tracker for member/local/global variable classification.
3487    let mut scope = CScopeState::default();
3488
3489    for (line_idx, line) in lines.iter().enumerate() {
3490        process_physical_line(
3491            line,
3492            line_idx,
3493            &config,
3494            &mut raw,
3495            &mut in_block_comment,
3496            &mut string_state,
3497            &mut pending_continuation,
3498            ieee,
3499            &mut scope,
3500        );
3501    }
3502
3503    // Flush any pending continuation that reaches end-of-file without a closing line.
3504    if let Some(pending) = pending_continuation.take() {
3505        classify_line(&mut raw, &pending, "");
3506    }
3507
3508    if in_block_comment {
3509        warnings.push("unclosed block comment detected; result is best effort".into());
3510    }
3511    if string_state.is_some() {
3512        warnings.push("unclosed string literal detected; result is best effort".into());
3513    }
3514
3515    RawFileAnalysis {
3516        raw,
3517        parse_mode: if warnings.is_empty() {
3518            ParseMode::Lexical
3519        } else {
3520            ParseMode::LexicalBestEffort
3521        },
3522        warnings,
3523        style_analysis: None,
3524    }
3525}
3526
3527/// Coarse per-physical-line classification used by the code-ownership attribution pass in
3528/// `sloc-core`. Deliberately three-way (blame is physical-line based, and the ownership view
3529/// only needs code / comment / blank) so that per-author tallies sum exactly to a file's
3530/// physical line count. This is *not* the IEEE policy classifier — it does not collapse
3531/// continuation lines or split the fine-grained mixed/docstring buckets.
3532#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3533#[serde(rename_all = "snake_case")]
3534pub enum LineCategory {
3535    Code,
3536    Comment,
3537    Blank,
3538}
3539
3540/// Classify every physical line of `text` (for `language`) into exactly one [`LineCategory`],
3541/// returning one entry per physical line in source order. Reuses the same lexical scanner as
3542/// [`analyze_text`] so block-comment / string state is tracked identically, but assigns a
3543/// category to each physical line without continuation collapsing — keeping the result aligned
3544/// one-to-one with `git blame`'s per-line output. Blank lines inside a block comment are
3545/// reported as `Comment`, matching the IEEE default.
3546pub fn classify_physical_lines(language: Language, text: &str) -> Vec<LineCategory> {
3547    #[cfg(feature = "tree-sitter")]
3548    let _ = (); // classification always uses the lexical scanner for stable line alignment.
3549
3550    let (mut config, _has_preprocessor) = language_scan_config(language);
3551    if language == Language::Python {
3552        config.skip_lines = detect_python_docstring_lines(text);
3553    }
3554
3555    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
3556    let lines: Vec<&str> = normalized.split_terminator('\n').collect();
3557
3558    let mut out = Vec::with_capacity(lines.len());
3559    let mut in_block_comment = false;
3560    let mut string_state: Option<StringState> = None;
3561
3562    for (idx, line) in lines.iter().enumerate() {
3563        if config.skip_lines.contains(&idx) {
3564            out.push(LineCategory::Comment); // pre-detected docstring line
3565            continue;
3566        }
3567
3568        let trimmed = line.trim();
3569        let mut facts = LineFacts::default();
3570        let opened_in_block = in_block_comment;
3571        if in_block_comment {
3572            facts.has_multi_comment = true;
3573        }
3574
3575        let chars: Vec<char> = line.chars().collect();
3576        let mut code_mask: Vec<u8> = Vec::with_capacity(chars.len());
3577        scan_line(
3578            &chars,
3579            &config,
3580            &mut facts,
3581            &mut in_block_comment,
3582            &mut string_state,
3583            &mut code_mask,
3584        );
3585
3586        out.push(categorize_physical_line(&facts, trimmed, opened_in_block));
3587    }
3588
3589    out
3590}
3591
3592/// Decide the [`LineCategory`] for a single physical line from its scanned `facts`. Split out of
3593/// [`classify_physical_lines`] so the (otherwise deeply nested) blank-in-block branch is isolated
3594/// and directly unit-testable. Mirrors the ordering of the `classify_line` bucket assignment below.
3595fn categorize_physical_line(
3596    facts: &LineFacts,
3597    trimmed: &str,
3598    opened_in_block: bool,
3599) -> LineCategory {
3600    if facts.has_code {
3601        LineCategory::Code
3602    } else if facts.has_single_comment || facts.has_multi_comment || facts.has_docstring {
3603        LineCategory::Comment
3604    } else if trimmed.is_empty() {
3605        // A blank line spanned by an open block comment counts as a comment line.
3606        if opened_in_block {
3607            LineCategory::Comment
3608        } else {
3609            LineCategory::Blank
3610        }
3611    } else {
3612        // Non-empty, non-comment, non-code (rare "skipped/unknown") — attribute as code so
3613        // ownership totals never silently drop physical lines.
3614        LineCategory::Code
3615    }
3616}
3617
3618const fn classify_line(raw: &mut RawLineCounts, facts: &LineFacts, trimmed: &str) {
3619    if facts.has_docstring {
3620        raw.docstring_comment_lines += 1;
3621    } else if !facts.has_code
3622        && !facts.has_single_comment
3623        && !facts.has_multi_comment
3624        && trimmed.is_empty()
3625    {
3626        raw.blank_only_lines += 1;
3627    } else if facts.has_code && facts.has_single_comment {
3628        raw.mixed_code_single_comment_lines += 1;
3629    } else if facts.has_code && facts.has_multi_comment {
3630        raw.mixed_code_multi_comment_lines += 1;
3631    } else if facts.has_code {
3632        raw.code_only_lines += 1;
3633    } else if facts.has_single_comment {
3634        raw.single_comment_only_lines += 1;
3635    } else if facts.has_multi_comment {
3636        raw.multi_comment_only_lines += 1;
3637    } else if trimmed.is_empty() {
3638        raw.blank_only_lines += 1;
3639    } else {
3640        raw.skipped_unknown_lines += 1;
3641    }
3642}
3643
3644/// True (as 0/1) when `trimmed` starts with any of the prefixes in `pats`.
3645fn prefix_hit(pats: &[&str], trimmed: &str) -> u64 {
3646    u64::from(pats.iter().any(|p| trimmed.starts_with(p)))
3647}
3648
3649/// Match a return-type-led function prefix (C/C++): prefix AND `(` present AND no `=` sits
3650/// between the prefix start and the first `(` (guards against `void* p = malloc(n)`).
3651fn fn_prefix_paren_hit(patterns: &SymbolPatterns, trimmed: &str) -> u64 {
3652    if patterns.functions_prefix_paren.is_empty() {
3653        return 0;
3654    }
3655    let Some(paren_pos) = trimmed.find('(') else {
3656        return 0;
3657    };
3658    if trimmed[..paren_pos].contains('=') {
3659        0
3660    } else {
3661        prefix_hit(patterns.functions_prefix_paren, trimmed)
3662    }
3663}
3664
3665/// Complement of `functions_prefix_paren`: same type keywords, but triggered when there is no
3666/// unguarded `(` on the line (i.e. not a function definition).
3667fn var_prefix_no_paren_hit(patterns: &SymbolPatterns, trimmed: &str) -> u64 {
3668    if patterns.variables_prefix_no_paren.is_empty()
3669        || prefix_hit(patterns.variables_prefix_no_paren, trimmed) == 0
3670    {
3671        return 0;
3672    }
3673    trimmed
3674        .find('(')
3675        .map_or(1, |pp| u64::from(trimmed[..pp].contains('=')))
3676}
3677
3678/// Statement/expression keywords that can legally precede `(` or a declarator but are NOT a
3679/// function or variable definition. Used to reject false positives in the C/C++ heuristics.
3680const C_STMT_KEYWORDS: &[&str] = &[
3681    "if",
3682    "for",
3683    "while",
3684    "switch",
3685    "return",
3686    "catch",
3687    "sizeof",
3688    "do",
3689    "else",
3690    "case",
3691    "throw",
3692    "goto",
3693    "using",
3694    "namespace",
3695    "typedef",
3696    "friend",
3697    "decltype",
3698    "alignof",
3699    "new",
3700    "delete",
3701    "static_assert",
3702    "template",
3703    "co_await",
3704    "co_return",
3705    "co_yield",
3706    "assert",
3707    "default",
3708    "class",
3709    "struct",
3710    "union",
3711    "enum",
3712    "public",
3713    "private",
3714    "protected",
3715    "try",
3716];
3717
3718/// True when `c` may appear inside a C/C++ return type or declarator (identifier chars, pointer
3719/// / reference markers, template brackets, scope resolution, qualifiers with spaces).
3720const fn c_type_char_ok(c: char) -> bool {
3721    c.is_ascii_alphanumeric()
3722        || matches!(
3723            c,
3724            '_' | ':' | '<' | '>' | '*' | '&' | '~' | ' ' | '\t' | ','
3725        )
3726}
3727
3728/// True when `name` (the token immediately before `(` or the assignment/terminator) is a
3729/// plausible C/C++ identifier — allowing leading `*`/`&`/`~` and a `Scope::` qualifier.
3730fn c_name_is_identifier(name: &str) -> bool {
3731    let core = name.trim_start_matches(['*', '&', '~']);
3732    let seg = core.rsplit("::").next().unwrap_or(core);
3733    let mut chars = seg.chars();
3734    match chars.next() {
3735        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
3736        _ => return false,
3737    }
3738    seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
3739}
3740
3741/// Heuristic: does `trimmed` look like a C/C++ function definition or prototype?
3742///
3743/// Recognises `<return-type> <name>(...)` regardless of the return type, so functions returning
3744/// user-defined or namespaced types (`std::string foo(...)`, `MyClass bar(...)`) are counted —
3745/// the fixed keyword list in `functions_prefix_paren` only caught built-in return types. Rejects
3746/// calls (`foo(x)`, `obj.m(x)`, `std::sort(v)`), control flow (`if (...)`), and initialisers
3747/// (`T x = f(y)`).
3748fn looks_like_c_function(trimmed: &str) -> u64 {
3749    let Some(paren) = trimmed.find('(') else {
3750        return 0;
3751    };
3752    let pre = trimmed[..paren].trim();
3753    // The segment before `(` must be a clean `<type> <name>` — reject anything containing
3754    // assignment, statement terminators, member access, indexing, or arithmetic/logical
3755    // operators, all of which indicate an expression or call rather than a definition.
3756    if pre.is_empty() || pre.contains("->") || !pre.chars().all(c_type_char_ok) {
3757        return 0;
3758    }
3759    // Need at least "<return type> <name>": two whitespace-separated tokens.
3760    let mut toks = pre.split_whitespace();
3761    let Some(first) = toks.next() else {
3762        return 0;
3763    };
3764    if C_STMT_KEYWORDS.contains(&first) || toks.next().is_none() {
3765        return 0;
3766    }
3767    let Some(name) = pre.split_whitespace().next_back() else {
3768        return 0;
3769    };
3770    if !c_name_is_identifier(name) {
3771        return 0;
3772    }
3773    // Disambiguate the "most vexing parse": `T v(expr);` is a variable direct-initialisation, not
3774    // a function. Only the `;`-terminated form is ambiguous — a definition ends with `{` (or a
3775    // continued signature). Treat it as a function only when the parentheses hold a parameter
3776    // list (empty, comma-separated, or containing type markers) rather than a lone value.
3777    if trimmed.ends_with(';') {
3778        let args = trimmed[paren + 1..]
3779            .rsplit_once(')')
3780            .map_or("", |(a, _)| a)
3781            .trim();
3782        let looks_like_params = args.is_empty()
3783            || args.contains(',')
3784            || args.contains('&')
3785            || args.contains('*')
3786            || args.contains("::")
3787            || args.split_whitespace().count() >= 2;
3788        if !looks_like_params {
3789            return 0;
3790        }
3791    }
3792    1
3793}
3794
3795/// Heuristic: does `trimmed` look like a C/C++ variable declaration?
3796///
3797/// Recognises `<type> <name>;`, `<type> <name> = …;`, and `<type> <name>{…};` for any type,
3798/// including user-defined / namespaced / templated types. Rejects function definitions and calls
3799/// (declarator immediately followed by `(`), labels, control flow, and bare expressions.
3800fn looks_like_c_variable(trimmed: &str) -> u64 {
3801    // Locate the first declarator-terminating delimiter. A leading `(` covers both calls
3802    // (`foo(x);`) and direct-init variables (`std::istringstream ss(s);`); the `<type> <name>`
3803    // shape test below rejects calls, while `count_symbols` only consults this heuristic when the
3804    // line was not already classified as a function, so real prototypes are not double-counted.
3805    let Some(delim_pos) = trimmed.find(['=', ';', '{', '(']) else {
3806        return 0;
3807    };
3808    let head = trimmed[..delim_pos].trim();
3809    if head.is_empty() || head.contains("->") || !head.chars().all(c_type_char_ok) {
3810        return 0;
3811    }
3812    let mut toks = head.split_whitespace();
3813    let Some(first) = toks.next() else {
3814        return 0;
3815    };
3816    if C_STMT_KEYWORDS.contains(&first) || toks.next().is_none() {
3817        return 0;
3818    }
3819    let Some(name) = head.split_whitespace().next_back() else {
3820        return 0;
3821    };
3822    u64::from(c_name_is_identifier(name))
3823}
3824
3825/// The kind of brace-delimited scope currently open, tracked to classify variable declarations
3826/// as member / local / global in C and C++.
3827#[derive(Clone, Copy, PartialEq, Eq)]
3828enum CScope {
3829    /// `class` / `struct` / `union` body — declarations inside are member variables.
3830    Aggregate,
3831    /// Function body — declarations inside are local variables.
3832    Function,
3833    /// `namespace` body — declarations inside are global-scope variables.
3834    Namespace,
3835    /// Control / other block (`if`/`for`/`{ … }`) — treated as local (blocks live in functions).
3836    Block,
3837}
3838
3839/// Which bucket a detected variable declaration belongs to, from the enclosing scope.
3840#[derive(Clone, Copy)]
3841enum VarKind {
3842    Member,
3843    Local,
3844    Global,
3845}
3846
3847/// Best-effort brace-based scope tracker for C/C++, threaded across the lines of one file.
3848#[derive(Default)]
3849struct CScopeState {
3850    stack: Vec<CScope>,
3851    /// Scope kind established by an opener line whose `{` has not yet appeared (handles
3852    /// Allman/K&R style where the brace is on the following line).
3853    pending: Option<CScope>,
3854}
3855
3856impl CScopeState {
3857    /// Classify a variable declaration by the current innermost scope.
3858    fn current_var_kind(&self) -> VarKind {
3859        match self.stack.last() {
3860            Some(CScope::Aggregate) => VarKind::Member,
3861            Some(CScope::Function | CScope::Block) => VarKind::Local,
3862            _ => VarKind::Global, // namespace or file scope
3863        }
3864    }
3865
3866    /// Update the brace stack for one C/C++ code line. Braces inside string / char literals and
3867    /// comments (`//` and same-line `/* … */`) are skipped so they cannot corrupt the stack.
3868    fn update(&mut self, trimmed: &str) {
3869        if let Some(kind) = c_line_scope_kind(trimmed) {
3870            self.pending = Some(kind);
3871        }
3872        let bytes = trimmed.as_bytes();
3873        let mut i = 0;
3874        let mut in_str: Option<u8> = None;
3875        while i < bytes.len() {
3876            // While inside a string/char literal, consume the byte and skip the brace logic.
3877            if let Some(next) = skip_string_literal(bytes, i, &mut in_str) {
3878                i = next;
3879                continue;
3880            }
3881            let b = bytes[i];
3882            match b {
3883                b'"' | b'\'' => in_str = Some(b),
3884                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => break, // line comment
3885                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
3886                    // Skip a same-line block comment; if unterminated, stop scanning the line.
3887                    match trimmed[i + 2..].find("*/") {
3888                        Some(off) => i += 2 + off + 2,
3889                        None => break,
3890                    }
3891                    continue;
3892                }
3893                b'{' => self
3894                    .stack
3895                    .push(self.pending.take().unwrap_or(CScope::Block)),
3896                b'}' => {
3897                    self.stack.pop();
3898                }
3899                _ => {}
3900            }
3901            i += 1;
3902        }
3903    }
3904}
3905
3906/// While the brace scanner is inside a C/C++ string or char literal, consume the byte at `i` and
3907/// return the next index to visit, honouring `\` escapes and clearing `in_str` on the closing
3908/// quote. Returns `None` when the scanner is not currently inside a literal.
3909fn skip_string_literal(bytes: &[u8], i: usize, in_str: &mut Option<u8>) -> Option<usize> {
3910    let q = (*in_str)?;
3911    let b = bytes[i];
3912    if b == b'\\' {
3913        return Some(i + 2); // skip escaped character
3914    }
3915    if b == q {
3916        *in_str = None;
3917    }
3918    Some(i + 1)
3919}
3920
3921/// Determine whether a C/C++ code line opens a named scope, to label the `{` it introduces.
3922/// Returns `None` for lines that merely declare (`struct Foo f;`), forward-declare (`struct Foo;`),
3923/// or prototype (`int f(int);`) — none of which open a body.
3924fn c_line_scope_kind(trimmed: &str) -> Option<CScope> {
3925    // A `;` with no `{` on the line is a declaration/prototype, not a body opener.
3926    if trimmed.contains(';') && !trimmed.contains('{') {
3927        return None;
3928    }
3929    if trimmed.starts_with("namespace") {
3930        return Some(CScope::Namespace);
3931    }
3932    if is_c_aggregate_opener(trimmed) {
3933        return Some(CScope::Aggregate);
3934    }
3935    if looks_like_c_function(trimmed) == 1 {
3936        return Some(CScope::Function);
3937    }
3938    None
3939}
3940
3941/// True when the line is a `class` / `struct` / `union` body definition (keyword present as a
3942/// standalone token and no `(`, which would make it a function returning that aggregate type).
3943fn is_c_aggregate_opener(trimmed: &str) -> bool {
3944    if trimmed.contains('(') {
3945        return false;
3946    }
3947    trimmed
3948        .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
3949        .any(|tok| matches!(tok, "class" | "struct" | "union"))
3950}
3951
3952/// True when `trimmed` is an object-like preprocessor macro definition (`#define NAME value`).
3953/// Function-like macros (`#define F(x) …`) and value-less defines (include guards) are excluded.
3954fn is_object_like_macro(trimmed: &str) -> bool {
3955    let Some(rest) = trimmed.strip_prefix('#') else {
3956        return false;
3957    };
3958    let Some(rest) = rest.trim_start().strip_prefix("define") else {
3959        return false;
3960    };
3961    if !rest.starts_with(char::is_whitespace) {
3962        return false; // `#defineFOO` is not a define
3963    }
3964    let rest = rest.trim_start();
3965    let name_end = rest
3966        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
3967        .unwrap_or(rest.len());
3968    if name_end == 0 {
3969        return false; // no macro name
3970    }
3971    let after = &rest[name_end..];
3972    // Function-like macro: name immediately followed by `(`. Require a non-empty replacement
3973    // (skips bare include-guard defines like `#define FOO_H`).
3974    !after.starts_with('(') && !after.trim().is_empty()
3975}
3976
3977fn count_symbols(patterns: &SymbolPatterns, trimmed: &str) -> (u64, u64, u64, u64, u64, u64, u64) {
3978    let hit = |pats: &[&str]| prefix_hit(pats, trimmed);
3979    // C and C++ are the only languages with a non-empty `functions_prefix_paren` list. For them
3980    // the generic `looks_like_c_*` heuristics fully replace the fixed keyword lists (which only
3981    // caught built-in return types like `int`/`void` and mis-fired on prototype continuation
3982    // lines); every other language keeps its prefix-based detection.
3983    let c_style = !patterns.functions_prefix_paren.is_empty();
3984    let fn_extra = if c_style {
3985        looks_like_c_function(trimmed)
3986    } else {
3987        fn_prefix_paren_hit(patterns, trimmed)
3988    };
3989    let test_hit = hit(patterns.tests);
3990    // Lines matching a test pattern count as tests, not as plain functions or classes.
3991    // This prevents double-counting in Python (`def test_` / `class Test`) and Go
3992    // (`func Test` / `func Benchmark` / `func Fuzz`) where the same line satisfies both
3993    // a function/class prefix and a test pattern. Rust is unaffected: `#[test]` is a
3994    // standalone attribute line; the `fn` declaration on the next line does not match any
3995    // test pattern and still increments functions correctly.
3996    let fn_hit = if test_hit == 0 {
3997        hit(patterns.functions) | fn_extra
3998    } else {
3999        0
4000    };
4001    let class_hit = if test_hit == 0 {
4002        hit(patterns.classes)
4003    } else {
4004        0
4005    };
4006    let var_hit = if c_style {
4007        // For C/C++, use only the generic heuristic, and only when the line is not already a
4008        // test, function, or class definition (avoids double-counting).
4009        if test_hit == 0 && fn_hit == 0 && class_hit == 0 {
4010            looks_like_c_variable(trimmed)
4011        } else {
4012            0
4013        }
4014    } else {
4015        hit(patterns.variables) | var_prefix_no_paren_hit(patterns, trimmed)
4016    };
4017    (
4018        fn_hit,
4019        class_hit,
4020        var_hit,
4021        hit(patterns.imports),
4022        test_hit,
4023        hit(patterns.assertions),
4024        hit(patterns.test_suites),
4025    )
4026}
4027
4028/// True when `line[start..end]` is surrounded by non-identifier characters.
4029fn is_word_boundary(line: &[u8], start: usize, end: usize) -> bool {
4030    let before_ok =
4031        start == 0 || (!line[start - 1].is_ascii_alphanumeric() && line[start - 1] != b'_');
4032    let after_ok = end >= line.len() || (!line[end].is_ascii_alphanumeric() && line[end] != b'_');
4033    before_ok && after_ok
4034}
4035
4036/// True when `kw_bytes` appears at `line[i..]`, respecting word boundaries when `word_kw` is set.
4037fn keyword_matches_at(line: &[u8], i: usize, kw_bytes: &[u8], word_kw: bool) -> bool {
4038    if &line[i..i + kw_bytes.len()] != kw_bytes {
4039        return false;
4040    }
4041    !word_kw || is_word_boundary(line, i, i + kw_bytes.len())
4042}
4043
4044/// Count branch keyword occurrences in `line` (ASCII bytes of a trimmed code line).
4045///
4046/// Alphabetic keywords are matched word-bounded (not as substrings of longer identifiers).
4047/// Operator tokens (`||`, `&&`, `?`) are matched as raw substrings.
4048fn count_branch_in_line(line: &[u8], keywords: &[&str]) -> u32 {
4049    if keywords.is_empty() || line.is_empty() {
4050        return 0;
4051    }
4052    let mut total = 0u32;
4053    for &kw in keywords {
4054        let kw_bytes = kw.as_bytes();
4055        let word_kw = kw.bytes().all(|b| b.is_ascii_alphabetic() || b == b'_');
4056        let mut i = 0usize;
4057        while i + kw_bytes.len() <= line.len() {
4058            if keyword_matches_at(line, i, kw_bytes, word_kw) {
4059                total += 1;
4060                i += kw_bytes.len();
4061            } else {
4062                i += 1;
4063            }
4064        }
4065    }
4066    total
4067}
4068
4069fn starts_with(chars: &[char], index: usize, needle: &str) -> bool {
4070    let Some(tail) = chars.get(index..) else {
4071        return false;
4072    };
4073
4074    for (i, needle_char) in needle.chars().enumerate() {
4075        if tail.get(i).copied() != Some(needle_char) {
4076            return false;
4077        }
4078    }
4079
4080    true
4081}
4082
4083#[derive(Debug, Clone)]
4084struct PyContext {
4085    indent: usize,
4086    expect_docstring: bool,
4087}
4088
4089/// Update `contexts` to pop any scopes that the current `indent` has outdented past.
4090fn py_pop_outdented_contexts(contexts: &mut Vec<PyContext>, indent: usize) {
4091    while contexts.len() > 1 && indent < contexts.last().map_or(0, |c| c.indent) {
4092        contexts.pop();
4093    }
4094}
4095
4096/// Handle `pending_block_indent` transition: push a new docstring-expecting context when we
4097/// detect the first indented line of a new block, or cancel the pending state otherwise.
4098fn py_handle_pending_indent(
4099    pending_block_indent: &mut Option<usize>,
4100    contexts: &mut Vec<PyContext>,
4101    indent: usize,
4102    trimmed: &str,
4103) {
4104    let Some(base_indent) = *pending_block_indent else {
4105        return;
4106    };
4107    if indent > base_indent {
4108        contexts.push(PyContext {
4109            indent,
4110            expect_docstring: true,
4111        });
4112        *pending_block_indent = None;
4113    } else if !trimmed.starts_with('@') {
4114        *pending_block_indent = None;
4115    }
4116}
4117
4118/// Check whether the current line is a docstring opener in the current context.
4119///
4120/// If it is, records the line, adjusts `ctx.expect_docstring`, and optionally sets
4121/// `active_docstring` for multi-line docstrings. Returns `true` when the caller should
4122/// `continue` to the next line.
4123fn py_try_record_docstring(
4124    ctx: &mut PyContext,
4125    trimmed: &str,
4126    idx: usize,
4127    docstring_lines: &mut HashSet<usize>,
4128    active_docstring: &mut Option<(&'static str, usize)>,
4129) -> bool {
4130    if !ctx.expect_docstring {
4131        return false;
4132    }
4133    if let Some(delim) = docstring_delimiter(trimmed) {
4134        docstring_lines.insert(idx);
4135        ctx.expect_docstring = false;
4136        if !closes_triple_docstring(trimmed, delim, true) {
4137            *active_docstring = Some((delim, idx));
4138        }
4139        return true;
4140    }
4141    ctx.expect_docstring = false;
4142    false
4143}
4144
4145/// Advance through an active multi-line docstring: marks the current line and clears
4146/// `active_docstring` when the closing delimiter is found. Returns `true` when the caller
4147/// should `continue` to the next line (i.e. we were inside a docstring).
4148fn track_active_docstring(
4149    active_docstring: &mut Option<(&'static str, usize)>,
4150    docstring_lines: &mut HashSet<usize>,
4151    idx: usize,
4152    trimmed: &str,
4153) -> bool {
4154    let Some((delim, start_line)) = *active_docstring else {
4155        return false;
4156    };
4157    docstring_lines.insert(idx);
4158    if closes_triple_docstring(trimmed, delim, idx == start_line) {
4159        *active_docstring = None;
4160    }
4161    true
4162}
4163
4164/// Attempt to record a docstring opener using the top of the context stack.
4165/// Returns `true` when the caller should `continue` to the next line.
4166fn try_record_docstring_if_context(
4167    contexts: &mut [PyContext],
4168    trimmed: &str,
4169    idx: usize,
4170    docstring_lines: &mut HashSet<usize>,
4171    active_docstring: &mut Option<(&'static str, usize)>,
4172) -> bool {
4173    let Some(ctx) = contexts.last_mut() else {
4174        return false;
4175    };
4176    py_try_record_docstring(ctx, trimmed, idx, docstring_lines, active_docstring)
4177}
4178
4179/// If an unclosed docstring is still active at end-of-file, mark all remaining lines.
4180fn mark_unclosed_docstring_lines(
4181    active_docstring: Option<&(&'static str, usize)>,
4182    docstring_lines: &mut HashSet<usize>,
4183    num_lines: usize,
4184) {
4185    if let Some(&(_, start_line)) = active_docstring {
4186        for idx in start_line..num_lines {
4187            docstring_lines.insert(idx);
4188        }
4189    }
4190}
4191
4192fn detect_python_docstring_lines(text: &str) -> HashSet<usize> {
4193    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
4194    let lines: Vec<&str> = normalized.split_terminator('\n').collect();
4195
4196    let mut docstring_lines = HashSet::new();
4197    let mut contexts = vec![PyContext {
4198        indent: 0,
4199        expect_docstring: true,
4200    }];
4201    let mut pending_block_indent: Option<usize> = None;
4202    let mut active_docstring: Option<(&'static str, usize)> = None;
4203
4204    for (idx, line) in lines.iter().enumerate() {
4205        let trimmed = line.trim();
4206        let indent = leading_indent(line);
4207
4208        if track_active_docstring(&mut active_docstring, &mut docstring_lines, idx, trimmed) {
4209            continue;
4210        }
4211
4212        // Blank lines and comment lines don't affect docstring detection.
4213        if trimmed.is_empty() || trimmed.starts_with('#') {
4214            continue;
4215        }
4216
4217        py_pop_outdented_contexts(&mut contexts, indent);
4218        py_handle_pending_indent(&mut pending_block_indent, &mut contexts, indent, trimmed);
4219
4220        if try_record_docstring_if_context(
4221            &mut contexts,
4222            trimmed,
4223            idx,
4224            &mut docstring_lines,
4225            &mut active_docstring,
4226        ) {
4227            continue;
4228        }
4229
4230        if is_python_block_header(trimmed) {
4231            pending_block_indent = Some(indent);
4232        }
4233    }
4234
4235    mark_unclosed_docstring_lines(active_docstring.as_ref(), &mut docstring_lines, lines.len());
4236
4237    docstring_lines
4238}
4239
4240fn leading_indent(line: &str) -> usize {
4241    line.chars().take_while(|c| c.is_whitespace()).count()
4242}
4243
4244fn is_python_block_header(trimmed: &str) -> bool {
4245    (trimmed.starts_with("def ")
4246        || trimmed.starts_with("async def ")
4247        || trimmed.starts_with("class "))
4248        && trimmed.ends_with(':')
4249}
4250
4251fn docstring_delimiter(trimmed: &str) -> Option<&'static str> {
4252    let mut idx = 0usize;
4253    let bytes = trimmed.as_bytes();
4254    while idx < bytes.len() {
4255        let c = bytes[idx] as char;
4256        if matches!(c, 'r' | 'R' | 'u' | 'U' | 'b' | 'B' | 'f' | 'F') {
4257            idx += 1;
4258            continue;
4259        }
4260        break;
4261    }
4262
4263    let rest = &trimmed[idx..];
4264    if rest.starts_with("\"\"\"") {
4265        Some("\"\"\"")
4266    } else if rest.starts_with("'''") {
4267        Some("'''")
4268    } else {
4269        None
4270    }
4271}
4272
4273fn closes_triple_docstring(trimmed: &str, delim: &str, same_line_as_start: bool) -> bool {
4274    let mut occurrences = 0usize;
4275    let mut search = trimmed;
4276    while let Some(index) = search.find(delim) {
4277        occurrences += 1;
4278        search = &search[index + delim.len()..];
4279    }
4280
4281    if same_line_as_start {
4282        occurrences >= 2
4283    } else {
4284        occurrences >= 1
4285    }
4286}
4287
4288/// Tree-sitter-backed adapters (compiled only when the `tree-sitter` feature is enabled).
4289///
4290/// When parsing succeeds the result is used directly; on any failure the caller falls back
4291/// to the lexical state machine.
4292#[cfg(feature = "tree-sitter")]
4293pub mod ts {
4294    use tree_sitter::Node;
4295
4296    use super::{ParseMode, RawFileAnalysis, RawLineCounts};
4297
4298    /// Configuration for which AST node kinds map to symbols in this grammar.
4299    struct SymbolKinds {
4300        /// Node kind name for function definitions (e.g. `"function_definition"`).
4301        function_def: &'static str,
4302        /// Node kind name for class definitions (e.g. `"class_definition"`).
4303        class_def: &'static str,
4304        /// Name field of a function node that, when it starts with this prefix, marks a test.
4305        /// Empty string disables test-prefix detection.
4306        test_fn_prefix: &'static str,
4307        /// Name field of a class node that, when it starts with this prefix, marks a test.
4308        /// Empty string disables test-prefix detection.
4309        test_class_prefix: &'static str,
4310        /// When non-empty, `call` nodes whose `function` is an `attribute` access and whose
4311        /// attribute identifier starts with this prefix are counted as test assertions.
4312        /// Used for Python `self.assertXxx(...)` detection.
4313        assertion_attr_prefix: &'static str,
4314    }
4315
4316    impl SymbolKinds {
4317        const fn none() -> Self {
4318            Self {
4319                function_def: "",
4320                class_def: "",
4321                test_fn_prefix: "",
4322                test_class_prefix: "",
4323                assertion_attr_prefix: "",
4324            }
4325        }
4326    }
4327
4328    /// Classify every line of `text` using a tree-sitter grammar.
4329    ///
4330    /// `comment_node_kinds` — node type names that represent comments in this grammar
4331    /// `docstring_stmt_kind` — optional parent node type whose direct `string` child is a docstring
4332    /// `symbols` — AST node kinds used to populate symbol counters
4333    fn analyze_lines(
4334        text: &str,
4335        ts_language: &tree_sitter::Language,
4336        comment_node_kinds: &[&str],
4337        docstring_stmt_kind: Option<&str>,
4338        symbols: &SymbolKinds,
4339    ) -> Option<RawFileAnalysis> {
4340        let mut parser = tree_sitter::Parser::new();
4341        parser.set_language(ts_language).ok()?;
4342        let tree = parser.parse(text, None)?;
4343
4344        let lines: Vec<&str> = text.split_terminator('\n').collect();
4345        let n = lines.len();
4346
4347        let mut has_code = vec![false; n];
4348        let mut has_comment = vec![false; n];
4349        let mut comment_is_block = vec![false; n];
4350        let mut has_docstring = vec![false; n];
4351
4352        // Walk every node in the tree and mark line arrays.
4353        let mut ctx = VisitCtx {
4354            source: text.as_bytes(),
4355            comment_kinds: comment_node_kinds,
4356            docstring_stmt_kind,
4357            has_code: &mut has_code,
4358            has_comment: &mut has_comment,
4359            comment_is_block: &mut comment_is_block,
4360            has_docstring: &mut has_docstring,
4361        };
4362        visit(tree.root_node(), &mut ctx);
4363
4364        let mut raw = RawLineCounts::default();
4365        classify_ts_lines(
4366            &lines,
4367            &has_code,
4368            &has_comment,
4369            &comment_is_block,
4370            &has_docstring,
4371            &mut raw,
4372        );
4373
4374        // Symbol counting: walk the AST a second time to collect function/class/test counts.
4375        if !symbols.function_def.is_empty() || !symbols.class_def.is_empty() {
4376            count_symbols(tree.root_node(), text.as_bytes(), symbols, &mut raw);
4377        }
4378
4379        Some(RawFileAnalysis {
4380            raw,
4381            parse_mode: ParseMode::TreeSitter,
4382            warnings: Vec::new(),
4383            style_analysis: None,
4384        })
4385    }
4386
4387    /// Recurse into every direct child of `node`.
4388    fn recurse_children(node: Node, source: &[u8], kinds: &SymbolKinds, raw: &mut RawLineCounts) {
4389        for i in 0..node.child_count() {
4390            #[allow(clippy::cast_possible_truncation)]
4391            if let Some(child) = node.child(i as u32) {
4392                count_symbols(child, source, kinds, raw);
4393            }
4394        }
4395    }
4396
4397    /// Handle a function-definition node. Returns `true` if the node matched.
4398    fn try_count_function(
4399        node: Node,
4400        source: &[u8],
4401        kinds: &SymbolKinds,
4402        raw: &mut RawLineCounts,
4403    ) -> bool {
4404        if kinds.function_def.is_empty() || node.kind() != kinds.function_def {
4405            return false;
4406        }
4407        let name = node
4408            .child_by_field_name("name")
4409            .and_then(|n| n.utf8_text(source).ok())
4410            .unwrap_or("");
4411        if !kinds.test_fn_prefix.is_empty() && name.starts_with(kinds.test_fn_prefix) {
4412            raw.test_count += 1;
4413        } else {
4414            raw.functions += 1;
4415        }
4416        recurse_children(node, source, kinds, raw);
4417        true
4418    }
4419
4420    /// Handle a class-definition node. Returns `true` if the node matched.
4421    fn try_count_class(
4422        node: Node,
4423        source: &[u8],
4424        kinds: &SymbolKinds,
4425        raw: &mut RawLineCounts,
4426    ) -> bool {
4427        if kinds.class_def.is_empty() || node.kind() != kinds.class_def {
4428            return false;
4429        }
4430        let name = node
4431            .child_by_field_name("name")
4432            .and_then(|n| n.utf8_text(source).ok())
4433            .unwrap_or("");
4434        if !kinds.test_class_prefix.is_empty() && name.starts_with(kinds.test_class_prefix) {
4435            raw.test_count += 1;
4436        } else {
4437            raw.classes += 1;
4438        }
4439        recurse_children(node, source, kinds, raw);
4440        true
4441    }
4442
4443    /// Handle an assertion call node. Returns `true` if the node matched (skips recursion
4444    /// into arguments, preserving "don't double-count test bodies" semantics).
4445    fn try_count_assertion(
4446        node: Node,
4447        source: &[u8],
4448        kinds: &SymbolKinds,
4449        raw: &mut RawLineCounts,
4450    ) -> bool {
4451        if kinds.assertion_attr_prefix.is_empty() || node.kind() != "call" {
4452            return false;
4453        }
4454        let Some(func) = node.child_by_field_name("function") else {
4455            return false;
4456        };
4457        if func.kind() != "attribute" {
4458            return false;
4459        }
4460        let attr_text = func
4461            .child_by_field_name("attribute")
4462            .and_then(|n| n.utf8_text(source).ok())
4463            .unwrap_or("");
4464        if !attr_text.starts_with(kinds.assertion_attr_prefix) {
4465            return false;
4466        }
4467        raw.test_assertion_count += 1;
4468        true
4469    }
4470
4471    /// Walk the AST and populate `raw.functions`, `raw.classes`, `raw.test_count`,
4472    /// and `raw.test_assertion_count`.
4473    fn count_symbols(node: Node, source: &[u8], kinds: &SymbolKinds, raw: &mut RawLineCounts) {
4474        if try_count_function(node, source, kinds, raw) {
4475            return;
4476        }
4477        if try_count_class(node, source, kinds, raw) {
4478            return;
4479        }
4480        if try_count_assertion(node, source, kinds, raw) {
4481            return;
4482        }
4483        recurse_children(node, source, kinds, raw);
4484    }
4485
4486    /// Flags describing what kinds of content appear on a single line.
4487    // Four bools are the natural representation for these four independent properties.
4488    #[allow(clippy::struct_excessive_bools)]
4489    #[derive(Clone, Copy)]
4490    struct TsLineFlags {
4491        has_code: bool,
4492        has_comment: bool,
4493        comment_is_block: bool,
4494        has_docstring: bool,
4495    }
4496
4497    /// Classify a single tree-sitter-annotated line and accumulate into `raw`.
4498    const fn classify_ts_line(trimmed: &str, flags: TsLineFlags, raw: &mut RawLineCounts) {
4499        if trimmed.is_empty() {
4500            raw.blank_only_lines += 1;
4501        } else if flags.has_docstring && !flags.has_code {
4502            raw.docstring_comment_lines += 1;
4503        } else if flags.has_code && flags.has_comment {
4504            // Classify the mixed line as single or multi based on what kind of comment is on it.
4505            if flags.comment_is_block {
4506                raw.mixed_code_multi_comment_lines += 1;
4507            } else {
4508                raw.mixed_code_single_comment_lines += 1;
4509            }
4510        } else if flags.has_comment {
4511            if flags.comment_is_block {
4512                raw.multi_comment_only_lines += 1;
4513            } else {
4514                raw.single_comment_only_lines += 1;
4515            }
4516        } else {
4517            raw.code_only_lines += 1;
4518        }
4519    }
4520
4521    /// Classify each tree-sitter-annotated line and accumulate counts into `raw`.
4522    fn classify_ts_lines(
4523        lines: &[&str],
4524        has_code: &[bool],
4525        has_comment: &[bool],
4526        comment_is_block: &[bool],
4527        has_docstring: &[bool],
4528        raw: &mut RawLineCounts,
4529    ) {
4530        for i in 0..lines.len() {
4531            raw.total_physical_lines += 1;
4532            classify_ts_line(
4533                lines[i].trim(),
4534                TsLineFlags {
4535                    has_code: has_code[i],
4536                    has_comment: has_comment[i],
4537                    comment_is_block: comment_is_block[i],
4538                    has_docstring: has_docstring[i],
4539                },
4540                raw,
4541            );
4542        }
4543    }
4544
4545    struct VisitCtx<'a> {
4546        source: &'a [u8],
4547        comment_kinds: &'a [&'a str],
4548        docstring_stmt_kind: Option<&'a str>,
4549        has_code: &'a mut Vec<bool>,
4550        has_comment: &'a mut Vec<bool>,
4551        comment_is_block: &'a mut Vec<bool>,
4552        has_docstring: &'a mut Vec<bool>,
4553    }
4554
4555    /// Mark all rows of a comment node and detect whether it is a block comment.
4556    fn visit_comment_node(node: Node, ctx: &mut VisitCtx<'_>) {
4557        let start_row = node.start_position().row;
4558        let end_row = node.end_position().row;
4559        let first_two = node
4560            .utf8_text(ctx.source)
4561            .unwrap_or("")
4562            .get(..2)
4563            .unwrap_or("");
4564        let is_block = first_two == "/*" || first_two == "<#";
4565        for row in start_row..=end_row {
4566            if row < ctx.has_comment.len() {
4567                ctx.has_comment[row] = true;
4568                if is_block {
4569                    ctx.comment_is_block[row] = true;
4570                }
4571            }
4572        }
4573    }
4574
4575    /// If `node` is an `expression_statement` whose sole named child is a string literal,
4576    /// mark those rows as docstring and return `true`.
4577    fn visit_maybe_docstring(node: Node, kind: &str, ctx: &mut VisitCtx<'_>) -> bool {
4578        let Some(stmt_kind) = ctx.docstring_stmt_kind else {
4579            return false;
4580        };
4581        if kind != stmt_kind || node.named_child_count() != 1 {
4582            return false;
4583        }
4584        let Some(child) = node.named_child(0) else {
4585            return false;
4586        };
4587        if child.kind() != "string" {
4588            return false;
4589        }
4590        let child_start = child.start_position().row;
4591        let child_end = child.end_position().row;
4592        for row in child_start..=child_end {
4593            if row < ctx.has_docstring.len() {
4594                ctx.has_docstring[row] = true;
4595            }
4596        }
4597        true
4598    }
4599
4600    /// Mark all rows of a leaf (non-comment, non-extra) node as code.
4601    fn visit_leaf_code(node: Node, ctx: &mut VisitCtx<'_>) {
4602        let start_row = node.start_position().row;
4603        let end_row = node.end_position().row;
4604        for row in start_row..=end_row {
4605            if row < ctx.has_code.len() {
4606                ctx.has_code[row] = true;
4607            }
4608        }
4609    }
4610
4611    #[allow(clippy::too_many_lines)]
4612    fn visit(node: Node, ctx: &mut VisitCtx<'_>) {
4613        let kind = node.kind();
4614
4615        // Comment node — mark rows as comment, detect block vs. line comment.
4616        if ctx.comment_kinds.contains(&kind) {
4617            visit_comment_node(node, ctx);
4618            return;
4619        }
4620
4621        // Python docstring: expression_statement whose only named child is a string literal.
4622        if visit_maybe_docstring(node, kind, ctx) {
4623            return;
4624        }
4625
4626        // Leaf non-comment node: mark as code.
4627        if node.child_count() == 0 && !node.is_extra() {
4628            visit_leaf_code(node, ctx);
4629            return;
4630        }
4631
4632        for i in 0..node.child_count() {
4633            #[allow(clippy::cast_possible_truncation)]
4634            // child_count bounded by tree-sitter u32 capacity
4635            if let Some(child) = node.child(i as u32) {
4636                visit(child, ctx);
4637            }
4638        }
4639    }
4640
4641    const C_SYMBOLS: SymbolKinds = SymbolKinds::none();
4642
4643    const PYTHON_SYMBOLS: SymbolKinds = SymbolKinds {
4644        function_def: "function_definition",
4645        class_def: "class_definition",
4646        test_fn_prefix: "test_",
4647        test_class_prefix: "Test",
4648        assertion_attr_prefix: "assert",
4649    };
4650
4651    /// Parse C or C++ source with tree-sitter-c.
4652    #[must_use]
4653    pub fn analyze_c(text: &str) -> Option<RawFileAnalysis> {
4654        let lang: tree_sitter::Language = tree_sitter_c::LANGUAGE.into();
4655        analyze_lines(text, &lang, &["comment"], None, &C_SYMBOLS)
4656    }
4657
4658    /// Parse Python source with tree-sitter-python.
4659    #[must_use]
4660    pub fn analyze_python(text: &str) -> Option<RawFileAnalysis> {
4661        let lang: tree_sitter::Language = tree_sitter_python::LANGUAGE.into();
4662        analyze_lines(
4663            text,
4664            &lang,
4665            &["comment"],
4666            Some("expression_statement"),
4667            &PYTHON_SYMBOLS,
4668        )
4669    }
4670}
4671
4672#[cfg(test)]
4673mod tests {
4674    use super::*;
4675
4676    #[test]
4677    fn python_docstrings_are_separated() {
4678        let input = r#""""module docs"""
4679
4680
4681def fn_a():
4682    """function docs"""
4683    value = 1  # trailing comment
4684    return value
4685"#;
4686
4687        let result = analyze_text(Language::Python, input, AnalysisOptions::default());
4688        assert_eq!(result.raw.docstring_comment_lines, 2);
4689        assert_eq!(result.raw.mixed_code_single_comment_lines, 1);
4690        assert_eq!(result.raw.code_only_lines, 2);
4691    }
4692
4693    #[test]
4694    fn c_style_mixed_lines_are_captured() {
4695        let input = "int x = 1; // note\n/* block */\n";
4696        let result = analyze_text(Language::C, input, AnalysisOptions::default());
4697        assert_eq!(result.raw.mixed_code_single_comment_lines, 1);
4698        assert_eq!(result.raw.multi_comment_only_lines, 1);
4699    }
4700
4701    #[test]
4702    fn branch_keywords_inside_strings_are_not_counted() {
4703        // Branch operators inside a normal string literal are not control flow → 0.
4704        let s = analyze_text(
4705            Language::Rust,
4706            "let s = \"if a && b || c ? d : e\";\n",
4707            AnalysisOptions::default(),
4708        );
4709        assert_eq!(s.raw.cyclomatic_complexity, 0);
4710
4711        // Same, inside a Rust raw string whose inner `\"` must not end the literal
4712        // (the HTML/JS-template shape that previously inflated cyclomatic complexity).
4713        let raw = analyze_text(
4714            Language::Rust,
4715            "let h = r#\"<a href=\"x\">a && b ? c : d</a>\"#;\n",
4716            AnalysisOptions::default(),
4717        );
4718        assert_eq!(raw.raw.cyclomatic_complexity, 0);
4719
4720        // Real control flow outside string literals is still counted.
4721        let code = analyze_text(
4722            Language::Rust,
4723            "if a && b { c } else { d }\n",
4724            AnalysisOptions::default(),
4725        );
4726        assert!(code.raw.cyclomatic_complexity >= 2);
4727    }
4728
4729    #[test]
4730    fn multiline_raw_string_does_not_swallow_following_code() {
4731        // Regression: a multi-line r##"..."## template with inner quotes must close cleanly so
4732        // the code after it stays classified as code (it was previously swallowed as string).
4733        let input = concat!(
4734            "let cfg = r##\"\n",
4735            "# looks like a comment but is string content\n",
4736            "key = \"value with \"\" inner quotes\"\n",
4737            "\"##;\n",
4738            "let x = 1;\n",
4739        );
4740        let r = analyze_text(Language::Rust, input, AnalysisOptions::default());
4741        assert!(
4742            r.raw.code_only_lines >= 1,
4743            "code after the raw string was swallowed"
4744        );
4745        assert_eq!(r.raw.single_comment_only_lines, 0);
4746        assert_eq!(r.raw.cyclomatic_complexity, 0);
4747    }
4748
4749    #[test]
4750    fn detect_language_by_shebang() {
4751        let language = detect_language(
4752            Path::new("script"),
4753            Some("#!/usr/bin/env bash"),
4754            &BTreeMap::new(),
4755            true,
4756        );
4757        assert_eq!(language, Some(Language::Shell));
4758    }
4759
4760    // ── count_symbols: no double-counting of test functions ──────────────────
4761
4762    fn sym(lang: Language, line: &str) -> (u64, u64, u64, u64, u64, u64, u64) {
4763        let result = analyze_text(lang, &format!("{line}\n"), AnalysisOptions::default());
4764        let r = &result.raw;
4765        (
4766            r.functions,
4767            r.classes,
4768            r.variables,
4769            r.imports,
4770            r.test_count,
4771            r.test_assertion_count,
4772            r.test_suite_count,
4773        )
4774    }
4775
4776    #[test]
4777    fn python_test_fn_not_double_counted() {
4778        // def test_ lines count as tests only, NOT as functions
4779        let (f, c, _, _, t, _, _) = sym(Language::Python, "def test_foo():");
4780        assert_eq!(f, 0, "test fn must not also increment functions");
4781        assert_eq!(t, 1, "must be counted as a test");
4782        assert_eq!(c, 0);
4783    }
4784
4785    #[test]
4786    fn python_test_class_not_double_counted() {
4787        // class Test* lines count as tests only, NOT as classes
4788        let (f, c, _, _, t, _, _) = sym(Language::Python, "class TestFoo:");
4789        assert_eq!(c, 0, "test class must not also increment classes");
4790        assert_eq!(t, 1, "must be counted as a test");
4791        assert_eq!(f, 0);
4792    }
4793
4794    #[test]
4795    fn python_regular_fn_counts_as_function() {
4796        let (f, c, _, _, t, _, _) = sym(Language::Python, "def regular():");
4797        assert_eq!(f, 1, "regular function must be counted");
4798        assert_eq!(t, 0);
4799        assert_eq!(c, 0);
4800    }
4801
4802    #[test]
4803    fn python_regular_class_counts_as_class() {
4804        let (f, c, _, _, t, _, _) = sym(Language::Python, "class Regular:");
4805        assert_eq!(c, 1, "regular class must be counted");
4806        assert_eq!(t, 0);
4807        assert_eq!(f, 0);
4808    }
4809
4810    #[test]
4811    fn go_test_fn_not_double_counted() {
4812        let (f, _, _, _, t, _, _) = sym(Language::Go, "func TestFoo(t *testing.T) {");
4813        assert_eq!(f, 0, "Go test func must not also increment functions");
4814        assert_eq!(t, 1, "must be counted as a test");
4815    }
4816
4817    #[test]
4818    fn go_benchmark_fn_not_double_counted() {
4819        let (f, _, _, _, t, _, _) = sym(Language::Go, "func BenchmarkBar(b *testing.B) {");
4820        assert_eq!(f, 0, "Go benchmark func must not also increment functions");
4821        assert_eq!(t, 1, "must be counted as a test");
4822    }
4823
4824    #[test]
4825    fn go_regular_fn_counts_as_function() {
4826        let (f, _, _, _, t, _, _) = sym(Language::Go, "func doSomething() {");
4827        assert_eq!(f, 1, "regular Go func must be counted");
4828        assert_eq!(t, 0);
4829    }
4830
4831    #[test]
4832    fn rust_test_attr_counts_as_test_not_function() {
4833        // #[test] is a standalone attribute line — counted as a test, never as a function
4834        let (f, _, _, _, t, _, _) = sym(Language::Rust, "#[test]");
4835        assert_eq!(t, 1, "#[test] must be counted as a test");
4836        assert_eq!(f, 0, "#[test] attribute must not be counted as a function");
4837    }
4838
4839    #[test]
4840    fn rust_fn_line_counts_as_function_not_test() {
4841        // The fn declaration after #[test] does NOT match any test pattern
4842        let (f, _, _, _, t, _, _) = sym(Language::Rust, "fn test_something() {");
4843        assert_eq!(f, 1, "fn declaration must count as a function");
4844        assert_eq!(
4845            t, 0,
4846            "fn declaration line must not be double-counted as a test"
4847        );
4848    }
4849
4850    #[test]
4851    fn js_describe_counts_as_test_not_function() {
4852        let (f, _, _, _, t, _, _) = sym(Language::JavaScript, "describe('suite', () => {");
4853        assert_eq!(t, 1, "describe must be counted as a test");
4854        assert_eq!(f, 0, "describe must not be counted as a function");
4855    }
4856
4857    #[test]
4858    fn js_regular_fn_counts_as_function() {
4859        let (f, _, _, _, t, _, _) = sym(Language::JavaScript, "function doWork() {");
4860        assert_eq!(f, 1, "JS function declaration must be counted");
4861        assert_eq!(t, 0);
4862    }
4863
4864    // ── Language detection tests ─────────────────────────────────────────────
4865
4866    use std::collections::BTreeMap;
4867    use std::path::Path;
4868
4869    #[test]
4870    fn detect_language_rs_extension() {
4871        let lang = detect_language(Path::new("foo.rs"), None, &BTreeMap::new(), false);
4872        assert_eq!(lang, Some(Language::Rust));
4873    }
4874
4875    #[test]
4876    fn detect_language_py_extension() {
4877        let lang = detect_language(Path::new("foo.py"), None, &BTreeMap::new(), false);
4878        assert_eq!(lang, Some(Language::Python));
4879    }
4880
4881    #[test]
4882    fn detect_language_ts_extension() {
4883        let lang = detect_language(Path::new("app.ts"), None, &BTreeMap::new(), false);
4884        assert_eq!(lang, Some(Language::TypeScript));
4885    }
4886
4887    #[test]
4888    fn detect_language_js_extension() {
4889        let lang = detect_language(Path::new("app.js"), None, &BTreeMap::new(), false);
4890        assert_eq!(lang, Some(Language::JavaScript));
4891    }
4892
4893    #[test]
4894    fn detect_language_go_extension() {
4895        let lang = detect_language(Path::new("main.go"), None, &BTreeMap::new(), false);
4896        assert_eq!(lang, Some(Language::Go));
4897    }
4898
4899    #[test]
4900    fn detect_language_c_extension() {
4901        let lang = detect_language(Path::new("main.c"), None, &BTreeMap::new(), false);
4902        assert_eq!(lang, Some(Language::C));
4903    }
4904
4905    #[test]
4906    fn detect_language_cpp_extension() {
4907        let lang = detect_language(Path::new("main.cpp"), None, &BTreeMap::new(), false);
4908        assert_eq!(lang, Some(Language::Cpp));
4909    }
4910
4911    #[test]
4912    fn detect_language_java_extension() {
4913        let lang = detect_language(Path::new("Main.java"), None, &BTreeMap::new(), false);
4914        assert_eq!(lang, Some(Language::Java));
4915    }
4916
4917    #[test]
4918    fn detect_language_makefile_exact_name() {
4919        let lang = detect_language(Path::new("Makefile"), None, &BTreeMap::new(), false);
4920        assert_eq!(lang, Some(Language::Makefile));
4921    }
4922
4923    #[test]
4924    fn detect_language_dockerfile_exact_name() {
4925        let lang = detect_language(Path::new("Dockerfile"), None, &BTreeMap::new(), false);
4926        assert_eq!(lang, Some(Language::Dockerfile));
4927    }
4928
4929    #[test]
4930    fn detect_language_rakefile() {
4931        let lang = detect_language(Path::new("Rakefile"), None, &BTreeMap::new(), false);
4932        assert_eq!(lang, Some(Language::Ruby));
4933    }
4934
4935    #[test]
4936    fn detect_language_gemfile() {
4937        let lang = detect_language(Path::new("Gemfile"), None, &BTreeMap::new(), false);
4938        assert_eq!(lang, Some(Language::Ruby));
4939    }
4940
4941    #[test]
4942    fn detect_language_unknown_extension_returns_none() {
4943        let lang = detect_language(Path::new("foo.xyz123"), None, &BTreeMap::new(), false);
4944        assert_eq!(lang, None);
4945    }
4946
4947    #[test]
4948    fn detect_language_extension_override() {
4949        let mut overrides = BTreeMap::new();
4950        overrides.insert("h".into(), "cpp".into());
4951        let lang = detect_language(Path::new("header.h"), None, &overrides, false);
4952        assert_eq!(lang, Some(Language::Cpp));
4953    }
4954
4955    #[test]
4956    fn detect_language_shebang_python() {
4957        let lang = detect_language(
4958            Path::new("script"),
4959            Some("#!/usr/bin/env python3"),
4960            &BTreeMap::new(),
4961            true,
4962        );
4963        assert_eq!(lang, Some(Language::Python));
4964    }
4965
4966    #[test]
4967    fn detect_language_shebang_bash() {
4968        let lang = detect_language(
4969            Path::new("script"),
4970            Some("#!/bin/bash"),
4971            &BTreeMap::new(),
4972            true,
4973        );
4974        assert_eq!(lang, Some(Language::Shell));
4975    }
4976
4977    #[test]
4978    fn detect_language_shebang_ruby() {
4979        let lang = detect_language(
4980            Path::new("script"),
4981            Some("#!/usr/bin/env ruby"),
4982            &BTreeMap::new(),
4983            true,
4984        );
4985        assert_eq!(lang, Some(Language::Ruby));
4986    }
4987
4988    #[test]
4989    fn detect_language_shebang_disabled() {
4990        // When shebang_detection=false, shebang is ignored
4991        let lang = detect_language(
4992            Path::new("script"),
4993            Some("#!/usr/bin/env python3"),
4994            &BTreeMap::new(),
4995            false,
4996        );
4997        assert_eq!(lang, None);
4998    }
4999
5000    #[test]
5001    fn from_name_rust() {
5002        assert_eq!(Language::from_name("rust"), Some(Language::Rust));
5003    }
5004
5005    #[test]
5006    fn from_name_python() {
5007        assert_eq!(Language::from_name("python"), Some(Language::Python));
5008    }
5009
5010    #[test]
5011    fn from_name_unknown() {
5012        assert_eq!(Language::from_name("brainfuck"), None);
5013    }
5014
5015    #[test]
5016    fn from_name_roundtrip_all() {
5017        // Every language's slug should round-trip through from_name
5018        for lang in [
5019            Language::C,
5020            Language::Cpp,
5021            Language::CSharp,
5022            Language::Go,
5023            Language::Java,
5024            Language::JavaScript,
5025            Language::Python,
5026            Language::Rust,
5027            Language::Shell,
5028            Language::PowerShell,
5029            Language::TypeScript,
5030            Language::Assembly,
5031            Language::Clojure,
5032            Language::Css,
5033            Language::Dart,
5034            Language::Dockerfile,
5035            Language::Elixir,
5036            Language::Erlang,
5037            Language::FSharp,
5038            Language::Groovy,
5039            Language::Haskell,
5040            Language::Html,
5041            Language::Julia,
5042            Language::Kotlin,
5043            Language::Lua,
5044            Language::Makefile,
5045            Language::Nim,
5046            Language::ObjectiveC,
5047            Language::Ocaml,
5048            Language::Perl,
5049            Language::Php,
5050            Language::R,
5051            Language::Ruby,
5052            Language::Scala,
5053            Language::Scss,
5054            Language::Sql,
5055            Language::Svelte,
5056            Language::Swift,
5057            Language::Vue,
5058            Language::Xml,
5059            Language::Zig,
5060            Language::Solidity,
5061            Language::Protobuf,
5062            Language::Hcl,
5063            Language::GraphQl,
5064            Language::Ada,
5065            Language::Vhdl,
5066            Language::Verilog,
5067            Language::Tcl,
5068            Language::Pascal,
5069            Language::VisualBasic,
5070            Language::Lisp,
5071            Language::Fortran,
5072            Language::Nix,
5073            Language::Crystal,
5074            Language::D,
5075            Language::Glsl,
5076            Language::Cmake,
5077            Language::Elm,
5078            Language::Awk,
5079        ] {
5080            let slug = lang.as_slug();
5081            let roundtripped = Language::from_name(slug);
5082            assert_eq!(
5083                roundtripped,
5084                Some(lang),
5085                "from_name({slug:?}) should return {lang:?}"
5086            );
5087        }
5088    }
5089
5090    // ── blank_in_block_comment_policy behavioral tests ───────────────────────
5091
5092    #[test]
5093    fn blank_in_block_comment_defaults_to_comment() {
5094        // Default: blank lines inside /* */ count as multi-comment lines (IEEE-aligned).
5095        let input = "/*\n\n*/";
5096        let opts = AnalysisOptions {
5097            blank_in_block_comment_as_comment: true,
5098            ..Default::default()
5099        };
5100        let result = analyze_text(Language::C, input, opts);
5101        assert_eq!(
5102            result.raw.multi_comment_only_lines, 3,
5103            "all 3 block-comment lines must count as multi-comment with CountAsComment policy"
5104        );
5105        assert_eq!(
5106            result.raw.blank_only_lines, 0,
5107            "no blank lines expected with CountAsComment policy"
5108        );
5109    }
5110
5111    #[test]
5112    fn blank_in_block_comment_counted_as_blank_when_policy_false() {
5113        // CountAsBlank: blank lines inside /* */ count as blank, not comment.
5114        let input = "/*\n\n*/";
5115        let opts = AnalysisOptions {
5116            blank_in_block_comment_as_comment: false,
5117            ..Default::default()
5118        };
5119        let result = analyze_text(Language::C, input, opts);
5120        assert_eq!(
5121            result.raw.multi_comment_only_lines, 2,
5122            "opener and closer must count as multi-comment with CountAsBlank policy"
5123        );
5124        assert_eq!(
5125            result.raw.blank_only_lines, 1,
5126            "the blank line inside the block comment must count as blank with CountAsBlank policy"
5127        );
5128    }
5129
5130    // ── continuation_line_policy behavioral tests ────────────────────────────
5131
5132    #[test]
5133    fn continuation_lines_each_physical_default() {
5134        // Default (EachPhysicalLine): every physical line counted separately.
5135        let input = "#define FOO \\\n  1 \\\n  + 2\n";
5136        let opts = AnalysisOptions {
5137            collapse_continuation_lines: false,
5138            ..Default::default()
5139        };
5140        let result = analyze_text(Language::C, input, opts);
5141        assert_eq!(
5142            result.raw.total_physical_lines, 3,
5143            "3 physical lines expected"
5144        );
5145        assert_eq!(
5146            result.raw.code_only_lines, 3,
5147            "each physical line must count as code with EachPhysicalLine policy"
5148        );
5149    }
5150
5151    #[test]
5152    fn continuation_lines_collapse_to_logical() {
5153        // CollapseToLogical: 3 backslash-continued lines collapse to 1 logical code line.
5154        let input = "#define FOO \\\n  1 \\\n  + 2\n";
5155        let opts = AnalysisOptions {
5156            collapse_continuation_lines: true,
5157            ..Default::default()
5158        };
5159        let result = analyze_text(Language::C, input, opts);
5160        assert_eq!(
5161            result.raw.total_physical_lines, 3,
5162            "physical line count is always 3 regardless of policy"
5163        );
5164        assert_eq!(
5165            result.raw.code_only_lines, 1,
5166            "3 continuation lines must collapse to 1 logical code line"
5167        );
5168    }
5169}