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)`. The first over-threshold function in a
34/// file holds it; each later one is a violation naming both.
35pub 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
69/// Every file under `root` the rule judges, sorted: the language's source files, minus the
70/// test and support files and the suite tiers under `<package root>/tests/`.
71fn 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
91/// The module-scope functions `source` (the file at `path`) declares, in source order.
92fn 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
100/// The module-level `def` / `async def` statements of a Python module.
101fn 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
121/// The code lines of a Python function body, the docstring excluded.
122fn 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
132/// `true` for a bare string-literal statement — a docstring wherever it leads a body.
133fn 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
143/// The module-scope functions of a TypeScript module: `function` declarations and
144/// bindings initialized with an arrow or function expression, `export` or not.
145fn 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
185/// Record a TypeScript `function` declaration; a bodyless overload signature is skipped.
186fn 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
207/// Record every `const` / `let` / `var` binding initialized with an arrow or function
208/// expression.
209fn 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
235/// The code lines of a TypeScript function body.
236fn 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
248/// The `fn` items at the top level of a Rust file.
249fn 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
267/// The code lines of a Rust function body — the block's statements, never the braces.
268fn 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/// How a language spells a comment, for the body-line count.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282enum Comment {
283    /// Python: `#`.
284    Hash,
285    /// TypeScript and Rust: `//`, and the `/* … */` block form.
286    Slash,
287}
288
289/// The number of lines in the inclusive 1-based range `first..=last` of `lines` that carry
290/// code — blank and comment lines don't count.
291fn 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
303/// `true` when a trimmed line carries only a comment. The `* ` form catches a `/* … */`
304/// block's interior, which a Rust dereference never matches: no space after the star.
305fn 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
318/// The 1-based line containing byte `offset` in `source`.
319fn 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    /// The `(name, body_lines)` pairs of a Python module's module-scope functions.
333    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    /// The `(name, body_lines)` pairs of a TypeScript module's module-scope functions.
342    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    /// The `(name, body_lines)` pairs of a Rust file's top-level `fn` items.
351    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}