elint 0.0.3

Erlang code linter
Documentation
# Preprocessing

How elint turns an Erlang source file into a parse forest, and two behaviors
of that pipeline that may surprise users coming from a compiler:

- every conditional branch is linted, not only the branch a compiler would
  take;
- `-include` / `-include_lib` are not resolved to the filesystem.

## Pipeline

For each `.erl` file elint runs a three-stage pipeline (`src/context.rs`):

1. **Tokenize** with `erl_tokenize`.
2. **Preprocess** with `erl_pp`. The driver responds to every
   `Awaiting*` event (include, conditional, macro expansion).
3. **Parse** the preprocessed lexical tokens with `erl_parse`
   (`ParseMode::Module`).

The driver may explore several preprocessor states for one file; each
explored state becomes a `BranchContext` (parse tree with its token slice,
origin side table, preprocessor diagnostics). The first branch is the
mainline. Rules run independently on each branch, and findings are mapped
back to byte spans in the original file via the origin side table.

## Conditional branch exploration

A compiler picks one branch per conditional according to the current macro
table. A linter instead wants to catch problems in code that the current
configuration would skip. elint therefore analyzes **every arm of every
conditional**:

- At each `-ifdef` / `-ifndef` / `-if` / `-elif`, the preprocessor is cloned
  and each arm is scanned with `Branch::Then` or `Branch::Else`.
- The **mainline** always takes `Branch::Then` at every conditional and
  continues past each `-endif`. This is a deliberate choice: it is not the
  OTP `recommended` branch, and `-if` / `-elif` conditions are never
  evaluated.
- A **non-mainline** arm is scanned only up to the `-endif` that closes the
  conditional it was forked from; the scan stops there.

This stop-at-`-endif` rule is what keeps the cost linear. Each source region
is scanned by exactly one branch, so findings do not duplicate across
branches and the total work is proportional to the file size rather than to
the 2^n product of independent conditionals. Nested conditionals inside an
arm are explored the same way by the branch that made that arm active.

All findings from all branches are reported, mapped to the original file
span of the offending tokens.

## Include resolution

`-include` / `-include_lib` are **not** followed to the filesystem. elint
keeps no search-path configuration (`-I`, `ERL_LIBS`, ...), so headers are
skipped entirely.

The consequences:

- Macros defined in headers are never seen, so they are treated like any
  other unknown macro.
- Unknown macros -- header macros, but also OTP predefined macros such as
  `?MODULE` / `?FUNCTION_NAME`, of which `erl_pp` only implements
  `?FILE` and `?LINE` -- expand to the placeholder atom `elint_dummy`.
  The placeholder is a lowercase atom so it is valid wherever a macro value
  could appear and the file still parses.
- No unknown-macro diagnostic is emitted. Without include resolution there is
  no way to tell a macro that is genuinely undefined from one defined in an
  unresolvable header, so reporting them would be noisy.

The consequence for rules is that they cannot see through header-defined
macros or records today. If a rule appears that needs header information,
include resolution should be reconsidered at that point.

## Reporting

- Tokenize errors abort the file: no branch is linted.
- Preprocessor structural errors (stray `-else`, unclosed conditional, macro
  arity mismatch, ...) and parse diagnostics are reported per branch.
- `-error` / `-warning` directives are deliberately ignored; see
  [diagnostics]diagnostics.md.
- Parse diagnostics cause only that branch's lint to be skipped; other
  branches still contribute findings.