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_config(source, &TypeScriptConfig::default())
13}
14
15#[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#[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
39fn 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 if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
51 code_lines += 1;
52 }
53
54 line_start = nl + 1;
55 }
56
57 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
78fn is_comment_only_line(trimmed: &str) -> bool {
80 trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
81}
82
83fn 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 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 if imports.len() > 1 {
113 let mut last_type = ImportType::System;
114
115 for (start, end, import_type) in &imports {
116 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, External, Internal, }
151
152fn parse_import_line(line: &str) -> Option<ImportType> {
154 if !line.starts_with("import") {
155 return None;
156 }
157
158 let from_pos = line.find(" from ")?;
161 let after_from = &line[from_pos + 6..].trim();
162
163 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 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
180fn is_nodejs_builtin(module: &str) -> bool {
182 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
221fn 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 if line.trim().is_empty() {
235 line_start = nl + 1;
236 continue;
237 }
238
239 if line.starts_with('\t') {
241 has_tabs = true;
242 } else if line.starts_with(' ') {
243 has_spaces = true;
244 }
245
246 let indent = count_leading_whitespace(line);
248 if indent > 0 && !line.trim().is_empty() {
249 if let Some(&last_indent) = indent_stack.last() {
251 if indent > last_indent {
252 let diff = indent - last_indent;
254 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 while let Some(&stack_indent) = indent_stack.last() {
262 if stack_indent <= indent {
263 break;
264 }
265 indent_stack.pop();
266 }
267 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 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 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 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
314fn 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 "mjs".to_string(),
358 "cjs".to_string(),
359 "mts".to_string(),
360 "cts".to_string(),
361 ],
362 supported_filenames: vec![
363 "package.json".to_string(),
364 "tsconfig.json".to_string(),
365 "jsconfig.json".to_string(),
366 ".eslintrc".to_string(),
367 ".prettierrc".to_string(),
368 "deno.json".to_string(),
369 "deno.jsonc".to_string(),
370 "bunfig.toml".to_string(),
371 ],
372 skip_filenames: vec![
373 "package-lock.json".to_string(),
374 "yarn.lock".to_string(),
375 "pnpm-lock.yaml".to_string(),
376 "bun.lockb".to_string(),
377 "deno.lock".to_string(),
378 "npm-shrinkwrap.json".to_string(),
379 ],
380 capabilities: vec![dictator_decree_abi::Capability::Lint],
381 }
382 }
383}
384
385#[must_use]
386pub fn init_decree() -> BoxDecree {
387 Box::new(TypeScript::default())
388}
389
390#[must_use]
392pub fn init_decree_with_config(config: TypeScriptConfig) -> BoxDecree {
393 Box::new(TypeScript::new(config, SupremeConfig::default()))
394}
395
396#[must_use]
398pub fn init_decree_with_configs(config: TypeScriptConfig, supreme: SupremeConfig) -> BoxDecree {
399 Box::new(TypeScript::new(config, supreme))
400}
401
402#[must_use]
404pub fn config_from_decree_settings(settings: &dictator_core::DecreeSettings) -> TypeScriptConfig {
405 TypeScriptConfig {
406 max_lines: settings.max_lines.unwrap_or(350),
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn detects_file_too_long() {
416 use std::fmt::Write;
417 let mut src = String::new();
419 for i in 0..400 {
420 let _ = writeln!(src, "const x{i} = {i};");
421 }
422 let diags = lint_source(&src);
423 assert!(
424 diags.iter().any(|d| d.rule == "typescript/file-too-long"),
425 "Should detect file with >350 code lines"
426 );
427 }
428
429 #[test]
430 fn ignores_comments_in_line_count() {
431 use std::fmt::Write;
432 let mut src = String::new();
434 for i in 0..340 {
435 let _ = writeln!(src, "const x{i} = {i};");
436 }
437 for i in 0..60 {
438 let _ = writeln!(src, "// Comment {i}");
439 }
440 let diags = lint_source(&src);
441 assert!(
442 !diags.iter().any(|d| d.rule == "typescript/file-too-long"),
443 "Should not count comment-only lines"
444 );
445 }
446
447 #[test]
448 fn ignores_blank_lines_in_count() {
449 use std::fmt::Write;
450 let mut src = String::new();
452 for i in 0..340 {
453 let _ = writeln!(src, "const x{i} = {i};");
454 }
455 for _ in 0..60 {
456 src.push('\n');
457 }
458 let diags = lint_source(&src);
459 assert!(
460 !diags.iter().any(|d| d.rule == "typescript/file-too-long"),
461 "Should not count blank lines"
462 );
463 }
464
465 #[test]
466 fn detects_wrong_import_order_system_after_external() {
467 let src = r"
468import { format } from 'date-fns';
469import * as fs from 'fs';
470import { config } from './config';
471";
472 let diags = lint_source(src);
473 assert!(
474 diags.iter().any(|d| d.rule == "typescript/import-order"),
475 "Should detect system import after external import"
476 );
477 }
478
479 #[test]
480 fn detects_wrong_import_order_internal_before_external() {
481 let src = r"
482import { config } from './config';
483import { format } from 'date-fns';
484import * as fs from 'fs';
485";
486 let diags = lint_source(src);
487 assert!(
488 diags.iter().any(|d| d.rule == "typescript/import-order"),
489 "Should detect wrong import order"
490 );
491 }
492
493 #[test]
494 fn accepts_correct_import_order() {
495 let src = r"
496import * as fs from 'fs';
497import * as path from 'path';
498import { format } from 'date-fns';
499import axios from 'axios';
500import { config } from './config';
501import type { Logger } from './types';
502";
503 let diags = lint_source(src);
504 assert!(
505 !diags.iter().any(|d| d.rule == "typescript/import-order"),
506 "Should accept correct import order"
507 );
508 }
509
510 #[test]
511 fn detects_mixed_tabs_and_spaces() {
512 let src = "function test() {\n\tconst x = 1;\n const y = 2;\n}\n";
513 let diags = lint_source(src);
514 assert!(
515 diags
516 .iter()
517 .any(|d| d.rule == "typescript/mixed-indentation"),
518 "Should detect mixed tabs and spaces"
519 );
520 }
521
522 #[test]
523 fn detects_inconsistent_indentation_depth() {
524 let src = r"
525function test() {
526 if (true) {
527 const x = 1;
528 }
529}
530";
531 let diags = lint_source(src);
532 assert!(
533 diags
534 .iter()
535 .any(|d| d.rule == "typescript/inconsistent-indentation"),
536 "Should detect inconsistent indentation depth (3 spaces instead of 2 or 4)"
537 );
538 }
539
540 #[test]
541 fn accepts_consistent_indentation() {
542 let src = r"
543function test() {
544 if (true) {
545 const x = 1;
546 const y = 2;
547 }
548}
549";
550 let diags = lint_source(src);
551 assert!(
552 !diags
553 .iter()
554 .any(|d| d.rule == "typescript/mixed-indentation"
555 || d.rule == "typescript/inconsistent-indentation"),
556 "Should accept consistent indentation"
557 );
558 }
559
560 #[test]
561 fn handles_empty_file() {
562 let src = "";
563 let diags = lint_source(src);
564 assert!(diags.is_empty(), "Empty file should have no violations");
565 }
566
567 #[test]
568 fn handles_file_with_only_comments() {
569 let src = "// Comment 1\n// Comment 2\n/* Block comment */\n";
570 let diags = lint_source(src);
571 assert!(
572 !diags.iter().any(|d| d.rule == "typescript/file-too-long"),
573 "File with only comments should not trigger line count"
574 );
575 }
576
577 #[test]
578 fn detects_nodejs_builtins_correctly() {
579 assert!(is_nodejs_builtin("fs"));
580 assert!(is_nodejs_builtin("path"));
581 assert!(is_nodejs_builtin("crypto"));
582 assert!(is_nodejs_builtin("events"));
583 assert!(!is_nodejs_builtin("date-fns"));
584 assert!(!is_nodejs_builtin("lodash"));
585 assert!(!is_nodejs_builtin("./config"));
586 }
587}