@frozenproductions/niteo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/Cargo.lock +458 -0
  2. package/Cargo.toml +13 -0
  3. package/LICENSE +21 -0
  4. package/README.md +119 -0
  5. package/bin/niteo.js +51 -0
  6. package/package.json +27 -0
  7. package/scripts/build-binary.js +23 -0
  8. package/src/app.rs +132 -0
  9. package/src/cli.rs +43 -0
  10. package/src/config.rs +989 -0
  11. package/src/discovery.rs +42 -0
  12. package/src/git.rs +63 -0
  13. package/src/main.rs +13 -0
  14. package/src/report.rs +490 -0
  15. package/src/rules/max_directory_depth.rs +125 -0
  16. package/src/rules/max_file_exports.rs +478 -0
  17. package/src/rules/max_items_per_directory.rs +145 -0
  18. package/src/rules/min_items_per_directory.rs +146 -0
  19. package/src/rules/no_barrel_files.rs +284 -0
  20. package/src/rules/no_comments.rs +222 -0
  21. package/src/rules/no_console.rs +242 -0
  22. package/src/rules/no_debugger.rs +209 -0
  23. package/src/rules/no_default_export.rs +241 -0
  24. package/src/rules/no_duplicate_file_names.rs +196 -0
  25. package/src/rules/no_empty_directories.rs +467 -0
  26. package/src/rules/no_empty_interface.rs +280 -0
  27. package/src/rules/no_enums.rs +208 -0
  28. package/src/rules/no_eval.rs +268 -0
  29. package/src/rules/no_export_star.rs +252 -0
  30. package/src/rules/no_inline_types.rs +367 -0
  31. package/src/rules/no_interface.rs +570 -0
  32. package/src/rules/no_large_file.rs +98 -0
  33. package/src/rules/no_logic_in_barrel.rs +346 -0
  34. package/src/rules/no_logic_in_domain.rs +987 -0
  35. package/src/rules/no_mutable_exports.rs +253 -0
  36. package/src/rules/no_upward_import.rs +427 -0
  37. package/src/rules/prefer_satisfies.rs +319 -0
  38. package/src/rules.rs +247 -0
