Skip to main content

ZLint v0.10.0

· 6 min read
Don Isaac
ZLint Maintainer

Two months of work, 24 PRs, and a few things I've wanted to fix for a while. The headline items are custom rules, control flow analysis, better config behavior, and a builtin update command.

There are also three breaking changes, and one of them will almost certainly require you to edit your zlint.json. Those are first.

Breaking Changes​

zlint.json no longer defaults to every rule being off​

This is the big one. Previously, the mere presence of a zlint.json turned all rules off, and you had to opt back in to each one individually:

zlint.json
{
"rules": {} // before v0.10.0: nothing runs at all
}

This was to prevent upgrades from unexpectedly introducing new rules into your CI. This ended up being bad DX and surprised basically everyone who tried it. Now rules run at their default severities, and zlint.json will override/adjust them.

zlint.json
{
"rules": {
// everything else has default behavior.
"unsafe-undefined": "error",
"homeless-try": "warn",
"unused-decls": "off"
}
}

What to do: if your config was an explicit allowlist, you'll now get diagnostics from rules you never enabled. Set the ones you don't want to "off". Run zlint once after upgrading and see what shows up - it's usually a short list.

ignore patterns are globs now​

ignore used to match with "starts with" semantics. It now takes real glob patterns, which means a bare folder name matches the folder and not everything inside it:

zlint.json
{
"ignore": [
// before
"src/codegen",
// after
"src/codegen/**",
],
}

Add /** to your existing entries and you're done. In exchange you get patterns that were previously impossible:

zlint.json
{
"ignore": ["src/codegen/**", "src/**/*_test.zig", "**/*.gen.zig"]
}

GlobSet also supports negation now, so you can carve exceptions back out of a broad pattern. And while I was in there: a zlint.json with any ignore key at all used to panic with exit code 134. That's fixed too ([#358]).

Custom rules​

Custom rules are now a proper build option. Add them when you declare the dependency:

build.zig
const zlint = @import("zlint");

const zlint_dep = b.dependency("zlint", .{
.custom_rules = &[_]std.Build.LazyPath{
b.path("./src/your_custom_rule.zig"),
},
});

const lint_step = b.step("lint", "run zlint");
lint_step.dependOn(&zlint.addRunLint(b, zlint_dep).step);

There's a matching addRunCustomLintRulesTest for running your rules' tests. See the custom rules docs for the full API.

CLI arguments are paths, not globs​

Positional arguments are now treated as file or directory paths. This mostly means the obvious thing finally works:

zlint . # same as `zlint`
zlint src
zlint ./src/
zlint src/main.zig src/root.zig

If you were passing glob patterns on the command line, let your shell expand them, or move the pattern into ignore.

Control Flow Analysis​

Semantic now builds a control flow graph. This is groundwork only, as no rules consume it yet. CFG-based rules will be added and released in the near future.

You can look at it today:

zlint --print-cfg src/main.zig | dot -Tsvg > cfg.svg
zlint --print-cfg src/main.zig --cfg-decls

I'm hoping to add rules to check for dead code, inits without deinits, and maybe even integrate with an SMT solver for some really cool lints.

--config and zlint update​

By default ZLint walks up from your cwd looking for a zlint.json. You can now skip that search entirely:

zlint --config ./ci/zlint.strict.json
zlint -c ../shared/zlint.json src

Handy for CI, or for keeping a stricter ruleset next to a looser local one.

And upgrading is now a command instead of a curl pipeline:

zlint update # aliases: `upgrade`, `up`

It downloads and installs the latest stable release in place.

Better looking diagnostics​

Multiline spans used to underline every line edge-to-edge, which was noisy and genuinely hard to read when two spans sat next to each other. They're now drawn with left-hand arrows that bracket the region:

𝙭 duplicate-case: Switch statement has duplicate cases
╭─[duplicate-case.zig:3:10]
2 │ const x = switch (bar) {
3 │ ╭─▶ 1 => {
4 │ │ if (cond) {
5 │ │ const y = x + 2;
6 │ │ return y;
7 │ │ }
8 │ │ return 0;
9 │ ╰─▶ },
10 │ ╭─▶ 2 => {
11 │ │ if (cond) {
12 │ │ const y = x + 2;
13 │ │ return y;
14 │ │ }
15 │ │ return 0;
16 │ ╰─▶ },
17 │ else => 0
╰────

There's also a new ASCII formatter for terminals that can't render the box drawing characters. ZLint sniffs your terminal's Unicode support and falls back automatically, but you can force it:

zlint --format ascii

Rule fixes​

homeless-try understands type aliases. It used to look only at the syntax of the return type node, so a named alias for an error union looked like an infallible function:

const E = error{A};
const Result = E!void;
fn bar() E!void { return error.A; }

pub fn foo() Result {
try bar(); // v0.9.1: "`try` cannot be used in functions that do not return errors."
}

Alias chains are now followed, and return types that can't be classified are assumed fallible and stay quiet. Aliases that definitively resolve to a concrete type are still reported.

duplicate-case compares more of the AST. The structural comparator understood a fairly narrow slice of Zig, which cut both ways: equivalent branches were missed, and token-level differences were sometimes treated as equal.

function(value) // now correctly equal to `function(value,)`

if (opt) |*left| left else null
if (opt) |*right| left else null // and these are still correctly different

unused-decls doesn't leave doc comments behind. The --fix-dangerously autofix deleted the declaration but not its /// comment, which left you with an "unattached documentation comment" error - or worse, silently glued the comment onto whatever declaration came next. The deletion span now extends backward over consecutive doc comment tokens. Module-level //! comments are deliberately left alone.

Performance and housekeeping​

  • The parser no longer tokenizes each file twice. Free speed.
  • Summary statistics update monotonically, so the counters no longer appear to jump backwards while a large run is in progress.
  • The static library artifact is now named zlint-lib.
  • Config reading and resolution got a good simplification pass.
  • CLI tests were silently not running in CI. They are now, which is how the argument-handling change above got its coverage.