Skip to content

New lint: definition_in_module_root#16965

Open
corygabrielsen wants to merge 1 commit into
rust-lang:masterfrom
corygabrielsen:spike/definition-in-module-root
Open

New lint: definition_in_module_root#16965
corygabrielsen wants to merge 1 commit into
rust-lang:masterfrom
corygabrielsen:spike/definition-in-module-root

Conversation

@corygabrielsen
Copy link
Copy Markdown

@corygabrielsen corygabrielsen commented May 5, 2026

What this lint does

definition_in_module_root is a restriction lint that warns when a mod.rs
file contains a definition (struct, enum, fn, impl, trait, type alias, etc.).
Items that introduce module structure — mod, use, extern crate,
#[macro_export] macros — are unaffected. lib.rs and main.rs are not
checked.

The motivation is to keep mod.rs files acting as a table of contents and to
push every definition into a named file, so filenames stay descriptive and
unique across a project. This is the declaration / definition distinction
applied to Rust's module system: declarations introduce names, definitions
bind names to meanings, and mod.rs is naturally suited to the former.

How this fits with existing module-style lints

Clippy already has two lints that pick a side on mod.rs versus self-named
files:

  • mod_module_files (the "no mod.rs" school) forbids mod.rs files,
    requiring foo.rs next to a foo/ directory of submodules.
  • self_named_module_files (the "always mod.rs" school) is the
    opposite: it requires foo/mod.rs and forbids the foo.rs-next-to-foo/
    layout.

definition_in_module_root doesn't pick a side; it complements
self_named_module_files by adding a constraint on the contents of
mod.rs. Under mod_module_files no mod.rs files exist, so this lint has
nothing to fire on (vacuously satisfied).

The interaction with module_inception is scoped: it fires only when a
definition's name happens to match its parent module's name, in which case
moving the definition produces foo/foo.rs, which module_inception
flags. For most definitions (where the name doesn't equal the parent
module's), the layout is parent/named_file.rs and module_inception
doesn't fire. Within that scoped class of types, the two lints are
formally unsatisfiable together — the next section is the proof.

Canonical layouts

Mathematical motivation.

This lint's value is more than stylistic. Combined with the convention of
naming each file after its primary definition, definition_in_module_root
collapses the remaining degrees of freedom in Rust's module-to-file mapping
so that the file layout is uniquely determined by the module tree.

The freedom problem

Given N symbols to organize into files, most partitions retain
combinatorial ambiguity: (N choose K) > 1 for 1 < K < N. Of all groupings,
only two are unambiguous: all symbols in one file, or one file per symbol.
The rest involve arbitrary choices about which symbols share a file with
which. The one-per-symbol extreme is the only scalable choice that
preserves canonicality.

Even under one-per-symbol, a residual freedom remains: a definition can
live in its parent's mod.rs or in a named sibling file. This lint
removes that last freedom — every definition goes in a named file. Once
both pins are in place, the filesystem tree is a direct projection of
the module tree: different authors converge on structurally identical
code, merges flatten, and automated refactoring becomes tractable.

Formal interaction with module_inception

The interaction noted in the previous section is the formal expression of
this property. Consider:

C1 (this lint):        mod.rs files hold only declarations.
C2 (module_inception): no module foo contains a child module foo.

For any type whose name matches its parent module's name, C1 ∧ C2 is
unsatisfiable:

  1. C1 forbids that type in foo/mod.rs.
  2. The one-name-per-file convention places it in a file named after the
    type — here, foo.rs.
  3. As a sibling of foo/, that file is foo/foo.rs.
  4. C2 forbids foo/foo.rs.

From ¬(C1 ∧ C2) it follows that any project enforcing C1 must accept ¬C2
for the relevant class — i.e., allow(module_inception) for types whose
name matches their parent module's name. The choice between dropping C1
or C2 is not symmetric: C1 is strictly stronger than C2, since
module_inception's premise (that definitions in mod.rs are
acceptable) is exactly what C1 denies.

Therefore the canonical-layout property requires this lint. C1 is
the load-bearing constraint; without enforcement of "declarations-only
in mod.rs," the whole property collapses to convention. The PR
provides C1; allow(module_inception) for the matching-name class is
the cost of that choice; canonical layouts are the payoff.

Portability and the codegen gap

The structural role of mod.rs is shared across module systems —
Python's __init__.py, TypeScript's index.ts, and Rust's mod.rs all
serve as the parent-as-table-of-contents file. The canonicality property
described above is therefore portable: enforce "declarations only" in
those slots in any language, and the layout becomes uniquely determined.

This matters for code-generation pipelines that target multiple languages
— schema- or IDL-driven generators that emit corresponding implementations
from one source of truth. For canonical layouts to hold across the
generated code, each target language needs its own enforcement of the
rule. Until this PR, Rust's slot was empty. Landing this lint fills it.

