Skip to main content

sloc_languages/style/
common.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4//! Shared types, helpers, and scoring utilities for all language style analysers.
5
6use serde::{Deserialize, Serialize};
7
8// ─── Common signal enums ──────────────────────────────────────────────────────
9
10/// Detected leading-whitespace style.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
12#[serde(rename_all = "snake_case")]
13pub enum IndentStyle {
14    Tabs,
15    Spaces2,
16    Spaces4,
17    Spaces8,
18    Mixed,
19    #[default]
20    Unknown,
21}
22
23impl IndentStyle {
24    #[must_use]
25    pub const fn display(self) -> &'static str {
26        match self {
27            Self::Tabs => "Tabs",
28            Self::Spaces2 => "2-Space",
29            Self::Spaces4 => "4-Space",
30            Self::Spaces8 => "8-Space",
31            Self::Mixed => "Mixed",
32            Self::Unknown => "\u{2014}",
33        }
34    }
35}
36
37// ─── Output types ─────────────────────────────────────────────────────────────
38
39/// An observable style signal specific to a language.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct StyleSignal {
42    /// Human-readable signal name, e.g. `"Quote Style"`.
43    pub name: String,
44    /// Detected value, e.g. `"Double quotes"`.
45    pub value: String,
46}
47
48/// Adherence percentage for one named style guide.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct StyleGuideScore {
51    pub name: String,
52    /// Key characteristics used in scoring (shown as a tooltip).
53    pub description: String,
54    /// Computed adherence, 0-100.
55    pub score_pct: u8,
56}
57
58/// Generic style analysis result — works for any supported language.
59#[derive(Debug, Clone, Serialize, Deserialize, Default)]
60pub struct StyleAnalysis {
61    /// Language family label, e.g. `"C / C++"`, `"Python"`.
62    pub language_family: String,
63
64    // ── Common measured metrics ───────────────────────────────────────────
65    pub indent_style: IndentStyle,
66    pub tab_indented_lines: u32,
67    pub space2_indented_lines: u32,
68    pub space4_indented_lines: u32,
69    pub lines_over_80: u32,
70    pub lines_over_100: u32,
71    pub lines_over_120: u32,
72    pub max_line_length: u32,
73    pub total_lines: u32,
74
75    /// Language-specific observable signals for display.
76    pub signals: Vec<StyleSignal>,
77
78    // ── Style-guide scores ────────────────────────────────────────────────
79    pub guide_scores: Vec<StyleGuideScore>,
80    pub dominant_guide: String,
81    pub dominant_score_pct: u8,
82}
83
84// ─── Shared scan helpers ──────────────────────────────────────────────────────
85
86/// Classify one line's leading whitespace into the three indent counters.
87pub fn scan_indent(line: &str, tabs: &mut u32, sp2: &mut u32, sp4: &mut u32) {
88    let Some(first) = line.chars().next() else {
89        return;
90    };
91    if first == '\t' {
92        *tabs += 1;
93        return;
94    }
95    if first != ' ' {
96        return;
97    }
98    let leading = line.bytes().take_while(|&b| b == b' ').count();
99    if leading == 0 {
100        return;
101    }
102    if leading % 4 == 0 {
103        *sp4 += 1;
104    } else if leading % 2 == 0 {
105        *sp2 += 1;
106    }
107}
108
109/// Classify accumulated indent counts into a dominant style.
110#[must_use]
111pub fn classify_indent(tabs: u32, sp2: u32, sp4: u32) -> IndentStyle {
112    let total = tabs + sp2 + sp4;
113    if total == 0 {
114        return IndentStyle::Unknown;
115    }
116    let tab_pct = f64::from(tabs) / f64::from(total);
117    let s2_pct = f64::from(sp2) / f64::from(total);
118    let s4_pct = f64::from(sp4) / f64::from(total);
119    if tab_pct >= 0.60 {
120        return IndentStyle::Tabs;
121    }
122    if s4_pct >= 0.60 {
123        return IndentStyle::Spaces4;
124    }
125    if s2_pct >= 0.60 {
126        return IndentStyle::Spaces2;
127    }
128    if sp4 > sp2 * 2 && sp4 > tabs {
129        return IndentStyle::Spaces4;
130    }
131    if sp2 > sp4 && sp2 > tabs {
132        return IndentStyle::Spaces2;
133    }
134    IndentStyle::Mixed
135}
136
137// ─── Scoring helpers ──────────────────────────────────────────────────────────
138
139/// Weighted average of feature values; each entry is (weight, value ∈ [0,1]).
140#[must_use]
141#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // clamped 0..=100
142pub fn weighted_score(features: &[(f32, f32)]) -> u8 {
143    let s: f32 = features.iter().map(|(w, v)| w * v).sum();
144    (s * 100.0).round().clamp(0.0, 100.0) as u8
145}
146
147#[must_use]
148pub const fn score_indent_2(s: IndentStyle) -> f32 {
149    match s {
150        IndentStyle::Spaces2 => 1.0,
151        IndentStyle::Mixed => 0.35,
152        _ => 0.05,
153    }
154}
155
156#[must_use]
157pub const fn score_indent_4(s: IndentStyle) -> f32 {
158    match s {
159        IndentStyle::Spaces4 => 1.0,
160        IndentStyle::Mixed => 0.35,
161        _ => 0.05,
162    }
163}
164
165#[must_use]
166pub const fn score_indent_tabs(s: IndentStyle) -> f32 {
167    match s {
168        IndentStyle::Tabs => 1.0,
169        IndentStyle::Mixed => 0.20,
170        _ => 0.05,
171    }
172}
173
174/// Score compliance with an 80-column limit.
175#[must_use]
176pub fn score_line80(over: u32, total: u32) -> f32 {
177    if total == 0 {
178        return 1.0;
179    }
180    let p = f64::from(over) / f64::from(total);
181    if p < 0.02 {
182        1.00
183    } else if p < 0.08 {
184        0.75
185    } else if p < 0.20 {
186        0.45
187    } else {
188        0.10
189    }
190}
191
192/// Score compliance with a 88-column limit (Black).
193#[must_use]
194pub fn score_line88(over88: u32, total: u32) -> f32 {
195    score_line_n(over88, total)
196}
197
198/// Score compliance with a 100-column limit.
199#[must_use]
200pub fn score_line100(over100: u32, total: u32) -> f32 {
201    score_line_n(over100, total)
202}
203
204/// Score compliance with a 120-column limit.
205#[must_use]
206pub fn score_line120(over120: u32, total: u32) -> f32 {
207    score_line_n(over120, total)
208}
209
210#[must_use]
211pub fn score_line_n(over: u32, total: u32) -> f32 {
212    if total == 0 {
213        return 1.0;
214    }
215    let p = f64::from(over) / f64::from(total);
216    if p < 0.03 {
217        1.00
218    } else if p < 0.10 {
219        0.75
220    } else if p < 0.25 {
221        0.45
222    } else {
223        0.10
224    }
225}
226
227/// Count lines over a given length threshold.
228#[must_use]
229pub fn count_over(lines: &[&str], limit: usize) -> u32 {
230    u32::try_from(lines.iter().filter(|l| l.len() > limit).count()).unwrap_or(u32::MAX)
231}
232
233// ─── Shared analysis helpers ──────────────────────────────────────────────────
234
235/// Return the guide with the highest score, or `("Unknown", 0)` for an empty slice.
236#[must_use]
237pub fn top_guide(scores: &[StyleGuideScore]) -> (String, u8) {
238    scores
239        .iter()
240        .max_by_key(|s| s.score_pct)
241        .map_or_else(|| ("Unknown".into(), 0), |s| (s.name.clone(), s.score_pct))
242}
243
244// ─── Base metrics ─────────────────────────────────────────────────────────────
245
246/// Metrics computed identically across every language analyser.
247pub struct BaseMetrics {
248    pub tabs: u32,
249    pub sp2: u32,
250    pub sp4: u32,
251    pub over80: u32,
252    pub over100: u32,
253    pub over120: u32,
254    pub max_len: u32,
255    pub total: u32,
256}
257
258/// Single-pass scan that fills all language-neutral metrics.
259#[must_use]
260pub fn scan_base_metrics(lines: &[&str]) -> BaseMetrics {
261    let over80 = count_over(lines, 80);
262    let over100 = count_over(lines, 100);
263    let over120 = count_over(lines, 120);
264    let max_len = lines
265        .iter()
266        .map(|l| u32::try_from(l.len()).unwrap_or(u32::MAX))
267        .max()
268        .unwrap_or(0);
269    let total = u32::try_from(lines.len()).unwrap_or(u32::MAX);
270    let mut tabs = 0u32;
271    let mut sp2 = 0u32;
272    let mut sp4 = 0u32;
273    for line in lines {
274        scan_indent(line, &mut tabs, &mut sp2, &mut sp4);
275    }
276    BaseMetrics {
277        tabs,
278        sp2,
279        sp4,
280        over80,
281        over100,
282        over120,
283        max_len,
284        total,
285    }
286}
287
288/// Count the first quote character (`'` or `"`) on a line.
289/// At most one counter is incremented per call.
290pub fn count_first_quote(trimmed: &str, single_q: &mut u32, double_q: &mut u32) {
291    for ch in trimmed.chars() {
292        if ch == '\'' {
293            *single_q += 1;
294            break;
295        }
296        if ch == '"' {
297            *double_q += 1;
298            break;
299        }
300    }
301}
302
303// ─── Shared brace-style helpers ───────────────────────────────────────────────
304
305/// Brace placement style shared across C, C++, Java, C#, and similar languages.
306#[derive(Clone, Copy, PartialEq, Eq)]
307pub enum BraceStyle {
308    Attach,
309    Allman,
310    Mixed,
311    Unknown,
312}
313
314impl BraceStyle {
315    #[must_use]
316    pub const fn display(self) -> &'static str {
317        match self {
318            Self::Attach => "K&R / Attach",
319            Self::Allman => "Allman",
320            Self::Mixed => "Mixed",
321            Self::Unknown => "\u{2014}",
322        }
323    }
324}
325
326/// Classify accumulated allman/attach counts into a dominant brace style.
327#[must_use]
328pub fn classify_brace(allman: u32, attach: u32) -> BraceStyle {
329    let t = allman + attach;
330    if t == 0 {
331        return BraceStyle::Unknown;
332    }
333    let a = f64::from(allman) / f64::from(t);
334    let k = f64::from(attach) / f64::from(t);
335    if a >= 0.65 {
336        BraceStyle::Allman
337    } else if k >= 0.65 {
338        BraceStyle::Attach
339    } else {
340        BraceStyle::Mixed
341    }
342}
343
344/// Score compliance with K&R / attach brace style.
345#[must_use]
346pub const fn score_attach_brace(b: BraceStyle) -> f32 {
347    match b {
348        BraceStyle::Attach => 1.0,
349        BraceStyle::Mixed => 0.40,
350        BraceStyle::Allman => 0.05,
351        BraceStyle::Unknown => 0.50,
352    }
353}
354
355/// Score compliance with Allman brace style.
356#[must_use]
357pub const fn score_allman_brace(b: BraceStyle) -> f32 {
358    match b {
359        BraceStyle::Allman => 1.0,
360        BraceStyle::Mixed => 0.40,
361        BraceStyle::Attach => 0.05,
362        BraceStyle::Unknown => 0.50,
363    }
364}
365
366impl StyleAnalysis {
367    /// Construct a `StyleAnalysis` from base metrics, signals, and guide scores.
368    /// Computes `dominant_guide` / `dominant_score_pct` internally.
369    #[must_use]
370    pub fn assemble(
371        language_family: &str,
372        indent: IndentStyle,
373        m: &BaseMetrics,
374        signals: Vec<StyleSignal>,
375        guides: Vec<StyleGuideScore>,
376    ) -> Self {
377        let (dominant, dominant_pct) = top_guide(&guides);
378        Self {
379            language_family: language_family.into(),
380            indent_style: indent,
381            tab_indented_lines: m.tabs,
382            space2_indented_lines: m.sp2,
383            space4_indented_lines: m.sp4,
384            lines_over_80: m.over80,
385            lines_over_100: m.over100,
386            lines_over_120: m.over120,
387            max_line_length: m.max_len,
388            total_lines: m.total,
389            signals,
390            guide_scores: guides,
391            dominant_guide: dominant,
392            dominant_score_pct: dominant_pct,
393        }
394    }
395}
396
397// ─── Tests ───────────────────────────────────────────────────────────────────
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    // ── IndentStyle::display ─────────────────────────────────────────────────
404
405    #[test]
406    fn indent_style_display_all_variants() {
407        assert_eq!(IndentStyle::Tabs.display(), "Tabs");
408        assert_eq!(IndentStyle::Spaces2.display(), "2-Space");
409        assert_eq!(IndentStyle::Spaces4.display(), "4-Space");
410        assert_eq!(IndentStyle::Spaces8.display(), "8-Space");
411        assert_eq!(IndentStyle::Mixed.display(), "Mixed");
412        // Unknown renders as em-dash
413        assert!(!IndentStyle::Unknown.display().is_empty());
414    }
415
416    // ── scan_indent ──────────────────────────────────────────────────────────
417
418    #[test]
419    fn scan_indent_tab_line() {
420        let mut tabs = 0u32;
421        let mut sp2 = 0u32;
422        let mut sp4 = 0u32;
423        scan_indent("\treturn x;", &mut tabs, &mut sp2, &mut sp4);
424        assert_eq!(tabs, 1);
425        assert_eq!(sp2, 0);
426        assert_eq!(sp4, 0);
427    }
428
429    #[test]
430    fn scan_indent_two_space_line() {
431        let mut tabs = 0u32;
432        let mut sp2 = 0u32;
433        let mut sp4 = 0u32;
434        scan_indent("  let x = 1;", &mut tabs, &mut sp2, &mut sp4);
435        assert_eq!(tabs, 0);
436        assert_eq!(sp2, 1);
437        assert_eq!(sp4, 0);
438    }
439
440    #[test]
441    fn scan_indent_four_space_line() {
442        let mut tabs = 0u32;
443        let mut sp2 = 0u32;
444        let mut sp4 = 0u32;
445        scan_indent("    let x = 1;", &mut tabs, &mut sp2, &mut sp4);
446        assert_eq!(tabs, 0);
447        assert_eq!(sp2, 0);
448        assert_eq!(sp4, 1);
449    }
450
451    #[test]
452    fn scan_indent_eight_space_line() {
453        let mut tabs = 0u32;
454        let mut sp2 = 0u32;
455        let mut sp4 = 0u32;
456        scan_indent("        let x = 1;", &mut tabs, &mut sp2, &mut sp4);
457        // 8 spaces: 8 % 4 == 0, so counts as sp4
458        assert_eq!(sp4, 1);
459    }
460
461    #[test]
462    fn scan_indent_empty_line_no_change() {
463        let mut tabs = 0u32;
464        let mut sp2 = 0u32;
465        let mut sp4 = 0u32;
466        scan_indent("", &mut tabs, &mut sp2, &mut sp4);
467        assert_eq!(tabs, 0);
468        assert_eq!(sp2, 0);
469        assert_eq!(sp4, 0);
470    }
471
472    #[test]
473    fn scan_indent_no_leading_whitespace() {
474        let mut tabs = 0u32;
475        let mut sp2 = 0u32;
476        let mut sp4 = 0u32;
477        scan_indent("let x = 1;", &mut tabs, &mut sp2, &mut sp4);
478        assert_eq!(tabs, 0);
479        assert_eq!(sp2, 0);
480        assert_eq!(sp4, 0);
481    }
482
483    #[test]
484    fn scan_indent_three_spaces_not_counted() {
485        // 3 spaces: not divisible by 2 or 4 → nothing incremented
486        let mut tabs = 0u32;
487        let mut sp2 = 0u32;
488        let mut sp4 = 0u32;
489        scan_indent("   let x = 1;", &mut tabs, &mut sp2, &mut sp4);
490        assert_eq!(tabs, 0);
491        assert_eq!(sp2, 0);
492        assert_eq!(sp4, 0);
493    }
494
495    // ── classify_indent ──────────────────────────────────────────────────────
496
497    #[test]
498    fn classify_indent_all_zero_returns_unknown() {
499        assert_eq!(classify_indent(0, 0, 0), IndentStyle::Unknown);
500    }
501
502    #[test]
503    fn classify_indent_dominant_tabs() {
504        // 10 tabs, 1 sp2, 1 sp4 → 83% tabs → Tabs
505        assert_eq!(classify_indent(10, 1, 1), IndentStyle::Tabs);
506    }
507
508    #[test]
509    fn classify_indent_dominant_spaces4() {
510        assert_eq!(classify_indent(0, 0, 10), IndentStyle::Spaces4);
511    }
512
513    #[test]
514    fn classify_indent_dominant_spaces2() {
515        assert_eq!(classify_indent(0, 10, 0), IndentStyle::Spaces2);
516    }
517
518    #[test]
519    fn classify_indent_mixed_when_evenly_split() {
520        // No clear dominant
521        assert_eq!(classify_indent(3, 3, 3), IndentStyle::Mixed);
522    }
523
524    #[test]
525    fn classify_indent_spaces4_when_sp4_greatly_exceeds_sp2() {
526        // sp4 > sp2*2 and sp4 > tabs → Spaces4
527        assert_eq!(classify_indent(0, 1, 10), IndentStyle::Spaces4);
528    }
529
530    #[test]
531    fn classify_indent_spaces2_when_sp2_exceeds_others() {
532        // sp2 > sp4 and sp2 > tabs but not 60% threshold
533        assert_eq!(classify_indent(0, 5, 2), IndentStyle::Spaces2);
534    }
535
536    // ── score_indent helpers ─────────────────────────────────────────────────
537
538    #[test]
539    fn score_indent_2_perfect() {
540        assert!((super::score_indent_2(IndentStyle::Spaces2) - 1.0).abs() < f32::EPSILON);
541    }
542
543    #[test]
544    fn score_indent_2_mixed() {
545        assert!((super::score_indent_2(IndentStyle::Mixed) - 0.35).abs() < 0.01);
546    }
547
548    #[test]
549    fn score_indent_4_perfect() {
550        assert!((super::score_indent_4(IndentStyle::Spaces4) - 1.0).abs() < f32::EPSILON);
551    }
552
553    #[test]
554    fn score_indent_tabs_perfect() {
555        assert!((super::score_indent_tabs(IndentStyle::Tabs) - 1.0).abs() < f32::EPSILON);
556    }
557
558    #[test]
559    fn score_indent_tabs_mixed() {
560        assert!((super::score_indent_tabs(IndentStyle::Mixed) - 0.20).abs() < 0.01);
561    }
562
563    // ── score_line80 ─────────────────────────────────────────────────────────
564
565    #[test]
566    fn score_line80_zero_total_is_perfect() {
567        assert!((score_line80(0, 0) - 1.0).abs() < f32::EPSILON);
568    }
569
570    #[test]
571    fn score_line80_very_few_violations() {
572        // < 2% over 80 → 1.0
573        let score = score_line80(1, 100);
574        assert!((score - 1.0).abs() < f32::EPSILON);
575    }
576
577    #[test]
578    fn score_line80_some_violations() {
579        // 5% → between 0.02 and 0.08 → 0.75
580        let score = score_line80(5, 100);
581        assert!((score - 0.75).abs() < 0.01);
582    }
583
584    #[test]
585    fn score_line80_many_violations() {
586        // 15% → between 0.08 and 0.20 → 0.45
587        let score = score_line80(15, 100);
588        assert!((score - 0.45).abs() < 0.01);
589    }
590
591    #[test]
592    fn score_line80_excessive_violations() {
593        // 30% → >= 0.20 → 0.10
594        let score = score_line80(30, 100);
595        assert!((score - 0.10).abs() < 0.01);
596    }
597
598    // ── score_line_n variants ────────────────────────────────────────────────
599
600    #[test]
601    fn score_line_n_zero_total_is_perfect() {
602        assert!((score_line_n(0, 0) - 1.0).abs() < f32::EPSILON);
603        assert!((score_line100(0, 0) - 1.0).abs() < f32::EPSILON);
604        assert!((score_line120(0, 0) - 1.0).abs() < f32::EPSILON);
605        assert!((score_line88(0, 0) - 1.0).abs() < f32::EPSILON);
606    }
607
608    #[test]
609    fn score_line_n_low_violations_is_perfect() {
610        // < 3% → 1.0
611        let score = score_line_n(2, 100);
612        assert!((score - 1.0).abs() < f32::EPSILON);
613    }
614
615    #[test]
616    fn score_line_n_moderate_violations() {
617        // 7% → between 0.03 and 0.10 → 0.75
618        let score = score_line_n(7, 100);
619        assert!((score - 0.75).abs() < 0.01);
620    }
621
622    #[test]
623    fn score_line_n_high_violations() {
624        // 15% → between 0.10 and 0.25 → 0.45
625        let score = score_line_n(15, 100);
626        assert!((score - 0.45).abs() < 0.01);
627    }
628
629    #[test]
630    fn score_line_n_very_high_violations() {
631        // 30% → >= 0.25 → 0.10
632        let score = score_line_n(30, 100);
633        assert!((score - 0.10).abs() < 0.01);
634    }
635
636    // ── count_over ───────────────────────────────────────────────────────────
637
638    #[test]
639    fn count_over_counts_lines_exceeding_limit() {
640        let lines = vec![
641            "short",
642            "a longer line that exceeds eighty characters in total when measured carefully",
643            "tiny",
644        ];
645        let count = count_over(&lines, 20);
646        assert_eq!(count, 1);
647    }
648
649    #[test]
650    fn count_over_none_exceeding_returns_zero() {
651        let lines = vec!["hi", "ok", "yes"];
652        assert_eq!(count_over(&lines, 80), 0);
653    }
654
655    #[test]
656    fn count_over_empty_slice_returns_zero() {
657        assert_eq!(count_over(&[], 80), 0);
658    }
659
660    // ── top_guide ────────────────────────────────────────────────────────────
661
662    #[test]
663    fn top_guide_returns_highest_scoring_guide() {
664        let guides = vec![
665            StyleGuideScore {
666                name: "A".into(),
667                description: String::new(),
668                score_pct: 70,
669            },
670            StyleGuideScore {
671                name: "B".into(),
672                description: String::new(),
673                score_pct: 95,
674            },
675            StyleGuideScore {
676                name: "C".into(),
677                description: String::new(),
678                score_pct: 80,
679            },
680        ];
681        let (name, score) = top_guide(&guides);
682        assert_eq!(name, "B");
683        assert_eq!(score, 95);
684    }
685
686    #[test]
687    fn top_guide_empty_slice_returns_unknown() {
688        let (name, score) = top_guide(&[]);
689        assert_eq!(name, "Unknown");
690        assert_eq!(score, 0);
691    }
692
693    // ── scan_base_metrics ────────────────────────────────────────────────────
694
695    #[test]
696    fn scan_base_metrics_all_fields() {
697        let lines = vec![
698            "    let x = 1;",      // 4-space indent
699            "\tlet y = 2;",        // tab indent
700            "  return z;",         // 2-space indent
701            "x".repeat(90).leak(), // > 80 chars
702            "short",
703        ];
704        let m = scan_base_metrics(&lines);
705        assert_eq!(m.total, 5);
706        assert!(m.tabs >= 1);
707        assert!(m.sp4 >= 1);
708        assert!(m.sp2 >= 1);
709        assert!(m.over80 >= 1);
710        assert_eq!(m.over100, 0);
711        assert_eq!(m.over120, 0);
712        assert!(m.max_len >= 90);
713    }
714
715    #[test]
716    fn scan_base_metrics_empty_input() {
717        let m = scan_base_metrics(&[]);
718        assert_eq!(m.total, 0);
719        assert_eq!(m.max_len, 0);
720    }
721
722    // ── count_first_quote ────────────────────────────────────────────────────
723
724    #[test]
725    fn count_first_quote_single_quote_counted() {
726        let mut sq = 0u32;
727        let mut dq = 0u32;
728        count_first_quote("let x = 'hello';", &mut sq, &mut dq);
729        assert_eq!(sq, 1);
730        assert_eq!(dq, 0);
731    }
732
733    #[test]
734    fn count_first_quote_double_quote_counted() {
735        let mut sq = 0u32;
736        let mut dq = 0u32;
737        count_first_quote("let x = \"hello\";", &mut sq, &mut dq);
738        assert_eq!(sq, 0);
739        assert_eq!(dq, 1);
740    }
741
742    #[test]
743    fn count_first_quote_no_quote_no_change() {
744        let mut sq = 0u32;
745        let mut dq = 0u32;
746        count_first_quote("let x = 42;", &mut sq, &mut dq);
747        assert_eq!(sq, 0);
748        assert_eq!(dq, 0);
749    }
750
751    #[test]
752    fn count_first_quote_only_increments_first_found() {
753        // Line has single quote first, then double → only single counted
754        let mut sq = 0u32;
755        let mut dq = 0u32;
756        count_first_quote("'a' + \"b\"", &mut sq, &mut dq);
757        assert_eq!(sq, 1);
758        assert_eq!(dq, 0);
759    }
760
761    // ── BraceStyle::display ──────────────────────────────────────────────────
762
763    #[test]
764    fn brace_style_display_all_variants() {
765        assert_eq!(BraceStyle::Attach.display(), "K&R / Attach");
766        assert_eq!(BraceStyle::Allman.display(), "Allman");
767        assert_eq!(BraceStyle::Mixed.display(), "Mixed");
768        assert!(!BraceStyle::Unknown.display().is_empty());
769    }
770
771    // ── classify_brace ───────────────────────────────────────────────────────
772
773    #[test]
774    fn classify_brace_unknown_when_both_zero() {
775        assert!(matches!(classify_brace(0, 0), BraceStyle::Unknown));
776    }
777
778    #[test]
779    fn classify_brace_allman_dominant() {
780        // 7 allman vs 3 attach → 70% allman → Allman
781        assert!(matches!(classify_brace(7, 3), BraceStyle::Allman));
782    }
783
784    #[test]
785    fn classify_brace_attach_dominant() {
786        assert!(matches!(classify_brace(2, 8), BraceStyle::Attach));
787    }
788
789    #[test]
790    fn classify_brace_mixed_when_even() {
791        assert!(matches!(classify_brace(5, 5), BraceStyle::Mixed));
792    }
793
794    // ── score_attach_brace and score_allman_brace ────────────────────────────
795
796    #[test]
797    fn score_attach_brace_all_variants() {
798        assert!((score_attach_brace(BraceStyle::Attach) - 1.0).abs() < f32::EPSILON);
799        assert!((score_attach_brace(BraceStyle::Mixed) - 0.40).abs() < 0.01);
800        assert!((score_attach_brace(BraceStyle::Allman) - 0.05).abs() < 0.01);
801        assert!((score_attach_brace(BraceStyle::Unknown) - 0.50).abs() < 0.01);
802    }
803
804    #[test]
805    fn score_allman_brace_all_variants() {
806        assert!((score_allman_brace(BraceStyle::Allman) - 1.0).abs() < f32::EPSILON);
807        assert!((score_allman_brace(BraceStyle::Mixed) - 0.40).abs() < 0.01);
808        assert!((score_allman_brace(BraceStyle::Attach) - 0.05).abs() < 0.01);
809        assert!((score_allman_brace(BraceStyle::Unknown) - 0.50).abs() < 0.01);
810    }
811
812    // ── weighted_score ───────────────────────────────────────────────────────
813
814    #[test]
815    fn weighted_score_single_feature_perfect() {
816        let score = weighted_score(&[(1.0, 1.0)]);
817        assert_eq!(score, 100);
818    }
819
820    #[test]
821    fn weighted_score_single_feature_zero() {
822        let score = weighted_score(&[(1.0, 0.0)]);
823        assert_eq!(score, 0);
824    }
825
826    #[test]
827    fn weighted_score_mixed_features() {
828        // 0.5 * 1.0 + 0.5 * 0.0 = 0.5 → 50%
829        let score = weighted_score(&[(0.5, 1.0), (0.5, 0.0)]);
830        assert_eq!(score, 50);
831    }
832
833    #[test]
834    fn weighted_score_clamped_to_100() {
835        // Weight sum > 1.0 would push above 100 but it is clamped
836        let score = weighted_score(&[(2.0, 1.0)]);
837        assert_eq!(score, 100);
838    }
839
840    #[test]
841    fn weighted_score_empty_features_is_zero() {
842        assert_eq!(weighted_score(&[]), 0);
843    }
844
845    // ── StyleAnalysis::assemble ──────────────────────────────────────────────
846
847    #[test]
848    fn style_analysis_assemble_sets_dominant_guide() {
849        let m = scan_base_metrics(&["    let x = 1;", "\tlet y = 2;"]);
850        let indent = classify_indent(m.tabs, m.sp2, m.sp4);
851        let guides = vec![
852            StyleGuideScore {
853                name: "Guide A".into(),
854                description: "desc".into(),
855                score_pct: 60,
856            },
857            StyleGuideScore {
858                name: "Guide B".into(),
859                description: "desc".into(),
860                score_pct: 85,
861            },
862        ];
863        let analysis = StyleAnalysis::assemble("TestLang", indent, &m, vec![], guides);
864        assert_eq!(analysis.language_family, "TestLang");
865        assert_eq!(analysis.dominant_guide, "Guide B");
866        assert_eq!(analysis.dominant_score_pct, 85);
867        assert_eq!(analysis.total_lines, 2);
868    }
869}