Skip to main content

dictator_typescript/
lib.rs

1#![warn(rust_2024_compatibility, clippy::all)]
2
3//! decree.typescript - TypeScript/JavaScript structural rules.
4
5use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
6use dictator_supreme::SupremeConfig;
7use memchr::memchr_iter;
8
9/// Lint TypeScript source for structural violations.
10#[must_use]
11pub fn lint_source(source: &str) -> Diagnostics {
12    lint_source_with_configs(source, &TypeScriptConfig::default(), &SupremeConfig::default())
13}
14
15/// Lint TypeScript source with custom configuration.
16#[must_use]
17pub fn lint_source_with_config(source: &str, config: &TypeScriptConfig) -> Diagnostics {
18    lint_source_with_configs(source, config, &SupremeConfig::default())
19}
20
21/// Lint TypeScript source with custom config + supreme config.
22#[must_use]
23pub fn lint_source_with_configs(
24    source: &str,
25    config: &TypeScriptConfig,
26    supreme_config: &SupremeConfig,
27) -> Diagnostics {
28    let mut diags = Diagnostics::new();
29
30    let supreme_diags =
31        dictator_supreme::lint_source_with_owner(source, supreme_config, "typescript");
32
33    if config.ignore_comments {
34        // Filter out line-too-long violations on comment lines
35        let lines: Vec<&str> = source.lines().collect();
36        diags.extend(supreme_diags.into_iter().filter(|d| {
37            if d.rule == "typescript/line-too-long" {
38                let line_idx = source[..d.span.start].matches('\n').count();
39                !lines
40                    .get(line_idx)
41                    .is_some_and(|line| line.trim_start().starts_with("//"))
42            } else {
43                true
44            }
45        }));
46    } else {
47        diags.extend(supreme_diags);
48    }
49
50    // TypeScript-specific rules
51    check_file_line_count(source, config.max_lines, &mut diags);
52    check_import_ordering(source, &mut diags);
53    check_indentation_consistency(source, &mut diags);
54
55    diags
56}
57
58/// Configuration for typescript decree
59#[derive(Debug, Clone)]
60pub struct TypeScriptConfig {
61    pub max_lines: usize,
62    pub ignore_comments: bool,
63}
64
65impl Default for TypeScriptConfig {
66    fn default() -> Self {
67        Self {
68            max_lines: 350,
69            ignore_comments: false,
70        }
71    }
72}
73
74/// Rule 1: File line count - configurable max lines (ignoring comments and blank lines)
75fn check_file_line_count(source: &str, max_lines: usize, diags: &mut Diagnostics) {
76    let mut code_lines = 0;
77    let bytes = source.as_bytes();
78    let mut line_start = 0;
79
80    for nl in memchr_iter(b'\n', bytes) {
81        let line = &source[line_start..nl];
82        let trimmed = line.trim();
83
84        // Count line if it's not blank and not a comment-only line
85        if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
86            code_lines += 1;
87        }
88
89        line_start = nl + 1;
90    }
91
92    // Handle last line without newline
93    if line_start < bytes.len() {
94        let line = &source[line_start..];
95        let trimmed = line.trim();
96        if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
97            code_lines += 1;
98        }
99    }
100
101    if code_lines > max_lines {
102        diags.push(Diagnostic {
103            rule: "typescript/file-too-long".to_string(),
104            message: format!(
105                "File has {code_lines} code lines \
106                 (max {max_lines}, excluding comments and blank lines)"
107            ),
108            enforced: false,
109            span: Span::new(0, source.len().min(100)),
110        });
111    }
112}
113
114/// Check if a line is comment-only (// or /* */ style)
115fn is_comment_only_line(trimmed: &str) -> bool {
116    trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
117}
118
119/// Rule 2: Import ordering - system → external → internal
120fn check_import_ordering(source: &str, diags: &mut Diagnostics) {
121    let bytes = source.as_bytes();
122    let mut imports: Vec<(usize, usize, ImportType)> = Vec::new();
123    let mut line_start = 0;
124
125    for nl in memchr_iter(b'\n', bytes) {
126        let line = &source[line_start..nl];
127        let trimmed = line.trim();
128
129        if let Some(import_type) = parse_import_line(trimmed) {
130            imports.push((line_start, nl, import_type));
131        }
132
133        // Stop at first non-import, non-comment, non-blank line
134        if !trimmed.is_empty()
135            && !trimmed.starts_with("import")
136            && !trimmed.starts_with("//")
137            && !trimmed.starts_with("/*")
138            && !trimmed.starts_with('*')
139            && !trimmed.starts_with("export")
140        {
141            break;
142        }
143
144        line_start = nl + 1;
145    }
146
147    // Check import order
148    if imports.len() > 1 {
149        let mut last_type = ImportType::System;
150
151        for (start, end, import_type) in &imports {
152            // Order should be: System → External → Internal
153            let type_order = match import_type {
154                ImportType::System => 0,
155                ImportType::External => 1,
156                ImportType::Internal => 2,
157            };
158
159            let last_type_order = match last_type {
160                ImportType::System => 0,
161                ImportType::External => 1,
162                ImportType::Internal => 2,
163            };
164
165            if type_order < last_type_order {
166                diags.push(Diagnostic {
167                    rule: "typescript/import-order".to_string(),
168                    message: format!(
169                        "Import order violation: {import_type:?} import after \
170                         {last_type:?} import. Expected: system → external → internal"
171                    ),
172                    enforced: false,
173                    span: Span::new(*start, *end),
174                });
175            }
176
177            last_type = *import_type;
178        }
179    }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183enum ImportType {
184    System,   // Node.js built-ins: fs, path, crypto, events, etc.
185    External, // npm packages
186    Internal, // Relative imports: ./ or ../
187}
188
189/// Parse an import line and determine its type
190fn parse_import_line(line: &str) -> Option<ImportType> {
191    if !line.starts_with("import") {
192        return None;
193    }
194
195    // Extract the module name from import statement
196    // Patterns: import ... from 'module' or import ... from "module"
197    let from_pos = line.find(" from ")?;
198    let after_from = &line[from_pos + 6..].trim();
199
200    // Extract quoted module name
201    let quote_start = after_from.find(['\'', '"'])?;
202    let quote_char = after_from.chars().nth(quote_start)?;
203    let module_start = quote_start + 1;
204    let module_end = after_from[module_start..].find(quote_char)?;
205    let module_name = &after_from[module_start..module_start + module_end];
206
207    // Determine type
208    if module_name.starts_with('.') {
209        Some(ImportType::Internal)
210    } else if is_nodejs_builtin(module_name) {
211        Some(ImportType::System)
212    } else {
213        Some(ImportType::External)
214    }
215}
216
217/// Check if module is a Node.js built-in
218fn is_nodejs_builtin(module: &str) -> bool {
219    // Remove 'node:' prefix if present
220    let module = module.strip_prefix("node:").unwrap_or(module);
221
222    matches!(
223        module,
224        "fs" | "path"
225            | "crypto"
226            | "events"
227            | "http"
228            | "https"
229            | "os"
230            | "util"
231            | "url"
232            | "stream"
233            | "buffer"
234            | "child_process"
235            | "cluster"
236            | "dns"
237            | "net"
238            | "readline"
239            | "repl"
240            | "tls"
241            | "dgram"
242            | "zlib"
243            | "querystring"
244            | "string_decoder"
245            | "timers"
246            | "tty"
247            | "vm"
248            | "assert"
249            | "console"
250            | "process"
251            | "v8"
252            | "perf_hooks"
253            | "worker_threads"
254            | "async_hooks"
255    )
256}
257
258/// Rule 3: Indentation consistency
259fn check_indentation_consistency(source: &str, diags: &mut Diagnostics) {
260    let bytes = source.as_bytes();
261    let mut line_start = 0;
262    let mut has_tabs = false;
263    let mut has_spaces = false;
264    let mut inconsistent_depths: Vec<(usize, usize)> = Vec::new();
265    let mut indent_stack: Vec<usize> = Vec::new();
266
267    for nl in memchr_iter(b'\n', bytes) {
268        let line = &source[line_start..nl];
269
270        // Skip empty lines
271        if line.trim().is_empty() {
272            line_start = nl + 1;
273            continue;
274        }
275
276        // Detect tabs vs spaces
277        if line.starts_with('\t') {
278            has_tabs = true;
279        } else if line.starts_with(' ') {
280            has_spaces = true;
281        }
282
283        // Calculate indentation depth
284        let indent = count_leading_whitespace(line);
285        if indent > 0 && !line.trim().is_empty() {
286            // Check for inconsistent indentation depth changes
287            if let Some(&last_indent) = indent_stack.last() {
288                if indent > last_indent {
289                    // Indentation increased
290                    let diff = indent - last_indent;
291                    // Check if it's a consistent multiple (2 or 4 spaces, or 1 tab)
292                    if has_spaces && diff != 2 && diff != 4 {
293                        inconsistent_depths.push((line_start, nl));
294                    }
295                    indent_stack.push(indent);
296                } else if indent < last_indent {
297                    // Indentation decreased - pop stack until we find matching level
298                    while let Some(&stack_indent) = indent_stack.last() {
299                        if stack_indent <= indent {
300                            break;
301                        }
302                        indent_stack.pop();
303                    }
304                    // If current indent doesn't match any previous level, it's inconsistent
305                    if indent_stack.last() != Some(&indent) && indent > 0 {
306                        inconsistent_depths.push((line_start, nl));
307                    }
308                }
309            } else if indent > 0 {
310                indent_stack.push(indent);
311            }
312        }
313
314        line_start = nl + 1;
315    }
316
317    // Handle last line without newline
318    if line_start < bytes.len() {
319        let line = &source[line_start..];
320        if !line.trim().is_empty() {
321            if line.starts_with('\t') {
322                has_tabs = true;
323            } else if line.starts_with(' ') {
324                has_spaces = true;
325            }
326        }
327    }
328
329    // Report mixed tabs and spaces
330    if has_tabs && has_spaces {
331        diags.push(Diagnostic {
332            rule: "typescript/mixed-indentation".to_string(),
333            message: "File has mixed tabs and spaces for indentation".to_string(),
334            enforced: true,
335            span: Span::new(0, source.len().min(100)),
336        });
337    }
338
339    // Report inconsistent indentation depths
340    if !inconsistent_depths.is_empty() {
341        let (start, end) = inconsistent_depths[0];
342        diags.push(Diagnostic {
343            rule: "typescript/inconsistent-indentation".to_string(),
344            message: "Inconsistent indentation depth detected".to_string(),
345            enforced: true,
346            span: Span::new(start, end),
347        });
348    }
349}
350
351/// Count leading whitespace characters
352fn count_leading_whitespace(line: &str) -> usize {
353    line.chars()
354        .take_while(|c| c.is_whitespace() && *c != '\n' && *c != '\r')
355        .count()
356}
357
358#[derive(Default)]
359pub struct TypeScript {
360    config: TypeScriptConfig,
361    supreme: SupremeConfig,
362}
363
364impl TypeScript {
365    #[must_use]
366    pub const fn new(config: TypeScriptConfig, supreme: SupremeConfig) -> Self {
367        Self { config, supreme }
368    }
369}
370
371impl Decree for TypeScript {
372    fn name(&self) -> &'static str {
373        "typescript"
374    }
375
376    fn lint(&self, _path: &str, source: &str) -> Diagnostics {
377        lint_source_with_configs(source, &self.config, &self.supreme)
378    }
379
380    fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
381        dictator_decree_abi::DecreeMetadata {
382            abi_version: dictator_decree_abi::ABI_VERSION.to_string(),
383            decree_version: env!("CARGO_PKG_VERSION").to_string(),
384            description: "TypeScript/JavaScript structural rules".to_string(),
385            dectauthors: Some(env!("CARGO_PKG_AUTHORS").to_string()),
386            supported_extensions: vec![
387                "ts".to_string(),
388                "tsx".to_string(),
389                "js".to_string(),
390                "jsx".to_string(),
391                "mjs".to_string(),
392                "cjs".to_string(),
393                "mts".to_string(),
394                "cts".to_string(),
395            ],
396            supported_filenames: vec![
397                "package.json".to_string(),
398                "tsconfig.json".to_string(),
399                "jsconfig.json".to_string(),
400                "biome.json".to_string(),
401                "biome.jsonc".to_string(),
402                ".eslintrc".to_string(),
403                ".prettierrc".to_string(),
404                "deno.json".to_string(),
405                "deno.jsonc".to_string(),
406                "bunfig.toml".to_string(),
407            ],
408            skip_filenames: vec![
409                "package-lock.json".to_string(),
410                "yarn.lock".to_string(),
411                "pnpm-lock.yaml".to_string(),
412                "bun.lockb".to_string(),
413                "deno.lock".to_string(),
414                "npm-shrinkwrap.json".to_string(),
415            ],
416            capabilities: vec![dictator_decree_abi::Capability::Lint],
417        }
418    }
419}
420
421#[must_use]
422pub fn init_decree() -> BoxDecree {
423    Box::new(TypeScript::default())
424}
425
426/// Create plugin with custom config
427#[must_use]
428pub fn init_decree_with_config(config: TypeScriptConfig) -> BoxDecree {
429    Box::new(TypeScript::new(config, SupremeConfig::default()))
430}
431
432/// Create plugin with custom config + supreme config
433/// (merged from decree.supreme + decree.typescript)
434#[must_use]
435pub fn init_decree_with_configs(config: TypeScriptConfig, supreme: SupremeConfig) -> BoxDecree {
436    Box::new(TypeScript::new(config, supreme))
437}
438
439/// Convert `DecreeSettings` to `TypeScriptConfig`
440#[must_use]
441pub fn config_from_decree_settings(settings: &dictator_core::DecreeSettings) -> TypeScriptConfig {
442    TypeScriptConfig {
443        max_lines: settings.max_lines.unwrap_or(350),
444        ignore_comments: settings.ignore_comments.unwrap_or(false),
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn detects_file_too_long() {
454        use std::fmt::Write;
455        // Create a file with 400 code lines (excluding blank lines and comments)
456        let mut src = String::new();
457        for i in 0..400 {
458            let _ = writeln!(src, "const x{i} = {i};");
459        }
460        let diags = lint_source(&src);
461        assert!(
462            diags.iter().any(|d| d.rule == "typescript/file-too-long"),
463            "Should detect file with >350 code lines"
464        );
465    }
466
467    #[test]
468    fn ignores_comments_in_line_count() {
469        use std::fmt::Write;
470        // 340 code lines + 60 comment lines = 400 total, but only 340 counted
471        let mut src = String::new();
472        for i in 0..340 {
473            let _ = writeln!(src, "const x{i} = {i};");
474        }
475        for i in 0..60 {
476            let _ = writeln!(src, "// Comment {i}");
477        }
478        let diags = lint_source(&src);
479        assert!(
480            !diags.iter().any(|d| d.rule == "typescript/file-too-long"),
481            "Should not count comment-only lines"
482        );
483    }
484
485    #[test]
486    fn ignores_blank_lines_in_count() {
487        use std::fmt::Write;
488        // 340 code lines + 60 blank lines = 400 total, but only 340 counted
489        let mut src = String::new();
490        for i in 0..340 {
491            let _ = writeln!(src, "const x{i} = {i};");
492        }
493        for _ in 0..60 {
494            src.push('\n');
495        }
496        let diags = lint_source(&src);
497        assert!(
498            !diags.iter().any(|d| d.rule == "typescript/file-too-long"),
499            "Should not count blank lines"
500        );
501    }
502
503    #[test]
504    fn detects_wrong_import_order_system_after_external() {
505        let src = r"
506import { format } from 'date-fns';
507import * as fs from 'fs';
508import { config } from './config';
509";
510        let diags = lint_source(src);
511        assert!(
512            diags.iter().any(|d| d.rule == "typescript/import-order"),
513            "Should detect system import after external import"
514        );
515    }
516
517    #[test]
518    fn detects_wrong_import_order_internal_before_external() {
519        let src = r"
520import { config } from './config';
521import { format } from 'date-fns';
522import * as fs from 'fs';
523";
524        let diags = lint_source(src);
525        assert!(
526            diags.iter().any(|d| d.rule == "typescript/import-order"),
527            "Should detect wrong import order"
528        );
529    }
530
531    #[test]
532    fn accepts_correct_import_order() {
533        let src = r"
534import * as fs from 'fs';
535import * as path from 'path';
536import { format } from 'date-fns';
537import axios from 'axios';
538import { config } from './config';
539import type { Logger } from './types';
540";
541        let diags = lint_source(src);
542        assert!(
543            !diags.iter().any(|d| d.rule == "typescript/import-order"),
544            "Should accept correct import order"
545        );
546    }
547
548    #[test]
549    fn detects_mixed_tabs_and_spaces() {
550        let src = "function test() {\n\tconst x = 1;\n  const y = 2;\n}\n";
551        let diags = lint_source(src);
552        assert!(
553            diags
554                .iter()
555                .any(|d| d.rule == "typescript/mixed-indentation"),
556            "Should detect mixed tabs and spaces"
557        );
558    }
559
560    #[test]
561    fn detects_inconsistent_indentation_depth() {
562        let src = r"
563function test() {
564  if (true) {
565     const x = 1;
566  }
567}
568";
569        let diags = lint_source(src);
570        assert!(
571            diags
572                .iter()
573                .any(|d| d.rule == "typescript/inconsistent-indentation"),
574            "Should detect inconsistent indentation depth (3 spaces instead of 2 or 4)"
575        );
576    }
577
578    #[test]
579    fn accepts_consistent_indentation() {
580        let src = r"
581function test() {
582  if (true) {
583    const x = 1;
584    const y = 2;
585  }
586}
587";
588        let diags = lint_source(src);
589        assert!(
590            !diags
591                .iter()
592                .any(|d| d.rule == "typescript/mixed-indentation"
593                    || d.rule == "typescript/inconsistent-indentation"),
594            "Should accept consistent indentation"
595        );
596    }
597
598    #[test]
599    fn handles_empty_file() {
600        let src = "";
601        let diags = lint_source(src);
602        assert!(diags.is_empty(), "Empty file should have no violations");
603    }
604
605    #[test]
606    fn handles_file_with_only_comments() {
607        let src = "// Comment 1\n// Comment 2\n/* Block comment */\n";
608        let diags = lint_source(src);
609        assert!(
610            !diags.iter().any(|d| d.rule == "typescript/file-too-long"),
611            "File with only comments should not trigger line count"
612        );
613    }
614
615    #[test]
616    fn detects_nodejs_builtins_correctly() {
617        assert!(is_nodejs_builtin("fs"));
618        assert!(is_nodejs_builtin("path"));
619        assert!(is_nodejs_builtin("crypto"));
620        assert!(is_nodejs_builtin("events"));
621        assert!(!is_nodejs_builtin("date-fns"));
622        assert!(!is_nodejs_builtin("lodash"));
623        assert!(!is_nodejs_builtin("./config"));
624    }
625
626    #[test]
627    fn ignores_long_comment_lines_when_configured() {
628        let long_comment = format!("// {}\n", "x".repeat(150));
629        let src = format!("function foo() {{\n{long_comment}}}\n");
630        let config = TypeScriptConfig {
631            ignore_comments: true,
632            ..Default::default()
633        };
634        let supreme = SupremeConfig {
635            max_line_length: Some(120),
636            ..Default::default()
637        };
638        let diags = lint_source_with_configs(&src, &config, &supreme);
639        assert!(
640            !diags.iter().any(|d| d.rule == "typescript/line-too-long"),
641            "Should ignore long comment lines when ignore_comments is true"
642        );
643    }
644
645    #[test]
646    fn detects_long_comment_lines_when_not_configured() {
647        let long_comment = format!("// {}\n", "x".repeat(150));
648        let src = format!("function foo() {{\n{long_comment}}}\n");
649        let config = TypeScriptConfig::default(); // ignore_comments = false
650        let supreme = SupremeConfig {
651            max_line_length: Some(120),
652            ..Default::default()
653        };
654        let diags = lint_source_with_configs(&src, &config, &supreme);
655        assert!(
656            diags.iter().any(|d| d.rule == "typescript/line-too-long"),
657            "Should detect long comment lines when ignore_comments is false"
658        );
659    }
660
661    #[test]
662    fn still_detects_long_code_lines_with_ignore_comments() {
663        let long_code = format!("  const x = \"{}\";\n", "a".repeat(150));
664        let src = format!("function foo() {{\n{long_code}}}\n");
665        let config = TypeScriptConfig {
666            ignore_comments: true,
667            ..Default::default()
668        };
669        let supreme = SupremeConfig {
670            max_line_length: Some(120),
671            ..Default::default()
672        };
673        let diags = lint_source_with_configs(&src, &config, &supreme);
674        assert!(
675            diags.iter().any(|d| d.rule == "typescript/line-too-long"),
676            "Should still detect long code lines even with ignore_comments enabled"
677        );
678    }
679}