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 match statement {
158 Statement::FunctionDeclaration(node) => {
159 push_ts_function(source, &lines, node, &mut found)
160 }
161 Statement::VariableDeclaration(node) => {
162 push_ts_bindings(source, &lines, node, &mut found)
163 }
164 Statement::ExportNamedDeclaration(node) => match &node.declaration {
165 Some(Declaration::FunctionDeclaration(inner)) => {
166 push_ts_function(source, &lines, inner, &mut found)
167 }
168 Some(Declaration::VariableDeclaration(inner)) => {
169 push_ts_bindings(source, &lines, inner, &mut found)
170 }
171 _ => {}
172 },
173 Statement::ExportDefaultDeclaration(node) => {
174 if let oxc::ast::ast::ExportDefaultDeclarationKind::FunctionDeclaration(inner) =
175 &node.declaration
176 {
177 push_ts_function(source, &lines, inner, &mut found)
178 }
179 }
180 _ => {}
181 }
182 }
183 Ok(found)
184}
185
186fn push_ts_function(
188 source: &str,
189 lines: &[&str],
190 node: &oxc::ast::ast::Function,
191 out: &mut Vec<Function>,
192) {
193 let Some(body) = &node.body else {
194 return;
195 };
196 let name = node
197 .id
198 .as_ref()
199 .map(|id| id.name.to_string())
200 .unwrap_or_else(|| "default".to_string());
201 out.push(Function {
202 name,
203 line: line_of(source, TextSize::from(node.span.start)),
204 body_lines: ts_body_lines(source, lines, body),
205 });
206}
207
208fn push_ts_bindings(
211 source: &str,
212 lines: &[&str],
213 node: &VariableDeclaration,
214 out: &mut Vec<Function>,
215) {
216 for declarator in &node.declarations {
217 let body = match &declarator.init {
218 Some(Expression::ArrowFunctionExpression(arrow)) => Some(arrow.body.as_ref()),
219 Some(Expression::FunctionExpression(function)) => function.body.as_deref(),
220 _ => None,
221 };
222 let Some(body) = body else {
223 continue;
224 };
225 let Some(name) = declarator.id.get_identifier_name() else {
226 continue;
227 };
228 out.push(Function {
229 name: name.to_string(),
230 line: line_of(source, TextSize::from(declarator.span.start)),
231 body_lines: ts_body_lines(source, lines, body),
232 });
233 }
234}
235
236fn ts_body_lines(source: &str, lines: &[&str], body: &oxc::ast::ast::FunctionBody) -> usize {
238 let (Some(first), Some(last)) = (body.statements.first(), body.statements.last()) else {
239 return 0;
240 };
241 code_lines(
242 lines,
243 line_of(source, TextSize::from(first.span().start)),
244 line_of(source, TextSize::from(last.span().end)),
245 Comment::Slash,
246 )
247}
248
249fn rust_functions(source: &str, path: &Path) -> Result<Vec<Function>> {
251 let ast =
252 syn::parse_file(source).map_err(|err| anyhow!("parsing `{}`: {err}", path.display()))?;
253 let lines: Vec<&str> = source.lines().collect();
254 let mut found = Vec::new();
255 for item in &ast.items {
256 let syn::Item::Fn(node) = item else {
257 continue;
258 };
259 found.push(Function {
260 name: node.sig.ident.to_string(),
261 line: node.sig.ident.span().start().line,
262 body_lines: rust_body_lines(&lines, &node.block),
263 });
264 }
265 Ok(found)
266}
267
268fn rust_body_lines(lines: &[&str], block: &syn::Block) -> usize {
270 let (Some(first), Some(last)) = (block.stmts.first(), block.stmts.last()) else {
271 return 0;
272 };
273 code_lines(
274 lines,
275 first.span().start().line,
276 last.span().end().line,
277 Comment::Slash,
278 )
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283enum Comment {
284 Hash,
286 Slash,
288}
289
290fn code_lines(lines: &[&str], first: usize, last: usize, comment: Comment) -> usize {
293 lines
294 .iter()
295 .skip(first.saturating_sub(1))
296 .take(last.saturating_sub(first) + 1)
297 .filter(|line| {
298 let trimmed = line.trim();
299 !trimmed.is_empty() && !is_comment(trimmed, comment)
300 })
301 .count()
302}
303
304fn is_comment(trimmed: &str, comment: Comment) -> bool {
307 match comment {
308 Comment::Hash => trimmed.starts_with('#'),
309 Comment::Slash => {
310 trimmed.starts_with("//")
311 || trimmed.starts_with("/*")
312 || trimmed == "*"
313 || trimmed.starts_with("* ")
314 || trimmed.starts_with("*/")
315 }
316 }
317}
318
319fn line_of(source: &str, offset: TextSize) -> usize {
321 let offset = (u32::from(offset) as usize).min(source.len());
322 source.as_bytes()[..offset]
323 .iter()
324 .filter(|&&byte| byte == b'\n')
325 .count()
326 + 1
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 fn python(source: &str) -> Vec<(String, usize)> {
335 python_functions(source, Path::new("widget.py"))
336 .expect("the snippet parses")
337 .into_iter()
338 .map(|function| (function.name, function.body_lines))
339 .collect()
340 }
341
342 fn typescript(source: &str) -> Vec<(String, usize)> {
344 typescript_functions(source, Path::new("widget.ts"))
345 .expect("the snippet parses")
346 .into_iter()
347 .map(|function| (function.name, function.body_lines))
348 .collect()
349 }
350
351 fn rust(source: &str) -> Vec<(String, usize)> {
353 rust_functions(source, Path::new("widget.rs"))
354 .expect("the snippet parses")
355 .into_iter()
356 .map(|function| (function.name, function.body_lines))
357 .collect()
358 }
359
360 #[test]
361 fn python_counts_module_level_defs_and_their_body_lines() {
362 let found = python(
363 "def alpha(value):\n total = value + 1\n return total\n\n\
364 async def beta(value):\n return value\n",
365 );
366 assert_eq!(
367 found,
368 vec![("alpha".to_string(), 2), ("beta".to_string(), 1)]
369 );
370 }
371
372 #[test]
373 fn python_skips_methods_and_nested_functions() {
374 let found = python(
375 "class Widget:\n def grow(self):\n self.size += 1\n return self.size\n\n\
376 def build(values):\n def inner(value):\n return value * 2\n\n return inner\n",
377 );
378 assert_eq!(found, vec![("build".to_string(), 3)]);
379 }
380
381 #[test]
382 fn python_excludes_the_docstring_blank_lines_and_comments() {
383 let found = python(
384 "def described(value):\n \"\"\"Return the value unchanged.\"\"\"\n\
385 \x20 # the identity is the whole contract\n\n return value\n",
386 );
387 assert_eq!(found, vec![("described".to_string(), 1)]);
388 }
389
390 #[test]
391 fn python_reports_a_decorated_function_by_name() {
392 let found = python("@cache\ndef alpha(value):\n return value\n");
393 assert_eq!(found, vec![("alpha".to_string(), 1)]);
394 }
395
396 #[test]
397 fn python_counts_an_empty_body_as_no_lines() {
398 let found = python("def stub():\n \"\"\"Nothing yet.\"\"\"\n");
399 assert_eq!(found, vec![("stub".to_string(), 0)]);
400 }
401
402 #[test]
403 fn typescript_counts_declarations_and_function_bound_bindings() {
404 let found = typescript(
405 "export function alpha(value: number): number {\n const total = value + 1;\n return total;\n}\n\
406 const beta = (value: number): number => value * 2;\n\
407 export const gamma = function (value: number): number {\n return value;\n};\n",
408 );
409 assert_eq!(
410 found,
411 vec![
412 ("alpha".to_string(), 2),
413 ("beta".to_string(), 1),
414 ("gamma".to_string(), 1),
415 ]
416 );
417 }
418
419 #[test]
420 fn typescript_skips_methods_nested_arrows_and_non_function_bindings() {
421 let found = typescript(
422 "const SIZE = 3;\n\
423 export class Widget {\n grow(amount: number): number {\n return amount;\n }\n}\n\
424 export function build(values: number[]): number[] {\n const inner = (v: number) => v * 2;\n return values.map(inner);\n}\n",
425 );
426 assert_eq!(found, vec![("build".to_string(), 2)]);
427 }
428
429 #[test]
430 fn typescript_counts_an_export_default_function() {
431 let found = typescript(
432 "export default function alpha(value: number): number {\n const total = value + 1;\n return total;\n}\n",
433 );
434 assert_eq!(found, vec![("alpha".to_string(), 2)]);
435 }
436
437 #[test]
438 fn typescript_skips_an_overload_signature() {
439 let found = typescript(
440 "export function alpha(value: number): number;\n\
441 export function alpha(value: string): string;\n\
442 export function alpha(value: unknown): unknown {\n const echoed = value;\n return echoed;\n}\n",
443 );
444 assert_eq!(found, vec![("alpha".to_string(), 2)]);
445 }
446
447 #[test]
448 fn typescript_excludes_comment_lines_from_the_body() {
449 let found = typescript(
450 "export function described(value: number): number {\n // the identity is the whole contract\n\n return value;\n}\n",
451 );
452 assert_eq!(found, vec![("described".to_string(), 1)]);
453 }
454
455 #[test]
456 fn rust_counts_top_level_items_only() {
457 let found = rust(
458 "pub struct Widget;\n\
459 impl Widget {\n pub fn grow(&self) -> u8 {\n 1\n }\n}\n\
460 pub fn build(values: &[u8]) -> u8 {\n fn inner(v: u8) -> u8 {\n v * 2\n }\n inner(values[0])\n}\n",
461 );
462 assert_eq!(found, vec![("build".to_string(), 4)]);
463 }
464
465 #[test]
466 fn rust_skips_functions_in_an_inline_test_module() {
467 let found = rust(
468 "pub fn ratio(a: u8, b: u8) -> u8 {\n (a + b) / 2\n}\n\
469 #[cfg(test)]\nmod tests {\n #[test]\n fn halves() {\n let x = 1;\n assert_eq!(x, 1);\n }\n}\n",
470 );
471 assert_eq!(found, vec![("ratio".to_string(), 1)]);
472 }
473
474 #[test]
475 fn rust_excludes_doc_comments_and_body_comments() {
476 let found = rust(
477 "/// Return the value unchanged.\npub fn described(value: u8) -> u8 {\n // the identity is the whole contract\n\n value\n}\n",
478 );
479 assert_eq!(found, vec![("described".to_string(), 1)]);
480 }
481
482 #[test]
483 fn rust_counts_an_empty_body_as_no_lines() {
484 let found = rust("pub fn stub() {}\n");
485 assert_eq!(found, vec![("stub".to_string(), 0)]);
486 }
487
488 #[test]
489 fn typescript_counts_a_plain_function_declaration() {
490 let found = typescript(
491 "function alpha(value: number): number {\n const total = value + 1;\n return total;\n}\n",
492 );
493 assert_eq!(found, vec![("alpha".to_string(), 2)]);
494 }
495
496 #[test]
497 fn typescript_skips_non_function_defaults_and_imports() {
498 let found = typescript(
499 "import { x } from './x';\nexport default class Widget {}\n\
500 const beta = (value: number): number => value;\n",
501 );
502 assert_eq!(found, vec![("beta".to_string(), 1)]);
503 }
504
505 #[test]
506 fn typescript_skips_a_destructured_function_binding() {
507 let found = typescript("const { a } = () => {};\n");
508 assert!(found.is_empty(), "got: {found:?}");
509 }
510
511 #[test]
512 fn typescript_counts_an_empty_arrow_body_as_no_lines() {
513 let found = typescript("const stub = () => {};\n");
514 assert_eq!(found, vec![("stub".to_string(), 0)]);
515 }
516
517 #[test]
518 fn typescript_parse_error_is_reported() {
519 let err = typescript_functions("const x = ;\n", Path::new("bad.ts")).unwrap_err();
520 assert!(err.to_string().contains("parsing"), "got: {err}");
521 }
522
523 fn unique_tmp(slug: &str) -> PathBuf {
524 use std::sync::atomic::{AtomicU64, Ordering};
525 static COUNTER: AtomicU64 = AtomicU64::new(0);
526 std::env::temp_dir().join(format!(
527 "tc-one-function-{slug}-{}-{}",
528 std::process::id(),
529 COUNTER.fetch_add(1, Ordering::Relaxed)
530 ))
531 }
532
533 #[test]
534 fn suite_tier_files_are_not_judged() {
535 let root = unique_tmp("suite");
536 std::fs::create_dir_all(root.join("tests")).unwrap();
537 std::fs::write(root.join("pyproject.toml"), "[project]\nname = \"w\"\n").unwrap();
538 let two_functions = "def alpha():\n return 1\n\ndef beta():\n return 2\n";
539 std::fs::write(root.join("widget.py"), two_functions).unwrap();
540 std::fs::write(root.join("tests").join("helper.py"), two_functions).unwrap();
541 let (found, _) = find_violations(&root, Language::Python, 0).expect("the tree scans");
542 assert_eq!(found.len(), 1, "got: {found:?}");
543 assert!(found[0].file.ends_with("widget.py"), "got: {found:?}");
544 }
545
546 #[test]
547 fn a_root_without_a_manifest_judges_every_source_file() {
548 let root = unique_tmp("no-manifest");
549 std::fs::create_dir_all(&root).unwrap();
550 let two_functions = "def alpha():\n return 1\n\ndef beta():\n return 2\n";
551 std::fs::write(root.join("widget.py"), two_functions).unwrap();
552 let (found, _) = find_violations(&root, Language::Python, 0).expect("the tree scans");
553 assert_eq!(found.len(), 1, "got: {found:?}");
554 }
555
556 #[test]
557 fn a_typescript_suite_tier_is_not_judged() {
558 let root = unique_tmp("ts-suite");
559 std::fs::create_dir_all(root.join("tests")).unwrap();
560 std::fs::write(root.join("package.json"), "{ \"name\": \"w\" }\n").unwrap();
561 let two = "const alpha = () => {\n return 1;\n};\nconst beta = () => {\n return 2;\n};\n";
562 std::fs::write(root.join("widget.ts"), two).unwrap();
563 std::fs::write(root.join("tests").join("helper.ts"), two).unwrap();
564 let (found, _) = find_violations(&root, Language::TypeScript, 0).expect("the tree scans");
565 assert_eq!(found.len(), 1, "got: {found:?}");
566 assert!(found[0].file.ends_with("widget.ts"), "got: {found:?}");
567 }
568
569 #[test]
570 fn a_rust_root_collects_only_rust_sources() {
571 let root = unique_tmp("rust-root");
572 std::fs::create_dir_all(&root).unwrap();
573 std::fs::write(root.join("widget.rs"), "pub fn one() -> u8 {\n 1\n}\n").unwrap();
574 let (found, _) = find_violations(&root, Language::Rust, 0).expect("the tree scans");
575 assert!(found.is_empty(), "got: {found:?}");
576 }
577
578 #[test]
579 fn an_unreadable_source_names_the_file() {
580 let root = unique_tmp("unreadable");
581 std::fs::create_dir_all(&root).unwrap();
582 std::fs::write(root.join("widget.rs"), [0xFF, 0xFE]).unwrap();
583 let err = find_violations(&root, Language::Rust, 0).unwrap_err();
584 assert!(
585 err.to_string().contains("reading source file"),
586 "got: {err}"
587 );
588 }
589
590 #[test]
591 fn an_unparsable_python_source_names_the_file() {
592 let err = python_functions("def broken(:\n", Path::new("widget.py")).unwrap_err();
593 assert!(err.to_string().contains("parsing"), "got: {err}");
594 }
595
596 #[test]
597 fn an_extension_without_a_source_type_is_an_error() {
598 let err = typescript_functions("", Path::new("widget.txt")).unwrap_err();
599 assert!(
600 err.to_string().contains("reading the source type"),
601 "got: {err}"
602 );
603 }
604
605 #[test]
606 fn an_unnamed_default_export_is_reported_as_default() {
607 let found =
608 typescript("export default function (value: number): number {\n return value;\n}\n");
609 assert_eq!(found, vec![("default".to_string(), 1)]);
610 }
611
612 #[test]
613 fn an_unparsable_rust_source_names_the_file() {
614 let err = rust_functions("fn broken( {\n", Path::new("widget.rs")).unwrap_err();
615 assert!(err.to_string().contains("parsing"), "got: {err}");
616 }
617
618 #[test]
619 fn code_lines_skips_blank_and_comment_lines() {
620 let lines = vec![
621 "let a = 1;",
622 "",
623 "// note",
624 "/* block",
625 " * inner",
626 " */",
627 "a",
628 ];
629 assert_eq!(code_lines(&lines, 1, 7, Comment::Slash), 2);
630 }
631
632 #[test]
633 fn is_comment_keeps_a_rust_dereference_as_code() {
634 assert!(!is_comment("*counter += 1;", Comment::Slash));
635 assert!(is_comment("* inner", Comment::Slash));
636 assert!(is_comment("# note", Comment::Hash));
637 assert!(!is_comment("value = 1", Comment::Hash));
638 }
639
640 #[test]
641 fn line_of_counts_newlines_before_the_offset() {
642 let source = "a\nb\nc";
643 assert_eq!(line_of(source, TextSize::from(0)), 1);
644 assert_eq!(line_of(source, TextSize::from(2)), 2);
645 assert_eq!(line_of(source, TextSize::from(4)), 3);
646 }
647}