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