1use std::path::{Path, PathBuf};
5
6use anyhow::{anyhow, Context, Result};
7use oxc::allocator::Allocator;
8use oxc::ast::ast::{
9 Declaration, ExportDefaultDeclarationKind, Expression, Statement, VariableDeclaration,
10};
11use oxc::parser::Parser;
12use oxc::span::{GetSpan, SourceType};
13use rustpython_ast::Ranged;
14use rustpython_parser::ast::{self, Constant, Expr, Stmt};
15use rustpython_parser::text_size::TextSize;
16use rustpython_parser::Parse;
17use syn::spanned::Spanned;
18
19pub use crate::violation::Violation;
20
21use crate::colocated_test::Language;
22
23const RULE: &str = "one-function-per-file";
25
26#[derive(Debug, PartialEq, Eq)]
28struct Function {
29 name: String,
30 line: usize,
31 body_lines: usize,
32}
33
34pub fn find_violations(
39 root: impl AsRef<Path>,
40 language: Language,
41 max_lines: u32,
42) -> Result<(Vec<Violation>, usize)> {
43 let root = root.as_ref();
44 let files = source_files(root, language)?;
45
46 let mut violations = Vec::new();
47 for file in &files {
48 let source = std::fs::read_to_string(file)
49 .with_context(|| format!("reading source file `{}`", file.display()))?;
50 let mut over = functions(&source, file, language)?
51 .into_iter()
52 .filter(|function| function.body_lines > max_lines as usize);
53 let Some(holder) = over.next() else {
54 continue;
55 };
56 for extra in over {
57 violations.push(Violation {
58 file: file.clone(),
59 line: extra.line,
60 rule: RULE,
61 message: format!(
62 "`{}` runs {} lines, and `{}` already holds this file; \
63 move it to its own module",
64 extra.name, extra.body_lines, holder.name
65 ),
66 });
67 }
68 }
69 Ok((violations, files.len()))
70}
71
72fn source_files(root: &Path, language: Language) -> Result<Vec<PathBuf>> {
75 let mut files = Vec::new();
76 if language == Language::Rust {
77 crate::colocated_test::collect_rust_source_files(root, &mut files)?;
78 files.sort();
79 return Ok(files);
80 }
81 crate::colocated_test::collect_files(root, language, &mut files)?;
82 let manifest = match language {
83 Language::Python => "pyproject.toml",
84 _ => "package.json",
85 };
86 if let Some(tests) = crate::tiers::suite_tests_dir(root, manifest) {
87 files.retain(|file| !file.starts_with(&tests));
88 }
89 files.retain(|file| !language.is_test(file) && !language.is_support(file));
90 files.sort();
91 Ok(files)
92}
93
94fn functions(source: &str, path: &Path, language: Language) -> Result<Vec<Function>> {
96 match language {
97 Language::Python => python_functions(source, path),
98 Language::TypeScript => typescript_functions(source, path),
99 Language::Rust => rust_functions(source, path),
100 }
101}
102
103fn python_functions(source: &str, path: &Path) -> Result<Vec<Function>> {
105 let suite = ast::Suite::parse(source, &path.to_string_lossy())
106 .map_err(|err| anyhow!("parsing `{}`: {err}", path.display()))?;
107 let lines: Vec<&str> = source.lines().collect();
108 let mut found = Vec::new();
109 for statement in &suite {
110 let (name, body, range) = match statement {
111 Stmt::FunctionDef(node) => (&node.name, &node.body, node.range),
112 Stmt::AsyncFunctionDef(node) => (&node.name, &node.body, node.range),
113 _ => continue,
114 };
115 found.push(Function {
116 name: name.to_string(),
117 line: line_of(source, range.start()),
118 body_lines: python_body_lines(source, &lines, body),
119 });
120 }
121 Ok(found)
122}
123
124fn python_body_lines(source: &str, lines: &[&str], body: &[Stmt]) -> usize {
126 let start = body.iter().position(|statement| !is_docstring(statement));
127 let Some(start) = start else {
128 return 0;
129 };
130 let first = line_of(source, body[start].range().start());
131 let last = line_of(source, body[body.len() - 1].range().end());
132 code_lines(lines, first, last, Comment::Hash)
133}
134
135fn is_docstring(statement: &Stmt) -> bool {
137 let Stmt::Expr(node) = statement else {
138 return false;
139 };
140 matches!(
141 node.value.as_ref(),
142 Expr::Constant(constant) if matches!(constant.value, Constant::Str(_))
143 )
144}
145
146fn typescript_functions(source: &str, path: &Path) -> Result<Vec<Function>> {
149 let allocator = Allocator::default();
150 let source_type = SourceType::from_path(path)
151 .map_err(|err| anyhow!("reading the source type of `{}`: {err}", path.display()))?;
152 let parsed = Parser::new(&allocator, source, source_type).parse();
153 if parsed.panicked || !parsed.diagnostics.is_empty() {
154 return Err(anyhow!("parsing `{}`", path.display()));
155 }
156 let lines: Vec<&str> = source.lines().collect();
157 let mut found = Vec::new();
158 for statement in &parsed.program.body {
159 push_ts_statement(source, &lines, statement, &mut found);
160 }
161 Ok(found)
162}
163
164fn push_ts_statement(source: &str, lines: &[&str], statement: &Statement, out: &mut Vec<Function>) {
167 match statement {
168 Statement::FunctionDeclaration(node) => push_ts_function(source, lines, node, out),
169 Statement::VariableDeclaration(node) => push_ts_bindings(source, lines, node, out),
170 Statement::ExportNamedDeclaration(node) => {
171 push_ts_declaration(source, lines, node.declaration.as_ref(), out);
172 }
173 Statement::ExportDefaultDeclaration(node) => {
174 push_ts_default(source, lines, &node.declaration, out);
175 }
176 _ => {}
177 }
178}
179
180fn push_ts_declaration(
183 source: &str,
184 lines: &[&str],
185 declaration: Option<&Declaration>,
186 out: &mut Vec<Function>,
187) {
188 match declaration {
189 Some(Declaration::FunctionDeclaration(inner)) => {
190 push_ts_function(source, lines, inner, out);
191 }
192 Some(Declaration::VariableDeclaration(inner)) => {
193 push_ts_bindings(source, lines, inner, out);
194 }
195 _ => {}
196 }
197}
198
199fn push_ts_default(
202 source: &str,
203 lines: &[&str],
204 declaration: &ExportDefaultDeclarationKind,
205 out: &mut Vec<Function>,
206) {
207 if let ExportDefaultDeclarationKind::FunctionDeclaration(inner) = declaration {
208 push_ts_function(source, lines, inner, out);
209 }
210}
211
212fn push_ts_function(
214 source: &str,
215 lines: &[&str],
216 node: &oxc::ast::ast::Function,
217 out: &mut Vec<Function>,
218) {
219 let Some(body) = &node.body else {
220 return;
221 };
222 let name = node
223 .id
224 .as_ref()
225 .map(|id| id.name.to_string())
226 .unwrap_or_else(|| "default".to_string());
227 out.push(Function {
228 name,
229 line: line_of(source, TextSize::from(node.span.start)),
230 body_lines: ts_body_lines(source, lines, body),
231 });
232}
233
234fn push_ts_bindings(
237 source: &str,
238 lines: &[&str],
239 node: &VariableDeclaration,
240 out: &mut Vec<Function>,
241) {
242 for declarator in &node.declarations {
243 let body = match &declarator.init {
244 Some(Expression::ArrowFunctionExpression(arrow)) => Some(arrow.body.as_ref()),
245 Some(Expression::FunctionExpression(function)) => function.body.as_deref(),
246 _ => None,
247 };
248 let Some(body) = body else {
249 continue;
250 };
251 let Some(name) = declarator.id.get_identifier_name() else {
252 continue;
253 };
254 out.push(Function {
255 name: name.to_string(),
256 line: line_of(source, TextSize::from(declarator.span.start)),
257 body_lines: ts_body_lines(source, lines, body),
258 });
259 }
260}
261
262fn ts_body_lines(source: &str, lines: &[&str], body: &oxc::ast::ast::FunctionBody) -> usize {
264 let (Some(first), Some(last)) = (body.statements.first(), body.statements.last()) else {
265 return 0;
266 };
267 code_lines(
268 lines,
269 line_of(source, TextSize::from(first.span().start)),
270 line_of(source, TextSize::from(last.span().end)),
271 Comment::Slash,
272 )
273}
274
275fn rust_functions(source: &str, path: &Path) -> Result<Vec<Function>> {
277 let ast =
278 syn::parse_file(source).map_err(|err| anyhow!("parsing `{}`: {err}", path.display()))?;
279 let lines: Vec<&str> = source.lines().collect();
280 let mut found = Vec::new();
281 for item in &ast.items {
282 let syn::Item::Fn(node) = item else {
283 continue;
284 };
285 found.push(Function {
286 name: node.sig.ident.to_string(),
287 line: node.sig.ident.span().start().line,
288 body_lines: rust_body_lines(&lines, &node.block),
289 });
290 }
291 Ok(found)
292}
293
294fn rust_body_lines(lines: &[&str], block: &syn::Block) -> usize {
296 let (Some(first), Some(last)) = (block.stmts.first(), block.stmts.last()) else {
297 return 0;
298 };
299 code_lines(
300 lines,
301 first.span().start().line,
302 last.span().end().line,
303 Comment::Slash,
304 )
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309enum Comment {
310 Hash,
312 Slash,
314}
315
316fn code_lines(lines: &[&str], first: usize, last: usize, comment: Comment) -> usize {
319 lines
320 .iter()
321 .skip(first.saturating_sub(1))
322 .take(last.saturating_sub(first) + 1)
323 .filter(|line| {
324 let trimmed = line.trim();
325 !trimmed.is_empty() && !is_comment(trimmed, comment)
326 })
327 .count()
328}
329
330fn is_comment(trimmed: &str, comment: Comment) -> bool {
333 match comment {
334 Comment::Hash => trimmed.starts_with('#'),
335 Comment::Slash => {
336 trimmed.starts_with("//")
337 || trimmed.starts_with("/*")
338 || trimmed == "*"
339 || trimmed.starts_with("* ")
340 || trimmed.starts_with("*/")
341 }
342 }
343}
344
345fn line_of(source: &str, offset: TextSize) -> usize {
347 let offset = (u32::from(offset) as usize).min(source.len());
348 source.as_bytes()[..offset]
349 .iter()
350 .filter(|&&byte| byte == b'\n')
351 .count()
352 + 1
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 fn python(source: &str) -> Vec<(String, usize)> {
361 python_functions(source, Path::new("widget.py"))
362 .expect("the snippet parses")
363 .into_iter()
364 .map(|function| (function.name, function.body_lines))
365 .collect()
366 }
367
368 fn typescript(source: &str) -> Vec<(String, usize)> {
370 typescript_functions(source, Path::new("widget.ts"))
371 .expect("the snippet parses")
372 .into_iter()
373 .map(|function| (function.name, function.body_lines))
374 .collect()
375 }
376
377 fn rust(source: &str) -> Vec<(String, usize)> {
379 rust_functions(source, Path::new("widget.rs"))
380 .expect("the snippet parses")
381 .into_iter()
382 .map(|function| (function.name, function.body_lines))
383 .collect()
384 }
385
386 #[test]
387 fn python_counts_module_level_defs_and_their_body_lines() {
388 let found = python(
389 "def alpha(value):\n total = value + 1\n return total\n\n\
390 async def beta(value):\n return value\n",
391 );
392 assert_eq!(
393 found,
394 vec![("alpha".to_string(), 2), ("beta".to_string(), 1)]
395 );
396 }
397
398 #[test]
399 fn python_skips_methods_and_nested_functions() {
400 let found = python(
401 "class Widget:\n def grow(self):\n self.size += 1\n return self.size\n\n\
402 def build(values):\n def inner(value):\n return value * 2\n\n return inner\n",
403 );
404 assert_eq!(found, vec![("build".to_string(), 3)]);
405 }
406
407 #[test]
408 fn python_excludes_the_docstring_blank_lines_and_comments() {
409 let found = python(
410 "def described(value):\n \"\"\"Return the value unchanged.\"\"\"\n\
411 \x20 # the identity is the whole contract\n\n return value\n",
412 );
413 assert_eq!(found, vec![("described".to_string(), 1)]);
414 }
415
416 #[test]
417 fn python_reports_a_decorated_function_by_name() {
418 let found = python("@cache\ndef alpha(value):\n return value\n");
419 assert_eq!(found, vec![("alpha".to_string(), 1)]);
420 }
421
422 #[test]
423 fn python_counts_an_empty_body_as_no_lines() {
424 let found = python("def stub():\n \"\"\"Nothing yet.\"\"\"\n");
425 assert_eq!(found, vec![("stub".to_string(), 0)]);
426 }
427
428 #[test]
429 fn typescript_counts_declarations_and_function_bound_bindings() {
430 let found = typescript(
431 "export function alpha(value: number): number {\n const total = value + 1;\n return total;\n}\n\
432 const beta = (value: number): number => value * 2;\n\
433 export const gamma = function (value: number): number {\n return value;\n};\n",
434 );
435 assert_eq!(
436 found,
437 vec![
438 ("alpha".to_string(), 2),
439 ("beta".to_string(), 1),
440 ("gamma".to_string(), 1),
441 ]
442 );
443 }
444
445 #[test]
446 fn typescript_skips_methods_nested_arrows_and_non_function_bindings() {
447 let found = typescript(
448 "const SIZE = 3;\n\
449 export class Widget {\n grow(amount: number): number {\n return amount;\n }\n}\n\
450 export function build(values: number[]): number[] {\n const inner = (v: number) => v * 2;\n return values.map(inner);\n}\n",
451 );
452 assert_eq!(found, vec![("build".to_string(), 2)]);
453 }
454
455 #[test]
456 fn typescript_counts_an_export_default_function() {
457 let found = typescript(
458 "export default function alpha(value: number): number {\n const total = value + 1;\n return total;\n}\n",
459 );
460 assert_eq!(found, vec![("alpha".to_string(), 2)]);
461 }
462
463 #[test]
464 fn typescript_skips_an_overload_signature() {
465 let found = typescript(
466 "export function alpha(value: number): number;\n\
467 export function alpha(value: string): string;\n\
468 export function alpha(value: unknown): unknown {\n const echoed = value;\n return echoed;\n}\n",
469 );
470 assert_eq!(found, vec![("alpha".to_string(), 2)]);
471 }
472
473 #[test]
474 fn typescript_excludes_comment_lines_from_the_body() {
475 let found = typescript(
476 "export function described(value: number): number {\n // the identity is the whole contract\n\n return value;\n}\n",
477 );
478 assert_eq!(found, vec![("described".to_string(), 1)]);
479 }
480
481 #[test]
482 fn rust_counts_top_level_items_only() {
483 let found = rust(
484 "pub struct Widget;\n\
485 impl Widget {\n pub fn grow(&self) -> u8 {\n 1\n }\n}\n\
486 pub fn build(values: &[u8]) -> u8 {\n fn inner(v: u8) -> u8 {\n v * 2\n }\n inner(values[0])\n}\n",
487 );
488 assert_eq!(found, vec![("build".to_string(), 4)]);
489 }
490
491 #[test]
492 fn rust_skips_functions_in_an_inline_test_module() {
493 let found = rust(
494 "pub fn ratio(a: u8, b: u8) -> u8 {\n (a + b) / 2\n}\n\
495 #[cfg(test)]\nmod tests {\n #[test]\n fn halves() {\n let x = 1;\n assert_eq!(x, 1);\n }\n}\n",
496 );
497 assert_eq!(found, vec![("ratio".to_string(), 1)]);
498 }
499
500 #[test]
501 fn rust_excludes_doc_comments_and_body_comments() {
502 let found = rust(
503 "/// Return the value unchanged.\npub fn described(value: u8) -> u8 {\n // the identity is the whole contract\n\n value\n}\n",
504 );
505 assert_eq!(found, vec![("described".to_string(), 1)]);
506 }
507
508 #[test]
509 fn rust_counts_an_empty_body_as_no_lines() {
510 let found = rust("pub fn stub() {}\n");
511 assert_eq!(found, vec![("stub".to_string(), 0)]);
512 }
513
514 #[test]
515 fn typescript_counts_a_plain_function_declaration() {
516 let found = typescript(
517 "function alpha(value: number): number {\n const total = value + 1;\n return total;\n}\n",
518 );
519 assert_eq!(found, vec![("alpha".to_string(), 2)]);
520 }
521
522 #[test]
523 fn typescript_skips_non_function_defaults_and_imports() {
524 let found = typescript(
525 "import { x } from './x';\nexport default class Widget {}\n\
526 const beta = (value: number): number => value;\n",
527 );
528 assert_eq!(found, vec![("beta".to_string(), 1)]);
529 }
530
531 #[test]
532 fn typescript_skips_a_destructured_function_binding() {
533 let found = typescript("const { a } = () => {};\n");
534 assert!(found.is_empty(), "got: {found:?}");
535 }
536
537 #[test]
538 fn typescript_counts_an_empty_arrow_body_as_no_lines() {
539 let found = typescript("const stub = () => {};\n");
540 assert_eq!(found, vec![("stub".to_string(), 0)]);
541 }
542
543 #[test]
544 fn typescript_parse_error_is_reported() {
545 let err = typescript_functions("const x = ;\n", Path::new("bad.ts")).unwrap_err();
546 assert!(err.to_string().contains("parsing"), "got: {err}");
547 }
548
549 fn unique_tmp(slug: &str) -> PathBuf {
550 use std::sync::atomic::{AtomicU64, Ordering};
551 static COUNTER: AtomicU64 = AtomicU64::new(0);
552 std::env::temp_dir().join(format!(
553 "tc-one-function-{slug}-{}-{}",
554 std::process::id(),
555 COUNTER.fetch_add(1, Ordering::Relaxed)
556 ))
557 }
558
559 #[test]
560 fn suite_tier_files_are_not_judged() {
561 let root = unique_tmp("suite");
562 std::fs::create_dir_all(root.join("tests")).unwrap();
563 std::fs::write(root.join("pyproject.toml"), "[project]\nname = \"w\"\n").unwrap();
564 let two_functions = "def alpha():\n return 1\n\ndef beta():\n return 2\n";
565 std::fs::write(root.join("widget.py"), two_functions).unwrap();
566 std::fs::write(root.join("tests").join("helper.py"), two_functions).unwrap();
567 let (found, _) = find_violations(&root, Language::Python, 0).expect("the tree scans");
568 assert_eq!(found.len(), 1, "got: {found:?}");
569 assert!(found[0].file.ends_with("widget.py"), "got: {found:?}");
570 }
571
572 #[test]
573 fn a_root_without_a_manifest_judges_every_source_file() {
574 let root = unique_tmp("no-manifest");
575 std::fs::create_dir_all(&root).unwrap();
576 let two_functions = "def alpha():\n return 1\n\ndef beta():\n return 2\n";
577 std::fs::write(root.join("widget.py"), two_functions).unwrap();
578 let (found, _) = find_violations(&root, Language::Python, 0).expect("the tree scans");
579 assert_eq!(found.len(), 1, "got: {found:?}");
580 }
581
582 #[test]
583 fn a_typescript_suite_tier_is_not_judged() {
584 let root = unique_tmp("ts-suite");
585 std::fs::create_dir_all(root.join("tests")).unwrap();
586 std::fs::write(root.join("package.json"), "{ \"name\": \"w\" }\n").unwrap();
587 let two = "const alpha = () => {\n return 1;\n};\nconst beta = () => {\n return 2;\n};\n";
588 std::fs::write(root.join("widget.ts"), two).unwrap();
589 std::fs::write(root.join("tests").join("helper.ts"), two).unwrap();
590 let (found, _) = find_violations(&root, Language::TypeScript, 0).expect("the tree scans");
591 assert_eq!(found.len(), 1, "got: {found:?}");
592 assert!(found[0].file.ends_with("widget.ts"), "got: {found:?}");
593 }
594
595 #[test]
596 fn a_rust_root_collects_only_rust_sources() {
597 let root = unique_tmp("rust-root");
598 std::fs::create_dir_all(&root).unwrap();
599 std::fs::write(root.join("widget.rs"), "pub fn one() -> u8 {\n 1\n}\n").unwrap();
600 let (found, _) = find_violations(&root, Language::Rust, 0).expect("the tree scans");
601 assert!(found.is_empty(), "got: {found:?}");
602 }
603
604 #[test]
605 fn an_unreadable_source_names_the_file() {
606 let root = unique_tmp("unreadable");
607 std::fs::create_dir_all(&root).unwrap();
608 std::fs::write(root.join("widget.rs"), [0xFF, 0xFE]).unwrap();
609 let err = find_violations(&root, Language::Rust, 0).unwrap_err();
610 assert!(
611 err.to_string().contains("reading source file"),
612 "got: {err}"
613 );
614 }
615
616 #[test]
617 fn an_unparsable_python_source_names_the_file() {
618 let err = python_functions("def broken(:\n", Path::new("widget.py")).unwrap_err();
619 assert!(err.to_string().contains("parsing"), "got: {err}");
620 }
621
622 #[test]
623 fn an_extension_without_a_source_type_is_an_error() {
624 let err = typescript_functions("", Path::new("widget.txt")).unwrap_err();
625 assert!(
626 err.to_string().contains("reading the source type"),
627 "got: {err}"
628 );
629 }
630
631 #[test]
632 fn an_unnamed_default_export_is_reported_as_default() {
633 let found =
634 typescript("export default function (value: number): number {\n return value;\n}\n");
635 assert_eq!(found, vec![("default".to_string(), 1)]);
636 }
637
638 #[test]
639 fn an_unparsable_rust_source_names_the_file() {
640 let err = rust_functions("fn broken( {\n", Path::new("widget.rs")).unwrap_err();
641 assert!(err.to_string().contains("parsing"), "got: {err}");
642 }
643
644 #[test]
645 fn code_lines_skips_blank_and_comment_lines() {
646 let lines = vec![
647 "let a = 1;",
648 "",
649 "// note",
650 "/* block",
651 " * inner",
652 " */",
653 "a",
654 ];
655 assert_eq!(code_lines(&lines, 1, 7, Comment::Slash), 2);
656 }
657
658 #[test]
659 fn is_comment_keeps_a_rust_dereference_as_code() {
660 assert!(!is_comment("*counter += 1;", Comment::Slash));
661 assert!(is_comment("* inner", Comment::Slash));
662 assert!(is_comment("# note", Comment::Hash));
663 assert!(!is_comment("value = 1", Comment::Hash));
664 }
665
666 #[test]
667 fn line_of_counts_newlines_before_the_offset() {
668 let source = "a\nb\nc";
669 assert_eq!(line_of(source, TextSize::from(0)), 1);
670 assert_eq!(line_of(source, TextSize::from(2)), 2);
671 assert_eq!(line_of(source, TextSize::from(4)), 3);
672 }
673}