Skip to main content

testing_conventions/
one_function.rs

1//! The `unit one-function-per-file` check.
2//!
3//! A source file holds at most **one** module-scope function whose body runs
4//! longer than the configured threshold. Functions at or under the threshold are
5//! trivial — an expression with a name — and share a file freely.
6//!
7//! Detection is AST-based, one arm per language, each reading the same shape:
8//! - **Python** — `def` / `async def` at module level (`rustpython_parser`).
9//! - **TypeScript** — top-level `function` declarations and a top-level `const` /
10//!   `let` / `var` bound to an arrow or function expression, `export` or not (`oxc`).
11//! - **Rust** — `fn` items at the top level of the file (`syn`).
12//!
13//! Methods, nested functions, and callbacks belong to their owner and are never
14//! counted on their own; the walk reads the same source tree the colocated-test
15//! presence rule does, so no test file is a subject.
16
17use 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
34/// The rule id reported for a function that shares its file with another over the
35/// threshold.
36const RULE: &str = "one-function-per-file";
37
38/// A module-scope function: its name, the line it is declared on, and the number
39/// of code lines in its body.
40#[derive(Debug, PartialEq, Eq)]
41struct Function {
42    name: String,
43    line: usize,
44    body_lines: usize,
45}
46
47/// Scan the source files under `root` for `language` and return a violation for
48/// every module-scope function past the first whose body runs longer than
49/// `max_lines`, sorted by `(file, line)`.
50///
51/// The first over-threshold function in a file holds it; each later one is a
52/// violation naming both. A file that cannot be read or parsed is an error.
53pub 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
87/// Every file under `root` the rule judges: the language's source files, minus
88/// the test and support files and the suite tiers under `<package root>/tests/`.
89/// Sorted, so the report is deterministic.
90fn 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
110/// The module-scope functions `source` (the file at `path`) declares, in source order.
111fn 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
119/// The module-level `def` / `async def` statements of a Python module.
120fn 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
140/// The code lines of a Python function body — the docstring excluded, so a
141/// documented one-statement function stays trivial.
142fn 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
152/// `true` for a bare string-literal statement — a docstring wherever it leads a body.
153fn 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
163/// The module-scope functions of a TypeScript module: `function` declarations and
164/// bindings initialized with an arrow or function expression, `export` or not.
165fn 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
205/// Record a TypeScript `function` declaration. An overload signature carries no
206/// body and declares nothing to move, so it is skipped.
207fn 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
228/// Record every binding in a `const` / `let` / `var` declaration whose initializer
229/// is an arrow or function expression — TypeScript's other module-scope function form.
230fn 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
256/// The code lines of a TypeScript function body. An arrow's expression body is a
257/// body of one statement, so both forms measure the same way.
258fn 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
270/// The `fn` items at the top level of a Rust file.
271fn 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
289/// The code lines of a Rust function body — the block's statements, so the braces
290/// on their own lines are not the body.
291fn 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/// How a language spells a comment, for the body-line count.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305enum Comment {
306    /// Python: `#`.
307    Hash,
308    /// TypeScript and Rust: `//`, and the `/* … */` block form.
309    Slash,
310}
311
312/// The number of lines in the inclusive 1-based range `first..=last` of `lines`
313/// that carry code — blank lines and comment lines don't count, so documentation
314/// never pushes a function over the threshold.
315fn 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
327/// `true` when a trimmed line carries only a comment. The `*` forms catch the
328/// interior and closing lines of a `/* … */` block, which a `*`-prefixed Rust
329/// dereference never matches: that spelling has no space after the star.
330fn 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
343/// The 1-based line containing byte `offset` in `source`.
344fn 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    /// The `(name, body_lines)` pairs of a Python module's module-scope functions.
358    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    /// The `(name, body_lines)` pairs of a TypeScript module's module-scope functions.
367    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    /// The `(name, body_lines)` pairs of a Rust file's top-level `fn` items.
376    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}