1use std::path::{Path, PathBuf};
18
19use anyhow::{anyhow, Context, Result};
20use oxc::allocator::Allocator;
21use oxc::ast::ast::{Declaration, Expression, Statement, VariableDeclaration};
22use oxc::parser::Parser;
23use oxc::span::{GetSpan, SourceType};
24use rustpython_ast::Ranged;
25use rustpython_parser::ast::{self, Constant, Expr, Stmt};
26use rustpython_parser::text_size::TextSize;
27use rustpython_parser::Parse;
28use syn::spanned::Spanned;
29
30pub use crate::violation::Violation;
31
32use crate::colocated_test::Language;
33
34const RULE: &str = "one-function-per-file";
37
38#[derive(Debug, PartialEq, Eq)]
41struct Function {
42 name: String,
43 line: usize,
44 body_lines: usize,
45}
46
47pub fn find_violations(
54 root: impl AsRef<Path>,
55 language: Language,
56 max_lines: u32,
57) -> Result<Vec<Violation>> {
58 let root = root.as_ref();
59 let files = source_files(root, language)?;
60
61 let mut violations = Vec::new();
62 for file in &files {
63 let source = std::fs::read_to_string(file)
64 .with_context(|| format!("reading source file `{}`", file.display()))?;
65 let mut over = functions(&source, file, language)?
66 .into_iter()
67 .filter(|function| function.body_lines > max_lines as usize);
68 let Some(holder) = over.next() else {
69 continue;
70 };
71 for extra in over {
72 violations.push(Violation {
73 file: file.clone(),
74 line: extra.line,
75 rule: RULE,
76 message: format!(
77 "`{}` runs {} lines, and `{}` already holds this file; \
78 move it to its own module",
79 extra.name, extra.body_lines, holder.name
80 ),
81 });
82 }
83 }
84 Ok(violations)
85}
86
87fn source_files(root: &Path, language: Language) -> Result<Vec<PathBuf>> {
91 let mut files = Vec::new();
92 if language == Language::Rust {
93 crate::colocated_test::collect_rust_source_files(root, &mut files)?;
94 files.sort();
95 return Ok(files);
96 }
97 crate::colocated_test::collect_files(root, language, &mut files)?;
98 let manifest = match language {
99 Language::Python => "pyproject.toml",
100 _ => "package.json",
101 };
102 if let Some(tests) = crate::tiers::suite_tests_dir(root, manifest) {
103 files.retain(|file| !file.starts_with(&tests));
104 }
105 files.retain(|file| !language.is_test(file) && !language.is_support(file));
106 files.sort();
107 Ok(files)
108}
109
110fn functions(source: &str, path: &Path, language: Language) -> Result<Vec<Function>> {
112 match language {
113 Language::Python => python_functions(source, path),
114 Language::TypeScript => typescript_functions(source, path),
115 Language::Rust => rust_functions(source, path),
116 }
117}
118
119fn python_functions(source: &str, path: &Path) -> Result<Vec<Function>> {
121 let suite = ast::Suite::parse(source, &path.to_string_lossy())
122 .map_err(|err| anyhow!("parsing `{}`: {err}", path.display()))?;
123 let lines: Vec<&str> = source.lines().collect();
124 let mut found = Vec::new();
125 for statement in &suite {
126 let (name, body, range) = match statement {
127 Stmt::FunctionDef(node) => (&node.name, &node.body, node.range),
128 Stmt::AsyncFunctionDef(node) => (&node.name, &node.body, node.range),
129 _ => continue,
130 };
131 found.push(Function {
132 name: name.to_string(),
133 line: line_of(source, range.start()),
134 body_lines: python_body_lines(source, &lines, body),
135 });
136 }
137 Ok(found)
138}
139
140fn python_body_lines(source: &str, lines: &[&str], body: &[Stmt]) -> usize {
143 let start = body.iter().position(|statement| !is_docstring(statement));
144 let Some(start) = start else {
145 return 0;
146 };
147 let first = line_of(source, body[start].range().start());
148 let last = line_of(source, body[body.len() - 1].range().end());
149 code_lines(lines, first, last, Comment::Hash)
150}
151
152fn is_docstring(statement: &Stmt) -> bool {
154 let Stmt::Expr(node) = statement else {
155 return false;
156 };
157 matches!(
158 node.value.as_ref(),
159 Expr::Constant(constant) if matches!(constant.value, Constant::Str(_))
160 )
161}
162
163fn typescript_functions(source: &str, path: &Path) -> Result<Vec<Function>> {
166 let allocator = Allocator::default();
167 let source_type = SourceType::from_path(path)
168 .map_err(|err| anyhow!("reading the source type of `{}`: {err}", path.display()))?;
169 let parsed = Parser::new(&allocator, source, source_type).parse();
170 if parsed.panicked || !parsed.diagnostics.is_empty() {
171 return Err(anyhow!("parsing `{}`", path.display()));
172 }
173 let lines: Vec<&str> = source.lines().collect();
174 let mut found = Vec::new();
175 for statement in &parsed.program.body {
176 match statement {
177 Statement::FunctionDeclaration(node) => {
178 push_ts_function(source, &lines, node, &mut found)
179 }
180 Statement::VariableDeclaration(node) => {
181 push_ts_bindings(source, &lines, node, &mut found)
182 }
183 Statement::ExportNamedDeclaration(node) => match &node.declaration {
184 Some(Declaration::FunctionDeclaration(inner)) => {
185 push_ts_function(source, &lines, inner, &mut found)
186 }
187 Some(Declaration::VariableDeclaration(inner)) => {
188 push_ts_bindings(source, &lines, inner, &mut found)
189 }
190 _ => {}
191 },
192 Statement::ExportDefaultDeclaration(node) => {
193 if let oxc::ast::ast::ExportDefaultDeclarationKind::FunctionDeclaration(inner) =
194 &node.declaration
195 {
196 push_ts_function(source, &lines, inner, &mut found)
197 }
198 }
199 _ => {}
200 }
201 }
202 Ok(found)
203}
204
205fn push_ts_function(
208 source: &str,
209 lines: &[&str],
210 node: &oxc::ast::ast::Function,
211 out: &mut Vec<Function>,
212) {
213 let Some(body) = &node.body else {
214 return;
215 };
216 let name = node
217 .id
218 .as_ref()
219 .map(|id| id.name.to_string())
220 .unwrap_or_else(|| "default".to_string());
221 out.push(Function {
222 name,
223 line: line_of(source, TextSize::from(node.span.start)),
224 body_lines: ts_body_lines(source, lines, body),
225 });
226}
227
228fn push_ts_bindings(
231 source: &str,
232 lines: &[&str],
233 node: &VariableDeclaration,
234 out: &mut Vec<Function>,
235) {
236 for declarator in &node.declarations {
237 let body = match &declarator.init {
238 Some(Expression::ArrowFunctionExpression(arrow)) => &arrow.body,
239 Some(Expression::FunctionExpression(function)) => match &function.body {
240 Some(body) => body,
241 None => continue,
242 },
243 _ => continue,
244 };
245 let Some(name) = declarator.id.get_identifier_name() else {
246 continue;
247 };
248 out.push(Function {
249 name: name.to_string(),
250 line: line_of(source, TextSize::from(declarator.span.start)),
251 body_lines: ts_body_lines(source, lines, body),
252 });
253 }
254}
255
256fn ts_body_lines(source: &str, lines: &[&str], body: &oxc::ast::ast::FunctionBody) -> usize {
259 let (Some(first), Some(last)) = (body.statements.first(), body.statements.last()) else {
260 return 0;
261 };
262 code_lines(
263 lines,
264 line_of(source, TextSize::from(first.span().start)),
265 line_of(source, TextSize::from(last.span().end)),
266 Comment::Slash,
267 )
268}
269
270fn rust_functions(source: &str, path: &Path) -> Result<Vec<Function>> {
272 let ast =
273 syn::parse_file(source).map_err(|err| anyhow!("parsing `{}`: {err}", path.display()))?;
274 let lines: Vec<&str> = source.lines().collect();
275 let mut found = Vec::new();
276 for item in &ast.items {
277 let syn::Item::Fn(node) = item else {
278 continue;
279 };
280 found.push(Function {
281 name: node.sig.ident.to_string(),
282 line: node.sig.ident.span().start().line,
283 body_lines: rust_body_lines(&lines, &node.block),
284 });
285 }
286 Ok(found)
287}
288
289fn rust_body_lines(lines: &[&str], block: &syn::Block) -> usize {
292 let (Some(first), Some(last)) = (block.stmts.first(), block.stmts.last()) else {
293 return 0;
294 };
295 code_lines(
296 lines,
297 first.span().start().line,
298 last.span().end().line,
299 Comment::Slash,
300 )
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305enum Comment {
306 Hash,
308 Slash,
310}
311
312fn code_lines(lines: &[&str], first: usize, last: usize, comment: Comment) -> usize {
316 lines
317 .iter()
318 .skip(first.saturating_sub(1))
319 .take(last.saturating_sub(first) + 1)
320 .filter(|line| {
321 let trimmed = line.trim();
322 !trimmed.is_empty() && !is_comment(trimmed, comment)
323 })
324 .count()
325}
326
327fn is_comment(trimmed: &str, comment: Comment) -> bool {
331 match comment {
332 Comment::Hash => trimmed.starts_with('#'),
333 Comment::Slash => {
334 trimmed.starts_with("//")
335 || trimmed.starts_with("/*")
336 || trimmed == "*"
337 || trimmed.starts_with("* ")
338 || trimmed.starts_with("*/")
339 }
340 }
341}
342
343fn line_of(source: &str, offset: TextSize) -> usize {
345 let offset = (u32::from(offset) as usize).min(source.len());
346 source.as_bytes()[..offset]
347 .iter()
348 .filter(|&&byte| byte == b'\n')
349 .count()
350 + 1
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 fn python(source: &str) -> Vec<(String, usize)> {
359 python_functions(source, Path::new("widget.py"))
360 .expect("the snippet parses")
361 .into_iter()
362 .map(|function| (function.name, function.body_lines))
363 .collect()
364 }
365
366 fn typescript(source: &str) -> Vec<(String, usize)> {
368 typescript_functions(source, Path::new("widget.ts"))
369 .expect("the snippet parses")
370 .into_iter()
371 .map(|function| (function.name, function.body_lines))
372 .collect()
373 }
374
375 fn rust(source: &str) -> Vec<(String, usize)> {
377 rust_functions(source, Path::new("widget.rs"))
378 .expect("the snippet parses")
379 .into_iter()
380 .map(|function| (function.name, function.body_lines))
381 .collect()
382 }
383
384 #[test]
385 fn python_counts_module_level_defs_and_their_body_lines() {
386 let found = python(
387 "def alpha(value):\n total = value + 1\n return total\n\n\
388 async def beta(value):\n return value\n",
389 );
390 assert_eq!(
391 found,
392 vec![("alpha".to_string(), 2), ("beta".to_string(), 1)]
393 );
394 }
395
396 #[test]
397 fn python_skips_methods_and_nested_functions() {
398 let found = python(
399 "class Widget:\n def grow(self):\n self.size += 1\n return self.size\n\n\
400 def build(values):\n def inner(value):\n return value * 2\n\n return inner\n",
401 );
402 assert_eq!(found, vec![("build".to_string(), 3)]);
403 }
404
405 #[test]
406 fn python_excludes_the_docstring_blank_lines_and_comments() {
407 let found = python(
408 "def described(value):\n \"\"\"Return the value unchanged.\"\"\"\n\
409 \x20 # the identity is the whole contract\n\n return value\n",
410 );
411 assert_eq!(found, vec![("described".to_string(), 1)]);
412 }
413
414 #[test]
415 fn python_reports_a_decorated_function_by_name() {
416 let found = python("@cache\ndef alpha(value):\n return value\n");
417 assert_eq!(found, vec![("alpha".to_string(), 1)]);
418 }
419
420 #[test]
421 fn python_counts_an_empty_body_as_no_lines() {
422 let found = python("def stub():\n \"\"\"Nothing yet.\"\"\"\n");
423 assert_eq!(found, vec![("stub".to_string(), 0)]);
424 }
425
426 #[test]
427 fn typescript_counts_declarations_and_function_bound_bindings() {
428 let found = typescript(
429 "export function alpha(value: number): number {\n const total = value + 1;\n return total;\n}\n\
430 const beta = (value: number): number => value * 2;\n\
431 export const gamma = function (value: number): number {\n return value;\n};\n",
432 );
433 assert_eq!(
434 found,
435 vec![
436 ("alpha".to_string(), 2),
437 ("beta".to_string(), 1),
438 ("gamma".to_string(), 1),
439 ]
440 );
441 }
442
443 #[test]
444 fn typescript_skips_methods_nested_arrows_and_non_function_bindings() {
445 let found = typescript(
446 "const SIZE = 3;\n\
447 export class Widget {\n grow(amount: number): number {\n return amount;\n }\n}\n\
448 export function build(values: number[]): number[] {\n const inner = (v: number) => v * 2;\n return values.map(inner);\n}\n",
449 );
450 assert_eq!(found, vec![("build".to_string(), 2)]);
451 }
452
453 #[test]
454 fn typescript_counts_an_export_default_function() {
455 let found = typescript(
456 "export default function alpha(value: number): number {\n const total = value + 1;\n return total;\n}\n",
457 );
458 assert_eq!(found, vec![("alpha".to_string(), 2)]);
459 }
460
461 #[test]
462 fn typescript_skips_an_overload_signature() {
463 let found = typescript(
464 "export function alpha(value: number): number;\n\
465 export function alpha(value: string): string;\n\
466 export function alpha(value: unknown): unknown {\n const echoed = value;\n return echoed;\n}\n",
467 );
468 assert_eq!(found, vec![("alpha".to_string(), 2)]);
469 }
470
471 #[test]
472 fn typescript_excludes_comment_lines_from_the_body() {
473 let found = typescript(
474 "export function described(value: number): number {\n // the identity is the whole contract\n\n return value;\n}\n",
475 );
476 assert_eq!(found, vec![("described".to_string(), 1)]);
477 }
478
479 #[test]
480 fn rust_counts_top_level_items_only() {
481 let found = rust(
482 "pub struct Widget;\n\
483 impl Widget {\n pub fn grow(&self) -> u8 {\n 1\n }\n}\n\
484 pub fn build(values: &[u8]) -> u8 {\n fn inner(v: u8) -> u8 {\n v * 2\n }\n inner(values[0])\n}\n",
485 );
486 assert_eq!(found, vec![("build".to_string(), 4)]);
487 }
488
489 #[test]
490 fn rust_skips_functions_in_an_inline_test_module() {
491 let found = rust(
492 "pub fn ratio(a: u8, b: u8) -> u8 {\n (a + b) / 2\n}\n\
493 #[cfg(test)]\nmod tests {\n #[test]\n fn halves() {\n let x = 1;\n assert_eq!(x, 1);\n }\n}\n",
494 );
495 assert_eq!(found, vec![("ratio".to_string(), 1)]);
496 }
497
498 #[test]
499 fn rust_excludes_doc_comments_and_body_comments() {
500 let found = rust(
501 "/// Return the value unchanged.\npub fn described(value: u8) -> u8 {\n // the identity is the whole contract\n\n value\n}\n",
502 );
503 assert_eq!(found, vec![("described".to_string(), 1)]);
504 }
505
506 #[test]
507 fn rust_counts_an_empty_body_as_no_lines() {
508 let found = rust("pub fn stub() {}\n");
509 assert_eq!(found, vec![("stub".to_string(), 0)]);
510 }
511
512 #[test]
513 fn code_lines_skips_blank_and_comment_lines() {
514 let lines = vec![
515 "let a = 1;",
516 "",
517 "// note",
518 "/* block",
519 " * inner",
520 " */",
521 "a",
522 ];
523 assert_eq!(code_lines(&lines, 1, 7, Comment::Slash), 2);
524 }
525
526 #[test]
527 fn is_comment_keeps_a_rust_dereference_as_code() {
528 assert!(!is_comment("*counter += 1;", Comment::Slash));
529 assert!(is_comment("* inner", Comment::Slash));
530 assert!(is_comment("# note", Comment::Hash));
531 assert!(!is_comment("value = 1", Comment::Hash));
532 }
533
534 #[test]
535 fn line_of_counts_newlines_before_the_offset() {
536 let source = "a\nb\nc";
537 assert_eq!(line_of(source, TextSize::from(0)), 1);
538 assert_eq!(line_of(source, TextSize::from(2)), 2);
539 assert_eq!(line_of(source, TextSize::from(4)), 3);
540 }
541}