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