Skip to content
Fred Souza edited this page Aug 3, 2026 · 1 revision

lanekeep checks .rs files. Rules are TypeScript programs matching a Rust syntax tree — see Home for why.

Install

cargo install lanekeep-cli
lanekeep check

Pin it, so every developer and CI agree on the version:

cargo install --locked lanekeep-cli@0.5.0

Rust 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-cli

brew install fmsouza/tap/lanekeep and the releases page work too, and are faster than building from source.

Configure

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.

Built-in rules

lanekeep/no-glob-import

// 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.

lanekeep/no-unwrap

// 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.

Writing a Rust rule

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.

What resolution knows about Rust

ctx.bindingKind understands:

Form Kind
use std::collections::HashMapHashMap 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

Patterns bind; constructors do not

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.

Other behaviors worth knowing

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.

Tree-sitter shapes that surprise people

Verified against the grammar, and each cost a round of failing tests:

  • attribute_item is a sibling of the item it decorates, not a child. #[test] sits before function_item in the parent's children, so reading the function's own children finds nothing.
  • The wildcard _ is not an identifier. let _ = x has no pattern field 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 against undefined, or read ctx.ancestors positionally.

Clone this wiki locally