-
Notifications
You must be signed in to change notification settings - Fork 0
Rust
lanekeep checks .rs files. Rules are TypeScript programs matching a Rust syntax tree — see
Home for why.
cargo install lanekeep-cli
lanekeep checkPin it, so every developer and CI agree on the version:
cargo install --locked lanekeep-cli@0.5.0Rust has no equivalent of go.mod's tool directive or package.json's devDependencies, so
there is no way to record that in a manifest. The practical options are a pinned
cargo install --locked line in your CI config and contributing guide, or
cargo-binstall, which fetches the prebuilt
binary instead of compiling it:
cargo binstall lanekeep-clibrew install fmsouza/tap/lanekeep and the
releases page work too, and are faster than
building from source.
lanekeep init in a directory with a Cargo.toml writes this for you:
{
"$schema": "https://raw.githubusercontent.com/fmsouza/lanekeep/main/schema/lanekeep.schema.json",
"include": ["src/**/*.rs"],
"exclude": [],
"rules": [
"lanekeep/no-unwrap",
"./lanekeep/rules/no-dbg.ts"
]
}There is no test path to exclude, deliberately: Rust keeps unit tests beside the code they
cover, under #[cfg(test)], so exclude cannot separate them. Rules that care about test code
detect the attribute instead — no-unwrap does.
In a workspace, widen the glob to reach every member:
{ "include": ["crates/*/src/**/*.rs"], "exclude": ["**/target/**"] }Build output under target/ is worth excluding explicitly if anything generates .rs there.
// bad
use crate::models::*;
// good
use crate::models::{User, Session};A glob makes it impossible to answer, by reading the file, where a name came from. Every unqualified identifier becomes a candidate for every glob in scope, and the answer moves when an upstream crate adds a public item — a name that resolved to yours last week resolves to theirs today, with no change on your side.
It is also the case that defeats tooling, including this one. lanekeep's resolver reports
nothing for a glob import, because the names it brings in cannot be known without reading the
other crate — so in a file with a glob, ctx.bindingKind quietly stops being able to answer.
*prelude* is allowed by default, since a prelude is the shape a glob is the intended spelling
of. allow takes patterns to widen that.
// bad
let config = load().unwrap();
// good
let config = load()?;A library that panics on a malformed input has failed at its job: the caller wanted an error it could handle and got a process abort. In a binary it is a crash whose stack trace points at the unwrap rather than at what was actually wrong.
Test code is exempt — #[test] functions, #[cfg(test)] modules and files under tests/.
Panicking is the failure mechanism there, and reporting it would mean either a rule nobody
turns on or a suppression on every assertion.
{ "rule": "lanekeep/no-unwrap", "options": { "allow": ["src/main.rs", "examples/*"] } }What it cannot tell apart: a method genuinely named expect on your own type — a mock
builder, say — is reported like Result::expect. Telling them apart needs type information,
which lanekeep deliberately does not have.
import { defineRule } from 'lanekeep'
export default defineRule({
id: 'local/no-std-process-exit',
language: 'rust', // ← required; the default is TypeScript
severity: 'error',
card: {
message: 'std::process::exit in library code',
remediation: 'return an error and let main decide the exit code, so destructors still run',
examples: {
bad: 'std::process::exit(1);',
good: 'return Err(Error::Config);',
},
},
gates: { fileContains: ['exit'] },
query: `
(call_expression
function: (scoped_identifier
path: (scoped_identifier) @path
name: (identifier) @name)) @call
`,
check(ctx, m) {
if (ctx.text(m.name) !== 'exit') return
if (ctx.text(m.path) !== 'std::process') return
ctx.report(m.call)
},
})language: 'rust' is not optional. The default is ['typescript', 'tsx'], and a rule does
not run on a file whose language it does not name — so omitting it means the rule silently never
fires.
gates.fileContains is an and. Every listed substring must be present. A rule matching
either of two tokens cannot express its gate as ['a', 'b'] — that rejects any file with only
one, which is usually most of them, and the rule then reports nothing while looking healthy.
Omit the gate when there is no single covering substring.
ctx.bindingKind understands:
| Form | Kind |
|---|---|
use std::collections::HashMap → HashMap
|
import |
let x, if let, while let, match arms |
let |
const MAX |
const |
static NAME |
var |
fn f() |
function |
struct S, enum E, union U, type A
|
type |
trait T |
trait |
mod m |
module |
| parameters, closure parameters | param |
fn f<T>(), const N: usize generics |
type-param |
for item in xs |
loop |
The one thing to know, and the thing a naive implementation gets wrong:
let Some(v) = opt else { return };v is bound. Some is not — it is being matched against. Resolution reflects that, so a
rule asking whether Some is the imported one still gets a straight answer. If it did not,
every constructor in the file would resolve to a local and import-based rules would go quiet.
The same holds for struct_pattern: in let Point { x, y } = p, Point is the type and x
and y are the bindings.
Items are order-independent. A function may call one declared two hundred lines below it,
and a const may reference a type declared later.
Destructuring binds every name. Tuples, slices, struct shorthands, ref bindings and both
sides of an or pattern all resolve.
if let and while let reach past their own header. The binding is visible in the
consequence and the else branch.
Globs claim nothing. use serde::*; binds names that cannot be known without reading the
crate, so nothing is reported rather than guessed — see no-glob-import above.
Macros are not expanded. macro_rules! bodies are token trees rather than expressions, and
pretending to resolve inside one would invent bindings that may never exist. A rule can still
match a macro_invocation node, which is how the scaffolded no-dbg rule works.
Verified against the grammar, and each cost a round of failing tests:
-
attribute_itemis a sibling of the item it decorates, not a child.#[test]sits beforefunction_itemin the parent's children, so reading the function's own children finds nothing. -
The wildcard
_is not an identifier.let _ = xhas nopatternfield at all, and a_match arm is a leaf. There is no node to resolve, so no guard against it is needed. - A method call is
call_expression > function: field_expression > field: field_identifier. -
Node handles are integers and the root's is
0.if (!ctx.parent(n))therefore discards the root, and every top-level item looks parentless. Compare againstundefined, or readctx.ancestorspositionally.
Languages
In the repository