Skip to main content

dictator_supreme/
lib.rs

1#![warn(rust_2024_compatibility, clippy::all)]
2
3//! decree.supreme - Universal structural rules for ALL files.
4
5use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
6use memchr::memchr_iter;
7use std::collections::HashMap;
8
9/// Configuration for supreme decree (will be loaded from .dictate.toml)
10#[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, // Don't enforce
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum LineEnding {
29    Lf,     // Unix
30    Crlf,   // Windows
31    Either, // Don't enforce
32}
33
34impl Default for SupremeConfig {
35    fn default() -> Self {
36        Self {
37            max_line_length: None, // Opt-in per language
38            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/// Lint source for universal structural violations.
48#[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/// Lint with configurable rule owner - "you touch it, you own it"
59#[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    // Detect line ending type and check for mixed endings
65    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    // Check expected line ending type
79    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    // Check each line for structural issues
102    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    // Handle last line without newline
114    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        // Missing final newline
127        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    // Strip CRLF if present
185    let line = line.strip_suffix('\r').unwrap_or(line);
186
187    // 1. Trailing whitespace
188    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    // 2. Tabs vs Spaces
201    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            // Check for any spaces in the indentation prefix (must be tabs only)
214            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            // Don't enforce
229        }
230    }
231
232    // 3. Blank line with whitespace
233    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    // 4. Line length (opt-in per language)
243    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
255/// Map file extension to language decree name
256fn 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-specific overrides (language name -> partial config)
271    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    /// Create with language overrides
284    #[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    /// Get effective config and rule owner for a file path
296    /// Returns (config, owner) where owner is the language name if overridden, else "supreme"
297    fn config_for_path(&self, path: &str) -> (SupremeConfig, &str) {
298        // Extract extension from path
299        let ext = std::path::Path::new(path)
300            .extension()
301            .and_then(|e| e.to_str())
302            .unwrap_or("");
303
304        // Look up language override - "you touch it, you own it"
305        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        // No override, supreme owns it
312        (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/// Create plugin with custom config
346#[must_use]
347pub fn init_decree_with_config(config: SupremeConfig) -> BoxDecree {
348    Box::new(Supreme::new(config))
349}
350
351/// Create plugin with config and language overrides
352#[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/// Convert `DecreeSettings` to `SupremeConfig`
362#[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/// Create merged config: base supreme + language override
398/// Language settings override supreme settings when explicitly set
399#[must_use]
400pub fn merged_config(
401    base: &dictator_core::DecreeSettings,
402    lang: &dictator_core::DecreeSettings,
403) -> SupremeConfig {
404    SupremeConfig {
405        // Language overrides base if set, otherwise use base
406        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"; // tab then space
501        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); // Default has max_line_length: None
561        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        // Empty file is valid (has no violations except maybe missing final newline)
598        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}