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