1#![warn(rust_2024_compatibility, clippy::all)]
2
3use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
6use dictator_supreme::SupremeConfig;
7use memchr::memchr_iter;
8
9#[must_use]
11pub fn lint_source(source: &str) -> Diagnostics {
12 lint_source_with_configs(source, &TypeScriptConfig::default(), &SupremeConfig::default())
13}
14
15#[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#[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 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 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#[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
74fn 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 if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
86 code_lines += 1;
87 }
88
89 line_start = nl + 1;
90 }
91
92 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
114fn is_comment_only_line(trimmed: &str) -> bool {
116 trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
117}
118
119fn 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 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 if imports.len() > 1 {
149 let mut last_type = ImportType::System;
150
151 for (start, end, import_type) in &imports {
152 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, External, Internal, }
188
189fn parse_import_line(line: &str) -> Option<ImportType> {
191 if !line.starts_with("import") {
192 return None;
193 }
194
195 let from_pos = line.find(" from ")?;
198 let after_from = &line[from_pos + 6..].trim();
199
200 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 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
217fn is_nodejs_builtin(module: &str) -> bool {
219 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
258fn 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 if line.trim().is_empty() {
272 line_start = nl + 1;
273 continue;
274 }
275
276 if line.starts_with('\t') {
278 has_tabs = true;
279 } else if line.starts_with(' ') {
280 has_spaces = true;
281 }
282
283 let indent = count_leading_whitespace(line);
285 if indent > 0 && !line.trim().is_empty() {
286 if let Some(&last_indent) = indent_stack.last() {
288 if indent > last_indent {
289 let diff = indent - last_indent;
291 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 while let Some(&stack_indent) = indent_stack.last() {
299 if stack_indent <= indent {
300 break;
301 }
302 indent_stack.pop();
303 }
304 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 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 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 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
351fn 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#[must_use]
428pub fn init_decree_with_config(config: TypeScriptConfig) -> BoxDecree {
429 Box::new(TypeScript::new(config, SupremeConfig::default()))
430}
431
432#[must_use]
435pub fn init_decree_with_configs(config: TypeScriptConfig, supreme: SupremeConfig) -> BoxDecree {
436 Box::new(TypeScript::new(config, supreme))
437}
438
439#[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 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 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 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(); 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}