@@ -0,0 +1,570 @@
1
+ use std::collections::HashMap;
2
+ use std::path::Path;
3
+
4
+ use crate::config::{NoInterfaceRuleConfig, Severity};
5
+ use crate::rules::Violation;
6
+
7
+ const RULE_NAME: &str = "no-interface";
8
+ const MESSAGE: &str = "Use a type alias instead of an interface.";
9
+
10
+ pub fn check_file(file: &Path, source: &str, config: &NoInterfaceRuleConfig) -> Vec<Violation> {
11
+ let scanner = Scanner::new(source);
12
+ let interfaces = scanner.find_interfaces();
13
+
14
+ if config.allow_declaration_merging {
15
+ let name_counts = count_interface_names(&interfaces);
16
+ interfaces
17
+ .into_iter()
18
+ .filter(|(name, _)| name_counts.get(name).copied().unwrap_or(0) <= 1)
19
+ .map(|(_, cursor)| interface_violation(file, &cursor, config.severity))
20
+ .collect()
21
+ } else {
22
+ interfaces
23
+ .into_iter()
24
+ .map(|(_, cursor)| interface_violation(file, &cursor, config.severity))
25
+ .collect()
26
+ }
27
+ }
28
+
29
+ #[derive(Debug)]
30
+ struct Scanner<'source> {
31
+ bytes: &'source [u8],
32
+ }
33
+
34
+ impl<'source> Scanner<'source> {
35
+ fn new(source: &'source str) -> Self {
36
+ Self {
37
+ bytes: source.as_bytes(),
38
+ }
39
+ }
40
+
41
+ fn find_interfaces(&self) -> Vec<(String, Cursor)> {
42
+ let mut interfaces = Vec::new();
43
+ let mut cursor = Cursor::default();
44
+ self.scan_code(&mut cursor, StopAt::End, &mut interfaces);
45
+ interfaces
46
+ }
47
+
48
+ fn scan_code(
49
+ &self,
50
+ cursor: &mut Cursor,
51
+ stop_at: StopAt,
52
+ interfaces: &mut Vec<(String, Cursor)>,
53
+ ) {
54
+ while cursor.index < self.bytes.len() {
55
+ if stop_at.should_stop(self.bytes, cursor.index) {
56
+ return;
57
+ }
58
+
59
+ let current = self.bytes[cursor.index];
60
+ let next = self.bytes.get(cursor.index + 1).copied();
61
+
62
+ match (current, next) {
63
+ (b'\'', _) | (b'"', _) => self.skip_quoted_string(cursor, current),
64
+ (b'`', _) => self.skip_template_literal(cursor),
65
+ (b'/', Some(b'/')) => self.skip_line_comment(cursor),
66
+ (b'/', Some(b'*')) => self.skip_block_comment(cursor),
67
+ (b'<', _) if is_jsx_tag_start(self.bytes, cursor.index) => {
68
+ self.skip_jsx_element(cursor, interfaces);
69
+ }
70
+ _ if self.starts_interface(cursor.index) => {
71
+ self.collect_interface(cursor, interfaces);
72
+ }
73
+ _ => cursor.advance(self.bytes),
74
+ }
75
+ }
76
+ }
77
+
78
+ fn skip_jsx_element(&self, cursor: &mut Cursor, interfaces: &mut Vec<(String, Cursor)>) {
79
+ let opening_tag = self.skip_jsx_tag(cursor, interfaces);
80
+ if opening_tag.is_self_closing || opening_tag.is_closing {
81
+ return;
82
+ }
83
+
84
+ self.skip_jsx_children(cursor, interfaces);
85
+ }
86
+
87
+ fn skip_jsx_children(&self, cursor: &mut Cursor, interfaces: &mut Vec<(String, Cursor)>) {
88
+ while cursor.index < self.bytes.len() {
89
+ let current = self.bytes[cursor.index];
90
+
91
+ match current {
92
+ b'{' => {
93
+ cursor.advance(self.bytes);
94
+ self.scan_code(cursor, StopAt::JsxExpressionEnd, interfaces);
95
+ if self.bytes.get(cursor.index) == Some(&b'}') {
96
+ cursor.advance(self.bytes);
97
+ }
98
+ }
99
+ b'<' if self.starts_jsx_closing_tag(cursor.index) => {
100
+ self.skip_jsx_tag(cursor, interfaces);
101
+ return;
102
+ }
103
+ b'<' if is_jsx_tag_start(self.bytes, cursor.index) => {
104
+ self.skip_jsx_element(cursor, interfaces);
105
+ }
106
+ _ => cursor.advance(self.bytes),
107
+ }
108
+ }
109
+ }
110
+
111
+ fn skip_jsx_tag(&self, cursor: &mut Cursor, interfaces: &mut Vec<(String, Cursor)>) -> JsxTag {
112
+ let is_closing = self.starts_jsx_closing_tag(cursor.index);
113
+ cursor.advance(self.bytes);
114
+
115
+ while cursor.index < self.bytes.len() {
116
+ let current = self.bytes[cursor.index];
117
+ let next = self.bytes.get(cursor.index + 1).copied();
118
+
119
+ match (current, next) {
120
+ (b'\'', _) | (b'"', _) => self.skip_quoted_string(cursor, current),
121
+ (b'`', _) => self.skip_template_literal(cursor),
122
+ (b'/', Some(b'>')) => {
123
+ cursor.advance(self.bytes);
124
+ cursor.advance(self.bytes);
125
+ return JsxTag {
126
+ is_closing,
127
+ is_self_closing: true,
128
+ };
129
+ }
130
+ (b'{', _) => {
131
+ cursor.advance(self.bytes);
132
+ self.scan_code(cursor, StopAt::JsxExpressionEnd, interfaces);
133
+ if self.bytes.get(cursor.index) == Some(&b'}') {
134
+ cursor.advance(self.bytes);
135
+ }
136
+ }
137
+ (b'>', _) => {
138
+ cursor.advance(self.bytes);
139
+ return JsxTag {
140
+ is_closing,
141
+ is_self_closing: false,
142
+ };
143
+ }
144
+ _ => cursor.advance(self.bytes),
145
+ }
146
+ }
147
+
148
+ JsxTag {
149
+ is_closing,
150
+ is_self_closing: false,
151
+ }
152
+ }
153
+
154
+ fn collect_interface(&self, cursor: &mut Cursor, interfaces: &mut Vec<(String, Cursor)>) {
155
+ let saved_cursor = *cursor;
156
+ cursor.index += b"interface".len();
157
+ cursor.column += b"interface".len();
158
+
159
+ self.skip_whitespace_and_comments(cursor);
160
+
161
+ let name_start = cursor.index;
162
+ if !self
163
+ .bytes
164
+ .get(name_start)
165
+ .copied()
166
+ .is_some_and(is_identifier_start)
167
+ {
168
+ return;
169
+ }
170
+
171
+ cursor.advance(self.bytes);
172
+ while cursor.index < self.bytes.len()
173
+ && self
174
+ .bytes
175
+ .get(cursor.index)
176
+ .copied()
177
+ .is_some_and(is_identifier_part)
178
+ {
179
+ cursor.advance(self.bytes);
180
+ }
181
+
182
+ let name = String::from_utf8_lossy(&self.bytes[name_start..cursor.index]).to_string();
183
+ interfaces.push((name, saved_cursor));
184
+ }
185
+
186
+ fn skip_template_literal(&self, cursor: &mut Cursor) {
187
+ cursor.advance(self.bytes);
188
+
189
+ while cursor.index < self.bytes.len() {
190
+ let current = self.bytes[cursor.index];
191
+
192
+ if current == b'\\' {
193
+ cursor.advance(self.bytes);
194
+ if cursor.index < self.bytes.len() {
195
+ cursor.advance(self.bytes);
196
+ }
197
+ continue;
198
+ }
199
+
200
+ if current == b'`' {
201
+ cursor.advance(self.bytes);
202
+ return;
203
+ }
204
+
205
+ cursor.advance(self.bytes);
206
+ }
207
+ }
208
+
209
+ fn skip_quoted_string(&self, cursor: &mut Cursor, quote: u8) {
210
+ cursor.advance(self.bytes);
211
+
212
+ while cursor.index < self.bytes.len() {
213
+ let current = self.bytes[cursor.index];
214
+
215
+ if current == b'\\' {
216
+ cursor.advance(self.bytes);
217
+ if cursor.index < self.bytes.len() {
218
+ cursor.advance(self.bytes);
219
+ }
220
+ continue;
221
+ }
222
+
223
+ cursor.advance(self.bytes);
224
+
225
+ if current == quote {
226
+ return;
227
+ }
228
+ }
229
+ }
230
+
231
+ fn skip_line_comment(&self, cursor: &mut Cursor) {
232
+ while cursor.index < self.bytes.len() && self.bytes[cursor.index] != b'\n' {
233
+ cursor.advance(self.bytes);
234
+ }
235
+ }
236
+
237
+ fn skip_block_comment(&self, cursor: &mut Cursor) {
238
+ cursor.advance(self.bytes);
239
+ cursor.advance(self.bytes);
240
+
241
+ while cursor.index < self.bytes.len() {
242
+ let current = self.bytes[cursor.index];
243
+ let next = self.bytes.get(cursor.index + 1).copied();
244
+
245
+ cursor.advance(self.bytes);
246
+
247
+ if current == b'*' && next == Some(b'/') {
248
+ cursor.advance(self.bytes);
249
+ break;
250
+ }
251
+ }
252
+ }
253
+
254
+ fn skip_whitespace_and_comments(&self, cursor: &mut Cursor) {
255
+ loop {
256
+ while cursor.index < self.bytes.len() && self.bytes[cursor.index].is_ascii_whitespace()
257
+ {
258
+ cursor.advance(self.bytes);
259
+ }
260
+
261
+ if cursor.index >= self.bytes.len() {
262
+ break;
263
+ }
264
+
265
+ match (
266
+ self.bytes.get(cursor.index),
267
+ self.bytes.get(cursor.index + 1),
268
+ ) {
269
+ (Some(b'/'), Some(b'/')) => self.skip_line_comment(cursor),
270
+ (Some(b'/'), Some(b'*')) => self.skip_block_comment(cursor),
271
+ _ => break,
272
+ }
273
+ }
274
+ }
275
+
276
+ fn starts_interface(&self, index: usize) -> bool {
277
+ starts_keyword(self.bytes, index, b"interface")
278
+ }
279
+
280
+ fn starts_jsx_closing_tag(&self, index: usize) -> bool {
281
+ self.bytes.get(index) == Some(&b'<') && self.bytes.get(index + 1) == Some(&b'/')
282
+ }
283
+ }
284
+
285
+ #[derive(Debug, Clone, Copy)]
286
+ struct JsxTag {
287
+ is_closing: bool,
288
+ is_self_closing: bool,
289
+ }
290
+
291
+ #[derive(Debug, Clone, Copy)]
292
+ enum StopAt {
293
+ End,
294
+ JsxExpressionEnd,
295
+ }
296
+
297
+ impl StopAt {
298
+ fn should_stop(self, bytes: &[u8], index: usize) -> bool {
299
+ matches!(self, Self::JsxExpressionEnd) && bytes.get(index) == Some(&b'}')
300
+ }
301
+ }
302
+
303
+ fn is_jsx_tag_start(bytes: &[u8], index: usize) -> bool {
304
+ let after = index + 1;
305
+ match bytes.get(after) {
306
+ Some(b'/') | Some(b'>') => true,
307
+ Some(b'a'..=b'z') | Some(b'A'..=b'Z') => true,
308
+ _ => false,
309
+ }
310
+ }
311
+
312
+ fn count_interface_names(interfaces: &[(String, Cursor)]) -> HashMap<String, usize> {
313
+ let mut counts = HashMap::new();
314
+ for (name, _) in interfaces {
315
+ *counts.entry(name.clone()).or_insert(0) += 1;
316
+ }
317
+ counts
318
+ }
319
+
320
+ #[derive(Debug, Clone, Copy)]
321
+ struct Cursor {
322
+ index: usize,
323
+ line: usize,
324
+ column: usize,
325
+ }
326
+
327
+ impl Default for Cursor {
328
+ fn default() -> Self {
329
+ Self {
330
+ index: 0,
331
+ line: 1,
332
+ column: 1,
333
+ }
334
+ }
335
+ }
336
+
337
+ impl Cursor {
338
+ fn advance(&mut self, bytes: &[u8]) {
339
+ if bytes[self.index] == b'\n' {
340
+ self.line += 1;
341
+ self.column = 1;
342
+ } else {
343
+ self.column += 1;
344
+ }
345
+
346
+ self.index += 1;
347
+ }
348
+ }
349
+
350
+ fn interface_violation(file: &Path, cursor: &Cursor, severity: Severity) -> Violation {
351
+ Violation {
352
+ file: file.to_path_buf(),
353
+ line: Some(cursor.line),
354
+ column: Some(cursor.column),
355
+ rule: RULE_NAME,
356
+ message: MESSAGE,
357
+ severity,
358
+ detail: None,
359
+ subject: None,
360
+ }
361
+ }
362
+
363
+ fn starts_keyword(bytes: &[u8], index: usize, keyword: &[u8]) -> bool {
364
+ bytes.get(index..index + keyword.len()) == Some(keyword)
365
+ && !is_identifier_byte(bytes.get(index.wrapping_sub(1)).copied())
366
+ && !is_identifier_byte(bytes.get(index + keyword.len()).copied())
367
+ }
368
+
369
+ fn is_identifier_byte(byte: Option<u8>) -> bool {
370
+ byte.is_some_and(is_identifier_part)
371
+ }
372
+
373
+ fn is_identifier_start(byte: u8) -> bool {
374
+ byte.is_ascii_alphabetic() || matches!(byte, b'_' | b'$')
375
+ }
376
+
377
+ fn is_identifier_part(byte: u8) -> bool {
378
+ byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$')
379
+ }
380
+
381
+ #[cfg(test)]
382
+ mod tests {
383
+ use super::check_file;
384
+ use crate::config::{NoInterfaceRuleConfig, Severity};
385
+ use std::path::Path;
386
+
387
+ fn test_config() -> NoInterfaceRuleConfig {
388
+ NoInterfaceRuleConfig {
389
+ severity: Severity::Warn,
390
+ allow_declaration_merging: true,
391
+ }
392
+ }
393
+
394
+ fn strict_config() -> NoInterfaceRuleConfig {
395
+ NoInterfaceRuleConfig {
396
+ severity: Severity::Warn,
397
+ allow_declaration_merging: false,
398
+ }
399
+ }
400
+
401
+ #[test]
402
+ fn reports_single_interface() {
403
+ let violations = check_file(
404
+ Path::new("types.ts"),
405
+ "interface User { name: string }\n",
406
+ &test_config(),
407
+ );
408
+
409
+ assert_eq!(violations.len(), 1);
410
+ assert_eq!(violations[0].line, Some(1));
411
+ assert_eq!(violations[0].column, Some(1));
412
+ }
413
+
414
+ #[test]
415
+ fn allows_declaration_merging() {
416
+ let source = r#"interface User { name: string }
417
+ interface User { age: number }
418
+ "#;
419
+ let violations = check_file(Path::new("types.ts"), source, &test_config());
420
+
421
+ assert!(violations.is_empty());
422
+ }
423
+
424
+ #[test]
425
+ fn reports_all_interfaces_when_merging_disabled() {
426
+ let source = r#"interface User { name: string }
427
+ interface User { age: number }
428
+ "#;
429
+ let violations = check_file(Path::new("types.ts"), source, &strict_config());
430
+
431
+ assert_eq!(violations.len(), 2);
432
+ assert_eq!(violations[0].line, Some(1));
433
+ assert_eq!(violations[1].line, Some(2));
434
+ }
435
+
436
+ #[test]
437
+ fn reports_mixed_interfaces() {
438
+ let source = r#"interface User { name: string }
439
+ interface User { age: number }
440
+ interface Post { title: string }
441
+ "#;
442
+ let violations = check_file(Path::new("types.ts"), source, &test_config());
443
+
444
+ assert_eq!(violations.len(), 1);
445
+ assert_eq!(violations[0].line, Some(3));
446
+ assert_eq!(violations[0].column, Some(1));
447
+ }
448
+
449
+ #[test]
450
+ fn ignores_interface_in_comments_and_strings() {
451
+ let source = r#"// interface User { name: string }
452
+ const text = "interface User";
453
+ /* interface Post { title: string } */
454
+ "#;
455
+ let violations = check_file(Path::new("types.ts"), source, &test_config());
456
+
457
+ assert!(violations.is_empty());
458
+ }
459
+
460
+ #[test]
461
+ fn does_not_match_identifier_fragments() {
462
+ let source = r#"const interfacex = true;
463
+ "#;
464
+ let violations = check_file(Path::new("types.ts"), source, &test_config());
465
+
466
+ assert!(violations.is_empty());
467
+ }
468
+
469
+ #[test]
470
+ fn ignores_interface_in_jsx_text() {
471
+ let source = r#"<p className="mt-1">
472
+ Scale the full app interface for the current window.
473
+ </p>
474
+ "#;
475
+ let violations = check_file(Path::new("component.tsx"), source, &test_config());
476
+
477
+ assert!(violations.is_empty());
478
+ }
479
+
480
+ #[test]
481
+ fn ignores_interface_in_jsx_text_with_bracketed_tailwind_class() {
482
+ let source = r#"<p className="mt-1 text-xs leading-[1.55] text-fumi-400">
483
+ Scale the full app interface for the current window.
484
+ </p>
485
+ "#;
486
+ let violations = check_file(Path::new("component.tsx"), source, &test_config());
487
+
488
+ assert!(violations.is_empty());
489
+ }
490
+
491
+ #[test]
492
+ fn ignores_interface_in_nested_jsx_text() {
493
+ let source = r#"<div>
494
+ <p>The user interface is ready.</p>
495
+ <span>interface keyword in text</span>
496
+ </div>
497
+ "#;
498
+ let violations = check_file(Path::new("component.tsx"), source, &test_config());
499
+
500
+ assert!(violations.is_empty());
501
+ }
502
+
503
+ #[test]
504
+ fn reports_interface_in_jsx_expression() {
505
+ let source = r#"<div>
506
+ {interface User { name: string }}
507
+ </div>
508
+ "#;
509
+ let violations = check_file(Path::new("component.tsx"), source, &strict_config());
510
+
511
+ assert_eq!(violations.len(), 1);
512
+ }
513
+
514
+ #[test]
515
+ fn ignores_interface_in_jsx_attribute_values() {
516
+ let source = r#"<Component tooltip="This interface is deprecated" />
517
+ "#;
518
+ let violations = check_file(Path::new("component.tsx"), source, &test_config());
519
+
520
+ assert!(violations.is_empty());
521
+ }
522
+
523
+ #[test]
524
+ fn handles_jsx_fragments() {
525
+ let source = r#"<><p>interface text</p></>
526
+ "#;
527
+ let violations = check_file(Path::new("component.tsx"), source, &test_config());
528
+
529
+ assert!(violations.is_empty());
530
+ }
531
+
532
+ #[test]
533
+ fn handles_jsx_with_expressions() {
534
+ let source = r#"<div>
535
+ {user.name}
536
+ <p>interface description</p>
537
+ {count}
538
+ </div>
539
+ "#;
540
+ let violations = check_file(Path::new("component.tsx"), source, &test_config());
541
+
542
+ assert!(violations.is_empty());
543
+ }
544
+
545
+ #[test]
546
+ fn handles_jsx_attribute_expression_with_nested_object() {
547
+ let source = r#"<Component
548
+ options={{ label: "interface label", value: count }}
549
+ >
550
+ interface text
551
+ </Component>
552
+ "#;
553
+ let violations = check_file(Path::new("component.tsx"), source, &test_config());
554
+
555
+ assert!(violations.is_empty());
556
+ }
557
+
558
+ #[test]
559
+ fn reports_interface_after_jsx() {
560
+ let source = r#"const element = <p>interface text</p>;
561
+
562
+ interface User { name: string }
563
+ "#;
564
+ let violations = check_file(Path::new("component.tsx"), source, &strict_config());
565
+
566
+ assert_eq!(violations.len(), 1);
567
+ assert_eq!(violations[0].line, Some(3));
568
+ assert_eq!(violations[0].column, Some(1));
569
+ }
570
+ }
@@ -0,0 +1,98 @@
1
+ use std::path::Path;
2
+
3
+ use crate::config::FileLengthRuleConfig;
4
+ use crate::rules::Violation;
5
+
6
+ const RULE_NAME: &str = "no-large-file";
7
+ const MESSAGE: &str = "Split this file into focused modules.";
8
+
9
+ pub fn check_file(file: &Path, source: &str, config: &FileLengthRuleConfig) -> Vec<Violation> {
10
+ let line_count = source.lines().count();
11
+ if line_count <= config.max_lines {
12
+ return Vec::new();
13
+ }
14
+
15
+ let location = last_location(source);
16
+
17
+ vec![Violation {
18
+ file: file.to_path_buf(),
19
+ line: Some(location.line),
20
+ column: Some(location.column),
21
+ rule: RULE_NAME,
22
+ message: MESSAGE,
23
+ severity: config.severity,
24
+ detail: None,
25
+ subject: None,
26
+ }]
27
+ }
28
+
29
+ #[derive(Debug, Clone, Copy)]
30
+ struct Location {
31
+ line: usize,
32
+ column: usize,
33
+ }
34
+
35
+ fn last_location(source: &str) -> Location {
36
+ let mut line = 1;
37
+ let mut column = 1;
38
+
39
+ for byte in source.as_bytes() {
40
+ if *byte == b'\n' {
41
+ line += 1;
42
+ column = 1;
43
+ } else {
44
+ column += 1;
45
+ }
46
+ }
47
+
48
+ Location { line, column }
49
+ }
50
+
51
+ #[cfg(test)]
52
+ mod tests {
53
+ use super::check_file;
54
+ use crate::config::{FileLengthRuleConfig, Severity};
55
+ use std::path::Path;
56
+
57
+ #[test]
58
+ fn allows_files_within_limit() {
59
+ let source = "line 1\nline 2\nline 3\n";
60
+ let violations = check_file(Path::new("file.ts"), source, &test_config(3));
61
+
62
+ assert!(violations.is_empty());
63
+ }
64
+
65
+ #[test]
66
+ fn reports_files_over_limit() {
67
+ let source = "line 1\nline 2\nline 3\nline 4\n";
68
+ let violations = check_file(Path::new("file.ts"), source, &test_config(3));
69
+
70
+ assert_eq!(violations.len(), 1);
71
+ assert_eq!(violations[0].line, Some(5));
72
+ assert_eq!(violations[0].column, Some(1));
73
+ }
74
+
75
+ #[test]
76
+ fn reports_last_column_when_file_has_no_trailing_newline() {
77
+ let source = "line 1\nline 2\nline 3";
78
+ let violations = check_file(Path::new("file.ts"), source, &test_config(2));
79
+
80
+ assert_eq!(violations.len(), 1);
81
+ assert_eq!(violations[0].line, Some(3));
82
+ assert_eq!(violations[0].column, Some(7));
83
+ }
84
+
85
+ #[test]
86
+ fn counts_empty_file_as_zero_lines() {
87
+ let violations = check_file(Path::new("file.ts"), "", &test_config(1));
88
+
89
+ assert!(violations.is_empty());
90
+ }
91
+
92
+ fn test_config(max_lines: usize) -> FileLengthRuleConfig {
93
+ FileLengthRuleConfig {
94
+ severity: Severity::Warn,
95
+ max_lines,
96
+ }
97
+ }
98
+ }