Skip to main content

testing_conventions/
one_function.rs

1//! The `unit one-function-per-file` check: a source file holds at most one module-scope
2//! function whose body runs longer than the configured threshold.
3
4use 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
21/// The rule id reported for a function sharing its file with another over the threshold.
22const RULE: &str = "one-function-per-file";
23
24/// A module-scope function: its name, declaration line, and body code-line count.
25#[derive(Debug, PartialEq, Eq)]
26struct Function {
27    name: String,
28    line: usize,
29    body_lines: usize,
30}
31
32/// A violation for every module-scope function under `root` past the first whose body runs
33/// longer than `max_lines`, sorted by `(file, line)`, and the number of files scanned to find
34/// them. The first over-threshold function in a file holds it; each later one is a violation
35/// naming both.
36pub 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
70/// Every file under `root` the rule judges, sorted: the language's source files, minus the
71/// test and support files and the suite tiers under `<package root>/tests/`.
72fn 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
92/// The module-scope functions `source` (the file at `path`) declares, in source order.
93fn 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
101/// The module-level `def` / `async def` statements of a Python module.
102fn 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
122/// The code lines of a Python function body, the docstring excluded.
123fn 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
133/// `true` for a bare string-literal statement — a docstring wherever it leads a body.
134fn 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
144/// The module-scope functions of a TypeScript module: `function` declarations and
145/// bindings initialized with an arrow or function expression, `export` or not.
146fn 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
186/// Record a TypeScript `function` declaration; a bodyless overload signature is skipped.
187fn 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
208/// Record every `const` / `let` / `var` binding initialized with an arrow or function
209/// expression.
210fn 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
236/// The code lines of a TypeScript function body.
237fn 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
249/// The `fn` items at the top level of a Rust file.
250fn 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
268/// The code lines of a Rust function body — the block's statements, never the braces.
269fn 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/// How a language spells a comment, for the body-line count.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283enum Comment {
284    /// Python: `#`.
285    Hash,
286    /// TypeScript and Rust: `//`, and the `/* … */` block form.
287    Slash,
288}
289
290/// The number of lines in the inclusive 1-based range `first..=last` of `lines` that carry
291/// code — blank and comment lines don't count.
292fn 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
304/// `true` when a trimmed line carries only a comment. The `* ` form catches a `/* … */`
305/// block's interior, which a Rust dereference never matches: no space after the star.
306fn 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
319/// The 1-based line containing byte `offset` in `source`.
320fn 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    /// The `(name, body_lines)` pairs of a Python module's module-scope functions.
334    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    /// The `(name, body_lines)` pairs of a TypeScript module's module-scope functions.
343    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    /// The `(name, body_lines)` pairs of a Rust file's top-level `fn` items.
352    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}