Skip to main content

sloc_languages/
lib.rs

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