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(
13 source,
14 &TypeScriptConfig::default(),
15 &SupremeConfig::default(),
16 )
17}
18
19#[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#[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 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 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#[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
78fn 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 if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
90 code_lines += 1;
91 }
92
93 line_start = nl + 1;
94 }
95
96 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
118fn is_comment_only_line(trimmed: &str) -> bool {
120 trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
121}
122
123fn 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 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 if imports.len() > 1 {
153 let mut last_type = ImportType::System;
154
155 for (start, end, import_type) in &imports {
156 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, External, Internal, }
192
193fn parse_import_line(line: &str) -> Option<ImportType> {
195 if !line.starts_with("import") {
196 return None;
197 }
198
199 let from_pos = line.find(" from ")?;
202 let after_from = &line[from_pos + 6..].trim();
203
204 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 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
221fn is_nodejs_builtin(module: &str) -> bool {
223 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
262fn 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 if line.trim().is_empty() {
276 line_start = nl + 1;
277 continue;
278 }
279
280 if line.starts_with('\t') {
282 has_tabs = true;
283 } else if line.starts_with(' ') {
284 has_spaces = true;
285 }
286
287 let indent = count_leading_whitespace(line);
289 if indent > 0 && !line.trim().is_empty() {
290 if let Some(&last_indent) = indent_stack.last() {
292 if indent > last_indent {
293 let diff = indent - last_indent;
295 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 while let Some(&stack_indent) = indent_stack.last() {
303 if stack_indent <= indent {
304 break;
305 }
306 indent_stack.pop();
307 }
308 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 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 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 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
355fn 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#[must_use]
432pub fn init_decree_with_config(config: TypeScriptConfig) -> BoxDecree {
433 Box::new(TypeScript::new(config, SupremeConfig::default()))
434}
435
436#[must_use]
439pub fn init_decree_with_configs(config: TypeScriptConfig, supreme: SupremeConfig) -> BoxDecree {
440 Box::new(TypeScript::new(config, supreme))
441}
442
443#[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 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 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 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(); 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}