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
200pub fn 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}
220
221// ─── Shared analysis helpers ──────────────────────────────────────────────────
222
223/// Return the guide with the highest score, or `("Unknown", 0)` for an empty slice.
224pub fn top_guide(scores: &[StyleGuideScore]) -> (String, u8) {
225    scores
226        .iter()
227        .max_by_key(|s| s.score_pct)
228        .map(|s| (s.name.clone(), s.score_pct))
229        .unwrap_or_else(|| ("Unknown".into(), 0))
230}
231
232// ─── Base metrics ─────────────────────────────────────────────────────────────
233
234/// Metrics computed identically across every language analyser.
235pub struct BaseMetrics {
236    pub tabs: u32,
237    pub sp2: u32,
238    pub sp4: u32,
239    pub over80: u32,
240    pub over100: u32,
241    pub over120: u32,
242    pub max_len: u32,
243    pub total: u32,
244}
245
246/// Single-pass scan that fills all language-neutral metrics.
247pub fn scan_base_metrics(lines: &[&str]) -> BaseMetrics {
248    let over80 = count_over(lines, 80);
249    let over100 = count_over(lines, 100);
250    let over120 = count_over(lines, 120);
251    let max_len = lines.iter().map(|l| l.len() as u32).max().unwrap_or(0);
252    let total = lines.len() as u32;
253    let mut tabs = 0u32;
254    let mut sp2 = 0u32;
255    let mut sp4 = 0u32;
256    for line in lines {
257        scan_indent(line, &mut tabs, &mut sp2, &mut sp4);
258    }
259    BaseMetrics {
260        tabs,
261        sp2,
262        sp4,
263        over80,
264        over100,
265        over120,
266        max_len,
267        total,
268    }
269}
270
271/// Count the first quote character (`'` or `"`) on a line.
272/// At most one counter is incremented per call.
273pub fn count_first_quote(trimmed: &str, single_q: &mut u32, double_q: &mut u32) {
274    for ch in trimmed.chars() {
275        if ch == '\'' {
276            *single_q += 1;
277            break;
278        }
279        if ch == '"' {
280            *double_q += 1;
281            break;
282        }
283    }
284}
285
286// ─── Shared brace-style helpers ───────────────────────────────────────────────
287
288/// Brace placement style shared across C, C++, Java, C#, and similar languages.
289#[derive(Clone, Copy, PartialEq, Eq)]
290pub enum BraceStyle {
291    Attach,
292    Allman,
293    Mixed,
294    Unknown,
295}
296
297impl BraceStyle {
298    pub fn display(self) -> &'static str {
299        match self {
300            Self::Attach => "K&R / Attach",
301            Self::Allman => "Allman",
302            Self::Mixed => "Mixed",
303            Self::Unknown => "\u{2014}",
304        }
305    }
306}
307
308/// Classify accumulated allman/attach counts into a dominant brace style.
309pub fn classify_brace(allman: u32, attach: u32) -> BraceStyle {
310    let t = allman + attach;
311    if t == 0 {
312        return BraceStyle::Unknown;
313    }
314    let a = allman as f32 / t as f32;
315    let k = attach as f32 / t as f32;
316    if a >= 0.65 {
317        BraceStyle::Allman
318    } else if k >= 0.65 {
319        BraceStyle::Attach
320    } else {
321        BraceStyle::Mixed
322    }
323}
324
325/// Score compliance with K&R / attach brace style.
326pub fn score_attach_brace(b: BraceStyle) -> f32 {
327    match b {
328        BraceStyle::Attach => 1.0,
329        BraceStyle::Mixed => 0.40,
330        BraceStyle::Allman => 0.05,
331        BraceStyle::Unknown => 0.50,
332    }
333}
334
335/// Score compliance with Allman brace style.
336pub fn score_allman_brace(b: BraceStyle) -> f32 {
337    match b {
338        BraceStyle::Allman => 1.0,
339        BraceStyle::Mixed => 0.40,
340        BraceStyle::Attach => 0.05,
341        BraceStyle::Unknown => 0.50,
342    }
343}
344
345impl StyleAnalysis {
346    /// Construct a `StyleAnalysis` from base metrics, signals, and guide scores.
347    /// Computes `dominant_guide` / `dominant_score_pct` internally.
348    pub fn assemble(
349        language_family: &str,
350        indent: IndentStyle,
351        m: &BaseMetrics,
352        signals: Vec<StyleSignal>,
353        guides: Vec<StyleGuideScore>,
354    ) -> Self {
355        let (dominant, dominant_pct) = top_guide(&guides);
356        Self {
357            language_family: language_family.into(),
358            indent_style: indent,
359            tab_indented_lines: m.tabs,
360            space2_indented_lines: m.sp2,
361            space4_indented_lines: m.sp4,
362            lines_over_80: m.over80,
363            lines_over_100: m.over100,
364            lines_over_120: m.over120,
365            max_line_length: m.max_len,
366            total_lines: m.total,
367            signals,
368            guide_scores: guides,
369            dominant_guide: dominant,
370            dominant_score_pct: dominant_pct,
371        }
372    }
373}