Skip to main content

dictator_golang/
lib.rs

1#![warn(rust_2024_compatibility, clippy::all)]
2
3//! decree.golang - Go structural rules.
4
5use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
6use dictator_supreme::{SupremeConfig, TabsOrSpaces};
7use memchr::memchr_iter;
8
9/// Configuration for golang decree
10#[derive(Debug, Clone)]
11pub struct GolangConfig {
12    pub max_lines: usize,
13}
14
15impl Default for GolangConfig {
16    fn default() -> Self {
17        Self { max_lines: 450 }
18    }
19}
20
21/// Lint Go source for structural violations.
22#[must_use]
23pub fn lint_source(source: &str) -> Diagnostics {
24    lint_source_with_config(source, &GolangConfig::default())
25}
26
27/// Lint with custom configuration
28#[must_use]
29pub fn lint_source_with_config(source: &str, config: &GolangConfig) -> Diagnostics {
30    let mut diags = Diagnostics::new();
31
32    check_file_line_count(source, config.max_lines, &mut diags);
33    check_indentation_style(source, &mut diags);
34
35    diags
36}
37
38/// Rule 1: File line count (ignoring comments and blank lines)
39fn check_file_line_count(source: &str, max_lines: usize, diags: &mut Diagnostics) {
40    let mut code_lines = 0;
41    let bytes = source.as_bytes();
42    let mut line_start = 0;
43
44    for nl in memchr_iter(b'\n', bytes) {
45        let line = &source[line_start..nl];
46        let trimmed = line.trim();
47
48        // Count line if it's not blank and not a comment-only line
49        if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
50            code_lines += 1;
51        }
52
53        line_start = nl + 1;
54    }
55
56    // Handle last line without newline
57    if line_start < bytes.len() {
58        let line = &source[line_start..];
59        let trimmed = line.trim();
60        if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
61            code_lines += 1;
62        }
63    }
64
65    if code_lines > max_lines {
66        diags.push(Diagnostic {
67            rule: "golang/file-too-long".to_string(),
68            message: format!(
69                "File has {code_lines} code lines (max {max_lines}, excl. comments/blanks)"
70            ),
71            enforced: false,
72            span: Span::new(0, source.len().min(100)),
73        });
74    }
75}
76
77/// Check if a line is comment-only (// or /* */ style)
78fn is_comment_only_line(trimmed: &str) -> bool {
79    trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
80}
81
82/// Rule 2: Indentation style - Go requires tabs, not spaces
83/// Skips lines inside raw string literals (backtick strings)
84fn check_indentation_style(source: &str, diags: &mut Diagnostics) {
85    let bytes = source.as_bytes();
86    let mut line_start = 0;
87    let mut in_raw_string = false;
88
89    for nl in memchr_iter(b'\n', bytes) {
90        let line = &source[line_start..nl];
91
92        // Count backticks in this line to track raw string state
93        let backtick_count = line.bytes().filter(|&b| b == b'`').count();
94        let was_in_raw_string = in_raw_string;
95
96        // Toggle state for each backtick (odd count flips state)
97        if backtick_count % 2 == 1 {
98            in_raw_string = !in_raw_string;
99        }
100
101        // Skip empty lines and lines inside raw strings
102        if line.trim().is_empty() || was_in_raw_string {
103            line_start = nl + 1;
104            continue;
105        }
106
107        // Check if line starts with spaces (not tabs)
108        // Go convention: only tabs for indentation
109        if line.starts_with(' ') {
110            diags.push(Diagnostic {
111                rule: "golang/spaces-instead-of-tabs".to_string(),
112                message: "Go requires tabs for indentation, not spaces".to_string(),
113                enforced: true,
114                span: Span::new(line_start, nl),
115            });
116        }
117
118        line_start = nl + 1;
119    }
120
121    // Handle last line without newline
122    if line_start < bytes.len() {
123        let line = &source[line_start..];
124        if !line.trim().is_empty() && !in_raw_string && line.starts_with(' ') {
125            diags.push(Diagnostic {
126                rule: "golang/spaces-instead-of-tabs".to_string(),
127                message: "Go requires tabs for indentation, not spaces".to_string(),
128                enforced: true,
129                span: Span::new(line_start, bytes.len()),
130            });
131        }
132    }
133}
134
135#[derive(Default)]
136pub struct Golang {
137    config: GolangConfig,
138    supreme: SupremeConfig,
139}
140
141impl Golang {
142    #[must_use]
143    pub const fn new(config: GolangConfig, supreme: SupremeConfig) -> Self {
144        Self { config, supreme }
145    }
146}
147
148impl Decree for Golang {
149    fn name(&self) -> &'static str {
150        "golang"
151    }
152
153    fn lint(&self, _path: &str, source: &str) -> Diagnostics {
154        // Go's decree owns indentation style; suppress supreme's tabs/spaces check to avoid
155        // duplicate diagnostics.
156        let mut supreme = self.supreme.clone();
157        supreme.tabs_vs_spaces = TabsOrSpaces::Either;
158
159        let mut diags = dictator_supreme::lint_source_with_owner(source, &supreme, "golang");
160        diags.extend(lint_source_with_config(source, &self.config));
161        diags
162    }
163
164    fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
165        dictator_decree_abi::DecreeMetadata {
166            abi_version: dictator_decree_abi::ABI_VERSION.to_string(),
167            decree_version: env!("CARGO_PKG_VERSION").to_string(),
168            description: "Go structural rules".to_string(),
169            dectauthors: Some(env!("CARGO_PKG_AUTHORS").to_string()),
170            supported_extensions: vec!["go".to_string()],
171            supported_filenames: vec!["go.mod".to_string(), "go.work".to_string()],
172            skip_filenames: vec!["go.sum".to_string()],
173            capabilities: vec![dictator_decree_abi::Capability::Lint],
174        }
175    }
176}
177
178#[must_use]
179pub fn init_decree() -> BoxDecree {
180    Box::new(Golang::default())
181}
182
183/// Create decree with custom config
184#[must_use]
185pub fn init_decree_with_config(config: GolangConfig) -> BoxDecree {
186    Box::new(Golang::new(config, SupremeConfig::default()))
187}
188
189/// Create decree with custom config + supreme config (merged from decree.supreme + decree.golang)
190#[must_use]
191pub fn init_decree_with_configs(config: GolangConfig, supreme: SupremeConfig) -> BoxDecree {
192    Box::new(Golang::new(config, supreme))
193}
194
195/// Convert `DecreeSettings` to `GolangConfig`
196#[must_use]
197pub fn config_from_decree_settings(settings: &dictator_core::DecreeSettings) -> GolangConfig {
198    GolangConfig {
199        max_lines: settings.max_lines.unwrap_or(450),
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn detects_file_too_long() {
209        use std::fmt::Write;
210        // Create a file with 460 code lines (excluding blank lines and comments)
211        let mut src = String::new();
212        for i in 0..460 {
213            let _ = writeln!(src, "x{i} := {i}");
214        }
215        let diags = lint_source(&src);
216        assert!(
217            diags.iter().any(|d| d.rule == "golang/file-too-long"),
218            "Should detect file with >450 code lines"
219        );
220    }
221
222    #[test]
223    fn ignores_comments_in_line_count() {
224        use std::fmt::Write;
225        // 440 code lines + 60 comment lines = 500 total, but only 440 counted
226        let mut src = String::new();
227        for i in 0..440 {
228            let _ = writeln!(src, "x{i} := {i}");
229        }
230        for i in 0..60 {
231            let _ = writeln!(src, "// Comment {i}");
232        }
233        let diags = lint_source(&src);
234        assert!(
235            !diags.iter().any(|d| d.rule == "golang/file-too-long"),
236            "Should not count comment-only lines"
237        );
238    }
239
240    #[test]
241    fn ignores_blank_lines_in_count() {
242        use std::fmt::Write;
243        // 440 code lines + 60 blank lines = 500 total, but only 440 counted
244        let mut src = String::new();
245        for i in 0..440 {
246            let _ = writeln!(src, "x{i} := {i}");
247        }
248        for _ in 0..60 {
249            src.push('\n');
250        }
251        let diags = lint_source(&src);
252        assert!(
253            !diags.iter().any(|d| d.rule == "golang/file-too-long"),
254            "Should not count blank lines"
255        );
256    }
257
258    #[test]
259    fn detects_spaces_instead_of_tabs() {
260        let src = "package main\n\nfunc test() {\n    x := 1\n}\n";
261        let diags = lint_source(src);
262        assert!(
263            diags
264                .iter()
265                .any(|d| d.rule == "golang/spaces-instead-of-tabs"),
266            "Should detect spaces used for indentation"
267        );
268    }
269
270    #[test]
271    fn allows_tabs_for_indentation() {
272        let src = "package main\n\nfunc test() {\n\tx := 1\n}\n";
273        let diags = lint_source(src);
274        assert!(
275            !diags
276                .iter()
277                .any(|d| d.rule == "golang/spaces-instead-of-tabs"),
278            "Should allow tabs for indentation"
279        );
280    }
281
282    #[test]
283    fn detects_spaces_at_line_start() {
284        let src = "package main\n\nfunc test() {\n    \tx := 1\n}\n";
285        let diags = lint_source(src);
286        assert!(
287            diags
288                .iter()
289                .any(|d| d.rule == "golang/spaces-instead-of-tabs"),
290            "Should detect spaces at start of indented line"
291        );
292    }
293
294    #[test]
295    fn handles_empty_file() {
296        let src = "";
297        let diags = lint_source(src);
298        assert!(diags.is_empty(), "Empty file should have no violations");
299    }
300
301    #[test]
302    fn handles_file_with_only_comments() {
303        let src = "// Comment 1\n// Comment 2\n/* Block comment */\n";
304        let diags = lint_source(src);
305        assert!(
306            !diags.iter().any(|d| d.rule == "golang/file-too-long"),
307            "File with only comments should not trigger line count"
308        );
309    }
310
311    #[test]
312    fn allows_blank_lines() {
313        let src = "package main\n\n\nfunc test() {\n\tx := 1\n}\n";
314        let diags = lint_source(src);
315        assert!(
316            !diags
317                .iter()
318                .any(|d| d.rule == "golang/spaces-instead-of-tabs"),
319            "Should allow blank lines"
320        );
321    }
322
323    #[test]
324    fn allows_spaces_inside_raw_string_literals() {
325        // Simulates Cobra command help text with intentional space indentation
326        let src = concat!(
327            "package main\n\nvar cmd = &cobra.Command{\n",
328            "\tUse:   \"test\",\n",
329            "\tExample: `\n    test foo bar\n    test baz qux`,\n}\n"
330        );
331        let diags = lint_source(src);
332        assert!(
333            !diags
334                .iter()
335                .any(|d| d.rule == "golang/spaces-instead-of-tabs"),
336            "Should allow spaces inside raw string literals (backtick strings)"
337        );
338    }
339
340    #[test]
341    fn allows_spaces_in_multiline_raw_string() {
342        let src = "package main\n\nvar help = `\n  Usage:\n    command [flags]\n`\n";
343        let diags = lint_source(src);
344        assert!(
345            !diags
346                .iter()
347                .any(|d| d.rule == "golang/spaces-instead-of-tabs"),
348            "Should allow spaces in multiline raw string"
349        );
350    }
351
352    #[test]
353    fn detects_spaces_after_raw_string_closes() {
354        let src = "package main\n\nvar x = `raw`\n  y := 1\n";
355        let diags = lint_source(src);
356        assert!(
357            diags
358                .iter()
359                .any(|d| d.rule == "golang/spaces-instead-of-tabs"),
360            "Should detect spaces after raw string closes"
361        );
362    }
363
364    #[test]
365    fn handles_multiple_raw_strings() {
366        let src = "package main\n\nvar a = `\n  first\n`\nvar b = `\n  second\n`\n";
367        let diags = lint_source(src);
368        assert!(
369            !diags
370                .iter()
371                .any(|d| d.rule == "golang/spaces-instead-of-tabs"),
372            "Should handle multiple raw strings correctly"
373        );
374    }
375
376    #[test]
377    fn handles_raw_string_with_backticks_inline() {
378        // Two backticks on same line = opens and closes
379        let src = "package main\n\nvar x = `inline`\n  y := 1\n";
380        let diags = lint_source(src);
381        assert!(
382            diags
383                .iter()
384                .any(|d| d.rule == "golang/spaces-instead-of-tabs"),
385            "Inline raw strings should not affect next line"
386        );
387    }
388}