1#![warn(rust_2024_compatibility, clippy::all)]
2
3use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
6use dictator_supreme::{SupremeConfig, TabsOrSpaces};
7use memchr::memchr_iter;
8
9#[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#[must_use]
23pub fn lint_source(source: &str) -> Diagnostics {
24 lint_source_with_config(source, &GolangConfig::default())
25}
26
27#[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
38fn 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 if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
50 code_lines += 1;
51 }
52
53 line_start = nl + 1;
54 }
55
56 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
77fn is_comment_only_line(trimmed: &str) -> bool {
79 trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
80}
81
82fn 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 let backtick_count = line.bytes().filter(|&b| b == b'`').count();
94 let was_in_raw_string = in_raw_string;
95
96 if backtick_count % 2 == 1 {
98 in_raw_string = !in_raw_string;
99 }
100
101 if line.trim().is_empty() || was_in_raw_string {
103 line_start = nl + 1;
104 continue;
105 }
106
107 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 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 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#[must_use]
185pub fn init_decree_with_config(config: GolangConfig) -> BoxDecree {
186 Box::new(Golang::new(config, SupremeConfig::default()))
187}
188
189#[must_use]
191pub fn init_decree_with_configs(config: GolangConfig, supreme: SupremeConfig) -> BoxDecree {
192 Box::new(Golang::new(config, supreme))
193}
194
195#[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 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 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 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 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 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}