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    pub fn display(self) -> &'static str {
25        match self {
26            Self::Tabs => "Tabs",
27            Self::Spaces2 => "2-Space",
28            Self::Spaces4 => "4-Space",
29            Self::Spaces8 => "8-Space",
30            Self::Mixed => "Mixed",
31            Self::Unknown => "\u{2014}",
32        }
33    }
34}
35
36// ─── Output types ─────────────────────────────────────────────────────────────
37
38/// An observable style signal specific to a language.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct StyleSignal {
41    /// Human-readable signal name, e.g. `"Quote Style"`.
42    pub name: String,
43    /// Detected value, e.g. `"Double quotes"`.
44    pub value: String,
45}
46
47/// Adherence percentage for one named style guide.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct StyleGuideScore {
50    pub name: String,
51    /// Key characteristics used in scoring (shown as a tooltip).
52    pub description: String,
53    /// Computed adherence, 0-100.
54    pub score_pct: u8,
55}
56
57/// Generic style analysis result — works for any supported language.
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct StyleAnalysis {
60    /// Language family label, e.g. `"C / C++"`, `"Python"`.
61    pub language_family: String,
62
63    // ── Common measured metrics ───────────────────────────────────────────
64    pub indent_style: IndentStyle,
65    pub tab_indented_lines: u32,
66    pub space2_indented_lines: u32,
67    pub space4_indented_lines: u32,
68    pub lines_over_80: u32,
69    pub lines_over_100: u32,
70    pub lines_over_120: u32,
71    pub max_line_length: u32,
72    pub total_lines: u32,
73
74    /// Language-specific observable signals for display.
75    pub signals: Vec<StyleSignal>,
76
77    // ── Style-guide scores ────────────────────────────────────────────────
78    pub guide_scores: Vec<StyleGuideScore>,
79    pub dominant_guide: String,
80    pub dominant_score_pct: u8,
81}
82
83// ─── Shared scan helpers ──────────────────────────────────────────────────────
84
85/// Classify one line's leading whitespace into the three indent counters.
86pub fn scan_indent(line: &str, tabs: &mut u32, sp2: &mut u32, sp4: &mut u32) {
87    let first = match line.chars().next() {
88        Some(c) => c,
89        None => 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.
110pub fn classify_indent(tabs: u32, sp2: u32, sp4: u32) -> IndentStyle {
111    let total = tabs + sp2 + sp4;
112    if total == 0 {
113        return IndentStyle::Unknown;
114    }
115    let tab_pct = tabs as f32 / total as f32;
116    let s2_pct = sp2 as f32 / total as f32;
117    let s4_pct = sp4 as f32 / total as f32;
118    if tab_pct >= 0.60 {
119        return IndentStyle::Tabs;
120    }
121    if s4_pct >= 0.60 {
122        return IndentStyle::Spaces4;
123    }
124    if s2_pct >= 0.60 {
125        return IndentStyle::Spaces2;
126    }
127    if sp4 > sp2 * 2 && sp4 > tabs {
128        return IndentStyle::Spaces4;
129    }
130    if sp2 > sp4 && sp2 > tabs {
131        return IndentStyle::Spaces2;
132    }
133    IndentStyle::Mixed
134}
135
136// ─── Scoring helpers ──────────────────────────────────────────────────────────
137
138/// Weighted average of feature values; each entry is (weight, value ∈ [0,1]).
139pub fn weighted_score(features: &[(f32, f32)]) -> u8 {
140    let s: f32 = features.iter().map(|(w, v)| w * v).sum();
141    (s * 100.0).round().clamp(0.0, 100.0) as u8
142}
143
144pub fn score_indent_2(s: IndentStyle) -> f32 {
145    match s {
146        IndentStyle::Spaces2 => 1.0,
147        IndentStyle::Mixed => 0.35,
148        _ => 0.05,
149    }
150}
151
152pub fn score_indent_4(s: IndentStyle) -> f32 {
153    match s {
154        IndentStyle::Spaces4 => 1.0,
155        IndentStyle::Mixed => 0.35,
156        _ => 0.05,
157    }
158}
159
160pub fn score_indent_tabs(s: IndentStyle) -> f32 {
161    match s {
162        IndentStyle::Tabs => 1.0,
163        IndentStyle::Mixed => 0.20,
164        _ => 0.05,
165    }
166}
167
168/// Score compliance with an 80-column limit.
169pub fn score_line80(over: u32, total: u32) -> f32 {
170    if total == 0 {
171        return 1.0;
172    }
173    let p = over as f32 / total as f32;
174    if p < 0.02 {
175        1.00
176    } else if p < 0.08 {
177        0.75
178    } else if p < 0.20 {
179        0.45
180    } else {
181        0.10
182    }
183}
184
185/// Score compliance with a 88-column limit (Black).
186pub fn score_line88(over88: u32, total: u32) -> f32 {
187    score_line_n(over88, total)
188}
189
190/// Score compliance with a 100-column limit.
191pub fn score_line100(over100: u32, total: u32) -> f32 {
192    score_line_n(over100, total)
193}
194
195/// Score compliance with a 120-column limit.
196pub fn score_line120(over120: u32, total: u32) -> f32 {
197    score_line_n(over120, total)
198}
199
200fn score_line_n(over: u32, total: u32) -> f32 {
201    if total == 0 {
202        return 1.0;
203    }
204    let p = over as f32 / total as f32;
205    if p < 0.03 {
206        1.00
207    } else if p < 0.10 {
208        0.75
209    } else if p < 0.25 {
210        0.45
211    } else {
212        0.10
213    }
214}
215
216/// Count lines over a given length threshold.
217pub fn count_over(lines: &[&str], limit: usize) -> u32 {
218    lines.iter().filter(|l| l.len() > limit).count() as u32
219}