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