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