1#![warn(rust_2024_compatibility, clippy::all)]
2
3use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
6use memchr::memchr_iter;
7use std::collections::HashMap;
8
9#[derive(Debug, Clone)]
11pub struct SupremeConfig {
12 pub max_line_length: Option<usize>,
13 pub trailing_whitespace: bool,
14 pub tabs_vs_spaces: TabsOrSpaces,
15 pub final_newline: bool,
16 pub blank_line_whitespace: bool,
17 pub line_endings: LineEnding,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum TabsOrSpaces {
22 Tabs,
23 Spaces,
24 Either, }
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum LineEnding {
29 Lf, Crlf, Either, }
33
34impl Default for SupremeConfig {
35 fn default() -> Self {
36 Self {
37 max_line_length: None, trailing_whitespace: true,
39 tabs_vs_spaces: TabsOrSpaces::Spaces,
40 final_newline: true,
41 blank_line_whitespace: true,
42 line_endings: LineEnding::Lf,
43 }
44 }
45}
46
47#[must_use]
49pub fn lint_source(source: &str) -> Diagnostics {
50 lint_source_with_config(source, &SupremeConfig::default())
51}
52
53#[must_use]
54pub fn lint_source_with_config(source: &str, config: &SupremeConfig) -> Diagnostics {
55 lint_source_with_owner(source, config, "supreme")
56}
57
58#[must_use]
60pub fn lint_source_with_owner(source: &str, config: &SupremeConfig, owner: &str) -> Diagnostics {
61 let mut diags = Diagnostics::new();
62 let bytes = source.as_bytes();
63
64 let line_ending_info = detect_line_endings(bytes);
66 if line_ending_info.has_mixed && config.line_endings != LineEnding::Either {
67 diags.push(Diagnostic {
68 rule: format!("{owner}/mixed-line-endings"),
69 message: format!(
70 "{} CRLF, {} LF",
71 line_ending_info.crlf_count, line_ending_info.lf_count
72 ),
73 enforced: true,
74 span: Span::new(0, bytes.len().min(100)),
75 });
76 }
77
78 if config.line_endings != LineEnding::Either {
80 match (config.line_endings, &line_ending_info) {
81 (LineEnding::Lf, info) if info.crlf_count > 0 && !info.has_mixed => {
82 diags.push(Diagnostic {
83 rule: format!("{owner}/wrong-line-ending"),
84 message: "CRLF, expected LF".to_string(),
85 enforced: true,
86 span: Span::new(0, bytes.len().min(100)),
87 });
88 }
89 (LineEnding::Crlf, info) if info.lf_only_count > 0 && !info.has_mixed => {
90 diags.push(Diagnostic {
91 rule: format!("{owner}/wrong-line-ending"),
92 message: "LF, expected CRLF".to_string(),
93 enforced: true,
94 span: Span::new(0, bytes.len().min(100)),
95 });
96 }
97 _ => {}
98 }
99 }
100
101 let mut line_start: usize = 0;
103 let mut line_idx: usize = 0;
104
105 for nl in memchr_iter(b'\n', bytes) {
106 check_line(
107 source, line_start, nl, true, line_idx, config, owner, &mut diags,
108 );
109 line_start = nl + 1;
110 line_idx += 1;
111 }
112
113 if line_start < bytes.len() {
115 check_line(
116 source,
117 line_start,
118 bytes.len(),
119 false,
120 line_idx,
121 config,
122 owner,
123 &mut diags,
124 );
125
126 if config.final_newline {
128 diags.push(Diagnostic {
129 rule: format!("{owner}/missing-final-newline"),
130 message: "no final newline".to_string(),
131 enforced: true,
132 span: Span::new(bytes.len().saturating_sub(1), bytes.len()),
133 });
134 }
135 }
136
137 diags
138}
139
140#[derive(Debug)]
141struct LineEndingInfo {
142 crlf_count: usize,
143 lf_only_count: usize,
144 lf_count: usize,
145 has_mixed: bool,
146}
147
148fn detect_line_endings(bytes: &[u8]) -> LineEndingInfo {
149 let mut crlf_count = 0;
150 let mut lf_only_count = 0;
151
152 for nl_pos in memchr_iter(b'\n', bytes) {
153 if nl_pos > 0 && bytes[nl_pos - 1] == b'\r' {
154 crlf_count += 1;
155 } else {
156 lf_only_count += 1;
157 }
158 }
159
160 let lf_count = crlf_count + lf_only_count;
161 let has_mixed = crlf_count > 0 && lf_only_count > 0;
162
163 LineEndingInfo {
164 crlf_count,
165 lf_only_count,
166 lf_count,
167 has_mixed,
168 }
169}
170
171#[allow(clippy::too_many_arguments)]
172fn check_line(
173 source: &str,
174 start: usize,
175 end: usize,
176 _had_newline: bool,
177 _line_idx: usize,
178 config: &SupremeConfig,
179 owner: &str,
180 diags: &mut Diagnostics,
181) {
182 let line = &source[start..end];
183
184 let line = line.strip_suffix('\r').unwrap_or(line);
186
187 if config.trailing_whitespace {
189 let trimmed_end = line.trim_end_matches([' ', '\t']).len();
190 if trimmed_end != line.len() {
191 diags.push(Diagnostic {
192 rule: format!("{owner}/trailing-whitespace"),
193 message: "trailing whitespace".to_string(),
194 enforced: true,
195 span: Span::new(start + trimmed_end, start + line.len()),
196 });
197 }
198 }
199
200 match config.tabs_vs_spaces {
202 TabsOrSpaces::Spaces => {
203 if let Some(pos) = line.bytes().position(|b| b == b'\t') {
204 diags.push(Diagnostic {
205 rule: format!("{owner}/tab-character"),
206 message: "tab found".to_string(),
207 enforced: true,
208 span: Span::new(start + pos, start + pos + 1),
209 });
210 }
211 }
212 TabsOrSpaces::Tabs => {
213 if let Some((idx, _)) = line
215 .char_indices()
216 .take_while(|(_, c)| c.is_whitespace() && *c != '\n' && *c != '\r')
217 .find(|(_, c)| *c == ' ')
218 {
219 diags.push(Diagnostic {
220 rule: format!("{owner}/space-indentation"),
221 message: "spaces found, use tabs".to_string(),
222 enforced: true,
223 span: Span::new(start + idx, start + idx + 1),
224 });
225 }
226 }
227 TabsOrSpaces::Either => {
228 }
230 }
231
232 if config.blank_line_whitespace && line.trim().is_empty() && !line.is_empty() {
234 diags.push(Diagnostic {
235 rule: format!("{owner}/blank-line-whitespace"),
236 message: "blank line has whitespace".to_string(),
237 enforced: true,
238 span: Span::new(start, start + line.len()),
239 });
240 }
241
242 if let Some(max_len) = config.max_line_length
244 && line.len() > max_len
245 {
246 diags.push(Diagnostic {
247 rule: format!("{owner}/line-too-long"),
248 message: format!("{} > {}", line.len(), max_len),
249 enforced: true,
250 span: Span::new(start, start + line.len()),
251 });
252 }
253}
254
255fn ext_to_language(ext: &str) -> Option<&'static str> {
257 match ext {
258 "rb" | "rake" | "gemspec" | "ru" => Some("ruby"),
259 "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" => Some("typescript"),
260 "go" => Some("golang"),
261 "rs" => Some("rust"),
262 "py" | "pyi" => Some("python"),
263 _ => None,
264 }
265}
266
267#[derive(Default)]
268pub struct Supreme {
269 config: SupremeConfig,
270 language_overrides: HashMap<String, SupremeConfig>,
272}
273
274impl Supreme {
275 #[must_use]
276 pub fn new(config: SupremeConfig) -> Self {
277 Self {
278 config,
279 language_overrides: HashMap::new(),
280 }
281 }
282
283 #[must_use]
285 pub const fn with_language_overrides(
286 config: SupremeConfig,
287 overrides: HashMap<String, SupremeConfig>,
288 ) -> Self {
289 Self {
290 config,
291 language_overrides: overrides,
292 }
293 }
294
295 fn config_for_path(&self, path: &str) -> (SupremeConfig, &str) {
298 let ext = std::path::Path::new(path)
300 .extension()
301 .and_then(|e| e.to_str())
302 .unwrap_or("");
303
304 if let Some(lang) = ext_to_language(ext)
306 && let Some(override_config) = self.language_overrides.get(lang)
307 {
308 return (override_config.clone(), lang);
309 }
310
311 (self.config.clone(), "supreme")
313 }
314}
315
316impl Decree for Supreme {
317 fn name(&self) -> &'static str {
318 "supreme"
319 }
320
321 fn lint(&self, path: &str, source: &str) -> Diagnostics {
322 let (effective_config, owner) = self.config_for_path(path);
323 lint_source_with_owner(source, &effective_config, owner)
324 }
325
326 fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
327 dictator_decree_abi::DecreeMetadata {
328 abi_version: dictator_decree_abi::ABI_VERSION.to_string(),
329 decree_version: env!("CARGO_PKG_VERSION").to_string(),
330 description: "Supreme structural rules (universal)".to_string(),
331 dectauthors: Some(env!("CARGO_PKG_AUTHORS").to_string()),
332 supported_extensions: vec![],
333 supported_filenames: vec![],
334 skip_filenames: vec![],
335 capabilities: vec![dictator_decree_abi::Capability::Lint],
336 }
337 }
338}
339
340#[must_use]
341pub fn init_decree() -> BoxDecree {
342 Box::new(Supreme::default())
343}
344
345#[must_use]
347pub fn init_decree_with_config(config: SupremeConfig) -> BoxDecree {
348 Box::new(Supreme::new(config))
349}
350
351#[must_use]
353#[allow(clippy::implicit_hasher)]
354pub fn init_decree_with_overrides(
355 config: SupremeConfig,
356 overrides: HashMap<String, SupremeConfig>,
357) -> BoxDecree {
358 Box::new(Supreme::with_language_overrides(config, overrides))
359}
360
361#[must_use]
363pub fn config_from_decree_settings(settings: &dictator_core::DecreeSettings) -> SupremeConfig {
364 SupremeConfig {
365 max_line_length: settings.max_line_length,
366 trailing_whitespace: settings
367 .trailing_whitespace
368 .as_deref()
369 .is_none_or(|s| s == "deny"),
370 tabs_vs_spaces: settings.tabs_vs_spaces.as_deref().map_or(
371 TabsOrSpaces::Spaces,
372 |s| match s {
373 "tabs" => TabsOrSpaces::Tabs,
374 "spaces" => TabsOrSpaces::Spaces,
375 _ => TabsOrSpaces::Either,
376 },
377 ),
378 final_newline: settings
379 .final_newline
380 .as_deref()
381 .is_none_or(|s| s == "require"),
382 blank_line_whitespace: settings
383 .blank_line_whitespace
384 .as_deref()
385 .is_none_or(|s| s == "deny"),
386 line_endings: settings
387 .line_endings
388 .as_deref()
389 .map_or(LineEnding::Lf, |s| match s {
390 "lf" => LineEnding::Lf,
391 "crlf" => LineEnding::Crlf,
392 _ => LineEnding::Either,
393 }),
394 }
395}
396
397#[must_use]
400pub fn merged_config(
401 base: &dictator_core::DecreeSettings,
402 lang: &dictator_core::DecreeSettings,
403) -> SupremeConfig {
404 SupremeConfig {
405 max_line_length: lang.max_line_length.or(base.max_line_length),
407 trailing_whitespace: lang
408 .trailing_whitespace
409 .as_deref()
410 .or(base.trailing_whitespace.as_deref())
411 .is_none_or(|s| s == "deny"),
412 tabs_vs_spaces: lang
413 .tabs_vs_spaces
414 .as_deref()
415 .or(base.tabs_vs_spaces.as_deref())
416 .map_or(TabsOrSpaces::Spaces, |s| match s {
417 "tabs" => TabsOrSpaces::Tabs,
418 "spaces" => TabsOrSpaces::Spaces,
419 _ => TabsOrSpaces::Either,
420 }),
421 final_newline: lang
422 .final_newline
423 .as_deref()
424 .or(base.final_newline.as_deref())
425 .is_none_or(|s| s == "require"),
426 blank_line_whitespace: lang
427 .blank_line_whitespace
428 .as_deref()
429 .or(base.blank_line_whitespace.as_deref())
430 .is_none_or(|s| s == "deny"),
431 line_endings: lang
432 .line_endings
433 .as_deref()
434 .or(base.line_endings.as_deref())
435 .map_or(LineEnding::Lf, |s| match s {
436 "lf" => LineEnding::Lf,
437 "crlf" => LineEnding::Crlf,
438 _ => LineEnding::Either,
439 }),
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 #[test]
448 fn detects_trailing_whitespace() {
449 let src = "hello world \n";
450 let diags = lint_source(src);
451 assert!(
452 diags
453 .iter()
454 .any(|d| d.rule == "supreme/trailing-whitespace")
455 );
456 }
457
458 #[test]
459 fn detects_tabs_when_spaces_expected() {
460 let src = "hello\tworld\n";
461 let diags = lint_source(src);
462 assert!(diags.iter().any(|d| d.rule == "supreme/tab-character"));
463 }
464
465 #[test]
466 fn allows_tabs_when_configured() {
467 let src = "\thello world\n";
468 let config = SupremeConfig {
469 tabs_vs_spaces: TabsOrSpaces::Tabs,
470 ..Default::default()
471 };
472 let diags = lint_source_with_config(src, &config);
473 assert!(!diags.iter().any(|d| d.rule == "supreme/tab-character"));
474 }
475
476 #[test]
477 fn detects_spaces_when_tabs_expected() {
478 let src = " hello world\n";
479 let config = SupremeConfig {
480 tabs_vs_spaces: TabsOrSpaces::Tabs,
481 ..Default::default()
482 };
483 let diags = lint_source_with_config(src, &config);
484 assert!(diags.iter().any(|d| d.rule == "supreme/space-indentation"));
485 }
486
487 #[test]
488 fn detects_single_space_when_tabs_expected() {
489 let src = " hello world\n";
490 let config = SupremeConfig {
491 tabs_vs_spaces: TabsOrSpaces::Tabs,
492 ..Default::default()
493 };
494 let diags = lint_source_with_config(src, &config);
495 assert!(diags.iter().any(|d| d.rule == "supreme/space-indentation"));
496 }
497
498 #[test]
499 fn detects_mixed_tabs_and_spaces_when_tabs_expected() {
500 let src = "\t hello world\n"; let config = SupremeConfig {
502 tabs_vs_spaces: TabsOrSpaces::Tabs,
503 ..Default::default()
504 };
505 let diags = lint_source_with_config(src, &config);
506 assert!(diags.iter().any(|d| d.rule == "supreme/space-indentation"));
507 }
508
509 #[test]
510 fn detects_missing_final_newline() {
511 let src = "hello world";
512 let diags = lint_source(src);
513 assert!(
514 diags
515 .iter()
516 .any(|d| d.rule == "supreme/missing-final-newline")
517 );
518 }
519
520 #[test]
521 fn allows_missing_final_newline_when_configured() {
522 let src = "hello world";
523 let config = SupremeConfig {
524 final_newline: false,
525 ..Default::default()
526 };
527 let diags = lint_source_with_config(src, &config);
528 assert!(
529 !diags
530 .iter()
531 .any(|d| d.rule == "supreme/missing-final-newline")
532 );
533 }
534
535 #[test]
536 fn detects_blank_line_whitespace() {
537 let src = "line1\n \nline2\n";
538 let diags = lint_source(src);
539 assert!(
540 diags
541 .iter()
542 .any(|d| d.rule == "supreme/blank-line-whitespace")
543 );
544 }
545
546 #[test]
547 fn detects_line_too_long() {
548 let src = format!("{}\n", "x".repeat(150));
549 let config = SupremeConfig {
550 max_line_length: Some(120),
551 ..Default::default()
552 };
553 let diags = lint_source_with_config(&src, &config);
554 assert!(diags.iter().any(|d| d.rule == "supreme/line-too-long"));
555 }
556
557 #[test]
558 fn skips_line_length_when_disabled() {
559 let src = format!("{}\n", "x".repeat(500));
560 let diags = lint_source(&src); assert!(!diags.iter().any(|d| d.rule == "supreme/line-too-long"));
562 }
563
564 #[test]
565 fn detects_mixed_line_endings() {
566 let src = "line1\r\nline2\nline3\r\n";
567 let diags = lint_source(src);
568 assert!(diags.iter().any(|d| d.rule == "supreme/mixed-line-endings"));
569 }
570
571 #[test]
572 fn detects_crlf_when_lf_expected() {
573 let src = "line1\r\nline2\r\n";
574 let config = SupremeConfig {
575 line_endings: LineEnding::Lf,
576 ..Default::default()
577 };
578 let diags = lint_source_with_config(src, &config);
579 assert!(diags.iter().any(|d| d.rule == "supreme/wrong-line-ending"));
580 }
581
582 #[test]
583 fn detects_lf_when_crlf_expected() {
584 let src = "line1\nline2\n";
585 let config = SupremeConfig {
586 line_endings: LineEnding::Crlf,
587 ..Default::default()
588 };
589 let diags = lint_source_with_config(src, &config);
590 assert!(diags.iter().any(|d| d.rule == "supreme/wrong-line-ending"));
591 }
592
593 #[test]
594 fn handles_empty_file() {
595 let src = "";
596 let diags = lint_source(src);
597 assert!(diags.is_empty() || diags.len() == 1);
599 }
600
601 #[test]
602 fn handles_single_line_with_newline() {
603 let src = "hello world\n";
604 let diags = lint_source(src);
605 assert!(diags.is_empty());
606 }
607}