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