Why restriction

This is a stylistic constraint that some teams want and others don't — the
classic restriction shape. Same tier as absolute_paths, allow_attributes,
and self_named_module_files. Off by default; users opt in.

Implementation

EarlyLintPass. A Vec<bool> module stack tracks, per nesting level, whether
items live in a mod.rs. check_item consults the top of the stack;
check_item_post pops. Filenames are resolved through the source map
(lookup_source_file), so #[path] is honoured by the real filename rather
than the declaration syntax.

Exemptions:

  • #[macro_export] macros (Rust hoists these to crate root regardless of
    enclosing file)
  • Inline modules (#[cfg(test)] mod tests { ... }) — children push false
    onto the stack
  • Items produced by macro expansion (item.span.from_expansion())

Tests

Six ui-cargo test directories under tests/ui-cargo/definition_in_module_root/:

Directory Covers
fail_mod Seven item kinds in mod.rs; #[macro_export] exemption
fail_path_attr #[path = "custom/mod.rs"] — flagged via real filename
fail_edge_cases #[derive] (only the user-written struct fires, not derived impls), generic impl<T>, async fn, const fn, non-exported macro_rules!, trait with default body, sibling separate-file mod sub; (definitions in sub.rs don't fire), inline #[cfg(test)] mod tests, pub use, glob re-exports, extern crate, static
pass_lib_with_definitions Definitions in lib.rs are not flagged
pass_bin Definitions in main.rs are not flagged
pass_path_attr #[path = "custom/impl.rs"] — not flagged because the real filename isn't mod.rs

Lintcheck

This is a restriction lint, so it's off by default. When opted into, it is
expected to produce many warnings on existing crates that organize code in
mod.rs — that is the lint's purpose. Flagging here so the lintcheck volume
isn't read as a false-positive signal.

Verification

  • cargo build --release clean (0 warnings)
  • TESTNAME=definition_in_module_root cargo uitest — all 6 directories pass
  • cargo dev fmt --check clean
  • cargo dev update_lints --check clean
  • cargo dev bless produces no diff

Checklist

  • Followed lint naming conventions
  • Added passing UI tests (with committed .stderr files)
  • cargo test passes locally (relevant subset)
  • Executed cargo dev update_lints
  • Added lint documentation
  • Run cargo dev fmt

changelog: new lint: [definition_in_module_root]

@rustbot rustbot added needs-fcp PRs that add, remove, or rename lints and need an FCP S-waiting-on-review Status: Awaiting review from the assignee but also interested parties labels May 5, 2026
@rustbot
Copy link
Copy Markdown
Collaborator

rustbot commented May 5, 2026

r? @samueltardieu

rustbot has assigned @samueltardieu.
They will have a look at your PR within the next two weeks and either review your PR or reassign to another reviewer.

Use r? to explicitly pick a reviewer

Why was this reviewer chosen?

The reviewer was selected based on:

  • Owners of files modified in this PR: 7 candidates
  • 7 candidates expanded to 7 candidates
  • Random selection from Jarcho, dswij, llogiq, samueltardieu

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 5, 2026

Lintcheck changes for 810fe1d

Lint Added Removed Changed
clippy::definition_in_module_root 7556 0 0

This comment will be updated if you push new changes

@corygabrielsen corygabrielsen force-pushed the spike/definition-in-module-root branch from abca430 to fa6e565 Compare May 5, 2026 06:53
Flags definitions (struct, enum, fn, impl, trait, etc.) in `mod.rs`
files. Encourages putting each definition in its own named file so
filenames are descriptive and unique. `lib.rs` and `main.rs` are
not checked, since defining a small crate's API there is common and
reasonable.

Filenames are resolved through the source map so `#[path]` is
handled by the real filename. `#[macro_export]` macros and inline
modules are exempt. Items produced by macro expansion are skipped.

Projects opting into this lint will typically also `allow(module_inception)`,
since enforcing declarations-only in `mod.rs` makes `foo/foo.rs` the
expected layout.

Category: `restriction`.

changelog: new lint: [`definition_in_module_root`]
@corygabrielsen corygabrielsen force-pushed the spike/definition-in-module-root branch from fa6e565 to 810fe1d Compare May 5, 2026 07:03
Copy link
Copy Markdown
Member

@samueltardieu samueltardieu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please trim the PR description. LLM generated description are overly verbose and take more time to review than it takes to generate, this is not normal.

View changes since this review

Comment on lines +127 to +140
ItemKind::Struct(..) => Some("struct"),
ItemKind::Enum(..) => Some("enum"),
ItemKind::Union(..) => Some("union"),
ItemKind::Fn(..) => Some("function"),
ItemKind::Const(..) => Some("const"),
ItemKind::Static(..) => Some("static"),
ItemKind::Impl(..) => Some("impl block"),
ItemKind::Trait(..) => Some("trait"),
ItemKind::TraitAlias(..) => Some("trait alias"),
ItemKind::TyAlias(..) => Some("type alias"),
ItemKind::ForeignMod(..) => Some("extern block"),
ItemKind::MacroDef(..) if !has_macro_export(item) => Some("macro"),
// Allowed: mod, use, extern crate, #[macro_export] macros, etc.
_ => None,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is best to use the official terms, and to be exhaustive to make sure that newly added variants won't be forgotten:

Suggested change
ItemKind::Struct(..) => Some("struct"),
ItemKind::Enum(..) => Some("enum"),
ItemKind::Union(..) => Some("union"),
ItemKind::Fn(..) => Some("function"),
ItemKind::Const(..) => Some("const"),
ItemKind::Static(..) => Some("static"),
ItemKind::Impl(..) => Some("impl block"),
ItemKind::Trait(..) => Some("trait"),
ItemKind::TraitAlias(..) => Some("trait alias"),
ItemKind::TyAlias(..) => Some("type alias"),
ItemKind::ForeignMod(..) => Some("extern block"),
ItemKind::MacroDef(..) if !has_macro_export(item) => Some("macro"),
// Allowed: mod, use, extern crate, #[macro_export] macros, etc.
_ => None,
i @ (ItemKind::Struct(..)
| ItemKind::Enum(..)
| ItemKind::Union(..)
| ItemKind::Fn(..)
| ItemKind::Const(..)
| ItemKind::Static(..)
| ItemKind::Impl(..)
| ItemKind::Trait(..)
| ItemKind::TraitAlias(..)
| ItemKind::TyAlias(..)
| ItemKind::ForeignMod(..)) => Some(i.descr()),
i @ ItemKind::MacroDef(..) if !has_macro_export(item) => Some(i.descr()),
ItemKind::ExternCrate(..)
| ItemKind::Use(..)
| ItemKind::ConstBlock(..)
| ItemKind::Mod(..)
| ItemKind::GlobalAsm(..)
| ItemKind::MacCall(..)
| ItemKind::MacroDef(..)
| ItemKind::Delegation(..)
| ItemKind::DelegationMac(..) => None,

let help = if let Some(ident) = item.kind.ident() {
format!("move {kind} `{ident}` to a dedicated file")
} else {
format!("move {kind} to a dedicated file")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
format!("move {kind} to a dedicated file")
format!("move the {kind} to a dedicated file")

/// [`self_named_module_files`]: https://rust-lang.github.io/rust-clippy/master/index.html#self_named_module_files
/// [`mod_module_files`]: https://rust-lang.github.io/rust-clippy/master/index.html#mod_module_files
/// [`module_inception`]: https://rust-lang.github.io/rust-clippy/master/index.html#module_inception
#[clippy::version = "1.96.0"]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't be in before Rust 1.97 at the soonest, if it gets in.

Suggested change
#[clippy::version = "1.96.0"]
#[clippy::version = "1.97.0"]

/// produces `foo/foo.rs`, which [`module_inception`] flags — projects
/// in that situation typically also `allow(module_inception)`.
///
/// [`self_named_module_files`]: https://rust-lang.github.io/rust-clippy/master/index.html#self_named_module_files
Copy link
Copy Markdown
Member

@samueltardieu samueltardieu May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't include those links as they may not refer to the same version (master vs. a specific version, or beta).

@rustbot rustbot added S-waiting-on-author Status: This is awaiting some action from the author. (Use `@rustbot ready` to update this status) and removed S-waiting-on-review Status: Awaiting review from the assignee but also interested parties labels May 8, 2026
@rustbot
Copy link
Copy Markdown
Collaborator

rustbot commented May 8, 2026

Reminder, once the PR becomes ready for a review, use @rustbot ready.

@samueltardieu samueltardieu added the lint-nominated Create an FCP-thread on Zulip for this PR label May 8, 2026
@rustbot
Copy link
Copy Markdown
Collaborator

rustbot commented May 8, 2026

This lint has been nominated for inclusion.

A FCP topic has been created on Zulip.

@rustbot
Copy link
Copy Markdown
Collaborator

rustbot commented May 12, 2026

☔ The latest upstream changes (possibly #16486) made this pull request unmergeable. Please resolve the merge conflicts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lint-nominated Create an FCP-thread on Zulip for this PR needs-fcp PRs that add, remove, or rename lints and need an FCP S-waiting-on-author Status: This is awaiting some action from the author. (Use `@rustbot ready` to update this status)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants