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