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