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