3.38. Lint Tools
daslang provides three complementary lint passes that detect issues at compile time:
Paranoid lint (
daslib/lint) — unreachable code, unused variables and arguments, variables that can belet, underscore naming, redundant reinterpret casts, 64-bit narrowing traps (error code50503)Performance lint (
daslib/perf_lint) — performance anti-patterns (error code31208)Style lint (
daslib/style_lint) — non-idiomatic patterns (error code31209)
Each pass can be used independently or together.
3.38.1. Quick start
Add the corresponding require to any file. The lint runs automatically at
compile time and reports warnings inline:
options gen2
require daslib/lint // paranoid
require daslib/perf_lint // performance
require daslib/style_lint // style
3.38.2. Standalone utility
A unified utility runs all three passes on files and directories:
bin/Release/daslang.exe utils/lint/main.das -- <files-or-dirs...> [options]
Options:
--quiet— suppress PASS lines and progress messages--comment-hygiene— enable STYLE014/STYLE015 comment-length checks--paranoid-only— only run paranoid lint--perf-only— only run performance lint--style-only— only run style lint--enable CODE[,CODE...]— force-enable specific rules (bypasses.lint_configdefaults)--disable CODE[,CODE...]— disable specific rules; on overlap with--enable,--disablewins--lint-fixtures— also lint fixture-named files (underscore-led names containingfixture)--lint-excluded-paths— report findings the.lint_config[paths]exclude policy would drop
Examples:
# Lint a single file (all 3 passes)
bin/Release/daslang.exe utils/lint/main.das -- daslib/json.das
# Lint a directory recursively
bin/Release/daslang.exe utils/lint/main.das -- daslib/
# Performance lint only, quiet mode
bin/Release/daslang.exe utils/lint/main.das -- daslib/ --perf-only --quiet
Output format per file:
FAIL— file failed to compileWARN— file has lint issues (count and details follow)PASS— file is clean (suppressed with--quiet)
Exit codes: 0 = clean, 1 = compile errors, 2 = warnings only.
3.38.3. Suppressing specific warnings
Add a // nolint:CODE comment on the same line as the flagged expression:
let ch = character_at(s, idx) // nolint:PERF003 — single indexed access
build_string() <| $(var w) { // nolint:STYLE001 — intentional pipe
The suppression is exact: // nolint:PERF003 only suppresses PERF003, not other
rules. An optional explanation after the code is recommended but not required.
A file whose subject conflicts with the lint pipeline’s compile policies can opt
out entirely with a // lint-skip-file: <reason> comment in the file header (the
first 16 lines — deeper occurrences are treated as prose, so quoting the directive
in a doc comment cannot silently unlint a file). Canonical user:
tests/language/static_if.das, which tests the infer-time
folding the pipeline’s no_infer_time_folding policy disables — it cannot even
compile under lint. The runner reports the file as skipped with the reason, so
coverage accounting still sees it.
3.38.4. Repo-level .lint_config
A .lint_config file at {get_das_root()}/.lint_config toggles
individual rules repository-wide. The three lint pass-macros
(daslib/lint, daslib/perf_lint, daslib/style_lint), the
standalone runner (utils/lint/main.das), and the MCP lint tool
all consult the same file.
The file is TOML 1.0, parsed by daslib/toml. Toggles live in a
single [rules] table; each entry sets a rule to true (on) or
false (off):
# Re-enable a default-off rule
[rules]
STYLE005 = true
# Disable a default-on rule
PERF007 = false
Defaults (applied before the file is read):
STYLE005 is off by default. Add
STYLE005 = trueto a repo’s.lint_configto opt back in.All other rules are on by default.
The file is optional. When missing, unreadable, or syntactically
malformed the defaults stand — bad TOML is a silent no-op, not a
compile error. Non-boolean entries under [rules] are ignored.
A [paths] table declares repo-root-relative globs whose findings are
dropped at REPORT time:
[paths]
exclude = ["tests/interfaces/test_interfaces.das"]
Excluded files still compile through the lint pipeline — their compiles
instantiate generics and consume nolint directives, so cross-file
findings they expose in non-excluded files stay visible; only findings
in the matching files are hidden, and the summary counts what the
policy hid. The intended use is a shrinking debt ratchet: narrow or
remove entries as paths reach zero. --lint-excluded-paths on the
standalone runner shows the hidden findings; the MCP lint tool
always shows raw findings (the same deliberate split as
--lint-fixtures).
CLI --enable on the standalone runner bypasses the defaults (the
explicit whitelist wins), so daslang utils/lint/main.das -- --enable
STYLE005 file.das always fires STYLE005 regardless of .lint_config.
The *_collect() APIs (paranoid_collect, perf_lint_collect,
style_lint_collect) do not read the file — callers pass
disabled_codes / enabled_codes tables explicitly. Tools that
want to honor the repo policy should call
daslib/lint_config::seed_default_disabled and load_lint_config
before invoking the collect overload.
3.38.5. Important notes
Lint runs after optimization. The lint pass runs on the post-optimization AST. Patterns in dead code may not trigger warnings. In real code where results are used, the patterns are preserved and detected correctly.
ExprRef2Value wrapping. The compiler wraps many value-type reads in
ExprRef2Value nodes. The lint visitors unwrap these transparently — this is
an implementation detail, not something users need to worry about.
Closures are excluded. Code inside closures (blocks, lambdas) is not checked for loop-related performance patterns, since the closure may be called outside the loop context.
3.38.6. Paranoid rules
3.38.6.1. LINT001 — unreachable code
Code after a return or panic() in the same block is unreachable and
will never execute.
def foo() {
return 1
print("never reached\n") // LINT001
}
3.38.6.2. LINT002 — unused variable
A declared variable is never read. Prefix the name with an underscore
(_x) to suppress the warning, or remove the variable entirely.
def foo() {
var x = 5 // LINT002 — x is never used
return 1
}
3.38.6.3. LINT003 — variable can be let
A var variable is never mutated. Declare it with let instead.
// Bad
var x = 5 // LINT003
return x
// Good
let x = 5
return x
The rule consults the access_info_pass_mutable detail flag: a variable
whose only “mutable” use is being passed to a mutable-ref parameter is not
flagged, even when the callee never writes through it — a let argument
would no longer match the var parameter and the build would break. The
callee side of that situation is LINT014’s report.
def probe(var a : float[4][4]) : string { // never writes a — see LINT014
return "{a[0][0]}"
}
def caller {
var mv : float[4][4] // no LINT003: `let mv` would not compile
print(probe(mv))
}
3.38.6.4. LINT004 — underscore-prefixed variable is used
A local variable named _x is conventionally unused. If it is actually
read, rename it without the underscore prefix. This applies to locals only —
a _-prefixed argument is never flagged (a parameter name is often
constrained: intentionally unused, or dodging a reserved keyword / shadow such
as _in).
def foo() : int {
var _x = compute()
return _x // LINT004
}
3.38.6.5. LINT005 — redundant reinterpret cast
reinterpret<T>(x) where T is the same type as x is a no-op.
Remove the cast.
The rule skips casts that strip const or temporary modifiers (those
serve a purpose) and casts between void? and typed pointers. It also
skips generic instantiations and compiler-generated functions.
// Bad — x is already int?
var y = unsafe(reinterpret<int?>(x)) // LINT005
// Good
var y = x
// Good — strips const (not flagged)
var y = unsafe(reinterpret<int?>(const_ptr))
3.38.6.6. LINT006 — division by zero (constant zero divisor)
x / 0 and x % 0 produce a runtime panic (integer) or inf / nan
(float). When the right-hand side is a literal zero, this is almost always a
typo. Also covers the compound forms /= and %=. Recognizes literal zero
across int, uint, int64, uint64, float, and double.
// Bad
let y = x / 0 // LINT006
x %= 0 // LINT006
// Good
let y = x / divisor
3.38.6.7. LINT007 — identical left and right operands
Both sides of a binary operator are the same expression. The result is trivial
(x == x is always true, x - x is always 0, x && x is just x)
and the code is almost always a copy-paste typo. Triggers on: ==, !=,
<, >, <=, >=, -, /, %, &&, ||, &,
|, ^, -=, /=, %=.
// Bad — author meant `size == capacity` or similar
if (size == size) { ... } // LINT007
// Good
if (size == capacity) { ... }
Operators like + and * are deliberately excluded: x + x is the
common way to double a value and x * x is squaring, both of which are
intentional.
NaN idiom. x != x on float or double is the canonical IEEE 754
NaN check — daslang has no dedicated is_nan helper for scalar floats.
Suppress LINT007 on the one line that needs it:
def is_nan(x : float) : bool {
return x != x // nolint:LINT007 — canonical IEEE 754 NaN check
}
Note on constant folding. The paranoid lint runs after optimization.
let x = 1; x == x is folded to true before lint sees it and will not
fire. LINT007 catches cases where operands are runtime values (function
parameters, field reads, function calls).
Structural equality uses the expression pretty-printer: describe(left) ==
describe(right). This catches both x vs x and a.b.c vs a.b.c,
at the cost of serializing both subtrees. Pointer-identity is not used as a
fast path because daslang AST nodes are not shared — each Expression has
exactly one parent, so sibling operands are always distinct nodes.
3.38.6.8. LINT008 — both ternary branches equivalent
cond ? x : x ignores cond and always produces x. Copy-paste bug.
// Bad
let y = cond ? value : value // LINT008
// Good
let y = cond ? then_value : else_value
3.38.6.9. LINT009 — then branch equivalent to else branch
if (c) { A } else { A } runs A regardless of c. Usually the author
copy-pasted one branch and forgot to edit the other. Caught even when A
has side effects — the structural pattern is suspicious regardless of purity.
// Bad
if (flag) {
x = 1 // LINT009
} else {
x = 1
}
// Good
if (flag) {
x = 1
} else {
x = 2
}
3.38.6.10. LINT010 — dead store
A local variable is written but the value never reaches a read — either it is
overwritten by a later write with no intervening read, or it goes out of scope
(return / end-of-block) with no subsequent read. Two forms fire:
overwritten without intervening read— the next write happens before any reader sees this one.value written but never read before scope exit— the scope ends and no subsequent code reads this store.
The variable being read elsewhere keeps LINT002 (unused variable) silent — this rule is for partial deadness within an otherwise-used local.
// Bad — re-init before any read
def f() : int {
var x = 1 // LINT010
x = 2
return x
}
// Bad — written before return, never read
def g(z : int) : int {
var t = z
print("{t}\n")
t = compute() // LINT010 — scope ends, no read
return z
}
// Bad — lambda captures by-copy at creation, later write never observed
def h() : int {
var x = 1
let f = @() => x + 1
x = 2 // LINT010 — lambda has its own copy
return invoke(f)
}
// Good
def g_fixed(z : int) : int {
var t = z
print("{t}\n")
return z * 2
}
The rule’s scope is intentionally narrow for the first pass: it only flags
pure stores (LHS of = / <- / :=) on function-local variables, only
when the dead store’s RHS has noSideEffects, and only in straight-line
basic blocks — control flow (if / for / while / try) and
block-argument callbacks (tab |> get(k) $(v) { … }) cause the analysis to
bail on the variable to avoid false positives. Bail signals also include
address-of (addr(x)), reference bindings (var r & = x), mutable-ref
parameter passing (foo(x) where foo takes var T&), and capture-by-
reference. Suppress structurally-needed dead inits with // nolint:LINT010.
3.38.6.11. LINT011 — int literal promoted with precision loss
When a bare integer literal flows into a float or double target via
the implicit promotion described in
Type Conversion and Casting, the cast can silently lose
precision: float exactly represents every integer in [-2^24, 2^24],
but above that range only every other integer (and at higher magnitudes,
only every fourth, eighth, …) is representable. LINT011 flags promotions
where the integer literal does not survive int → float → int round-trip
at compile time. The check is decided at the promotion site, so the lint
sees a single bit per ExprConstFloat / ExprConstDouble and never
has to redo the math.
double covers integers up to 2^53 exactly, and the current promotion
sources cap at uint32 (2^32 - 1). LINT011 therefore never fires on
double targets today — the rule is wired symmetrically so future broader
sources stay covered.
// Bad — float can't represent 2^24 + 1 exactly
let inexact : float = 16777217 // LINT011
// Good — 2^24 itself IS exactly representable
let exact : float = 16777216
// Suppress per call site
let intentional : float = 16777219 // nolint:LINT011
// double is fine for the current promotion sources
let big : double = 1000000000 // no warning
Suppress with // nolint:LINT011 on the offending line when the inexact
value is intentional (a sentinel, a sampled constant, etc.).
3.38.6.12. LINT012 — unused function argument
A function argument is never read. Prefix the name with an underscore
(_x) to suppress, annotate the function with [unused_argument(x)], or
remove the argument. The underscore and annotation forms are the right tool
when the signature is fixed — interface conformance or a default-value
placeholder.
Arguments of class methods are exempt: their signature is dictated by the base class or interface, so an unused parameter there is structural rather than a mistake. LINT012 fires on free functions only.
// Bad — `b` is never used
def scale(a : int; b : int) : int { // LINT012 on b
return a * 2
}
// Good — underscore marks it intentionally unused
def scale(a : int; _b : int) : int {
return a * 2
}
// Good — annotation keeps the name (call sites / docs unchanged)
[unused_argument(b)]
def scale(a : int; b : int) : int {
return a * 2
}
3.38.6.13. LINT013 — unused block argument
The same check for the parameters of a block, lambda, or generator passed as a callback. Callbacks that ignore a parameter are common; suppress exactly as for LINT012.
// Bad — the callback ignores its parameter
tab |> get(key) $(var value : int&) { // LINT013 on value
report(true)
}
// Good
tab |> get(key) $(var _value : int&) {
report(true)
}
3.38.6.14. LINT014 — mutable (var) argument is never written
A by-ref parameter declared var whose body never writes it forces every
caller to keep its argument mutable for nothing — and silences LINT003 at
every call site (see the LINT003 exemption above). Drop the var; callers
may then declare their arguments with let.
The rule skips: workhorse by-value parameters (a var int is a mutable
local copy, not a caller burden), class methods and [extern] stubs
(signature dictated externally), finalize overloads (the delete protocol
requires var), address-taken functions (signature conforms to a
function-pointer type), and underscore-prefixed names. It also skips a
parameter whose pointer-valued data flows into a mutable-pointee slot —
a store like node.next = b.p needs the pointer non-const (error 30915),
which only var b provides. Slots that accept a const pointer
(void?, Foo const?, and const-pointer results) do not suppress.
Cascade: a parameter that is itself passed onward to a mutable-ref slot is skipped — the leaf callee is flagged first; once its signature is fixed, the next lint run exposes the caller.
// Bad — b is only read
def get_value(var b : Box) : int { // LINT014 on b
return b.value
}
// Good
def get_value(b : Box) : int {
return b.value
}
// Not flagged — mutability is demanded transitively; fix get_value first
def get_twice(var b : Box) : int {
return get_value(b) * 2
}
Note
// nolint:LINT012 / // nolint:LINT013 / // nolint:LINT014
suppression is scanned on the exact reported line only — not the line
above. On a multi-line argument or block-parameter list, place the comment
on the specific parameter’s line; on a single-line def foo(a, b, c) a
trailing // nolint:LINT012 suppresses the whole line.
3.38.6.15. LINT015 — free-floating unary +/- statement
At statement level daslang is one statement per line, so a multi-line
arithmetic RHS without wrapping parentheses splits each continuation into its
own statement. A line beginning with + or - re-parses as a unary
expression (+b / -b) whose value is discarded — the term no longer
contributes. When the operand is pure the optimizer removes the whole statement
silently: terms vanish, the result is wrong, and nothing is reported. (If
the operand has side effects those still run, but the leading +/- is
meaningless either way — the rule fires regardless.)
Only + and - qualify — they are the only operators that are both binary
and unary, so a split binary a + b orphans as a valid unary statement.
~ / ! are not binary (they cannot be a split continuation), ++ /
-- mutate (real statements), and an operator that cannot begin a statement
(*, |>, …) raises a loud parse error instead.
// Bad — `+ b` and `+ c` become separate `+b` / `+c` statements, dropped
let x = a
+ b // LINT015
+ c // LINT015
// Good — the newlines now fall inside the parentheses
let x = (a
+ b
+ c)
Wrap any multi-line arithmetic RHS in (...).
3.38.6.16. LINT016 — ineffective string clone syntax
In the default single-context mode, copying a regular string only copies
its pointer. Consequently,
dst := src, var dst := src, and strings |> push_clone(src) do not
clone the character data when src is an ordinary string; their clone
spelling is misleading.
Use ordinary copy syntax for same-context storage, and make a cross-context
copy explicit with clone_string in the receiving context:
dst = src
var local = src
strings |> push(src)
dst = clone_string(src)
var local_copy = clone_string(src)
strings |> push(clone_string(src))
The rule stays silent in two cases where clone syntax has real semantics:
options multiple_contextsis enabled. The compiler then lowers string:=— including variable initialization and the assignment insidepush_clone— toclone_string.The source expression has temporary type
string#. A real clone is required to retain the borrowed characters beyond the temporary’s lifetime, and the compiler inserts it automatically.
Only user-written, non-generated code is reported. Generic templates and their instantiated functions are skipped; a direct call from ordinary user code is still checked.
3.38.6.17. LINT017 — 64-bit cast of a call that has a long_ counterpart
int64(length(x)) widens a result that is already 32-bit, so the
231 limit is reached inside length before the cast ever runs — as
a silent wrap for the unguarded pairs, or as a panic for array, table and
string length, which carry an always-on guard. Either way the cast looks like
it buys 64-bit range and buys nothing. Call the long_ form, which is
64-bit the whole way through.
// Bad — wraps before the cast ever runs
let n = int64(length(arr))
// Good
let n = long_length(arr)
Applies to int64(...) and uint64(...) over length, capacity,
count, find_index, fread and fwrite.
The pair table is hardcoded on purpose. An “does this function exist” check is
not meaningful for user functions — a macro may add or remove them during
compilation — so the only well-defined domain is the builtin and daslib
set, which is enumerable. Each pair is additionally gated on the receiver
type, which keeps a same-named user overload silent, along with the
fixed-array length generic in daslib/builtin.das that genuinely has no
long_ twin.
3.38.6.18. LINT024 — 64-bit cast over a 32-bit product
int64(w * h * 3) computes the product in 32 bits and widens the wrapped
result: the overflow happened inside the parentheses, and the cast that looks
like it buys 64-bit range buys nothing. LINT017’s sibling — there the inner
call clamps at 231, here the inner arithmetic wraps. Widen a
factor first, so the multiply itself runs in 64 bits.
// Bad — an int product wraps past ~46k x 46k before int64 sees it
let bytes = int64(length(pixels) * 3)
// Good — the multiply is 64-bit from the first factor
let bytes = long_length(pixels) * 3l
Fires on int64(...) / uint64(...) whose operand is a +/-/*
tree containing a product, whose type is still 32-bit, and one of whose
leaves is a call — length(a), a dimension getter, the unbounded factor
that makes the wrap real. So int64(length(a) * 4 + pad) fires (the product
wraps under the sum) while int64(w * h * 3) over plain locals does not: a
product of locals is kernel tile geometry far more often than a byte count
(the in-tree sweep found 81 of those against 2 real ones), and the rule keeps
its signal by staying silent there — widen those by hand where they are byte
counts. int64(a + b) has no product and is not this rule’s shape; an
operand that is already 64-bit is silent.
3.38.6.19. LINT018 — narrowed size argument of a call with a 64-bit overload
memcpy and memcmp carry uint, int64 and uint64 size
overloads; array resize, resize_no_init, resize_and_init,
reserve and erase (both forms) and table reserve carry int64
overloads. An int(...) cast on the size or position argument of any of
them is pure loss: above 231 it silently covers the wrong count while
the overload would have taken the 64-bit value straight through.
// Bad — truncates for nbytes > 2GB
unsafe(memcpy(dst, src, int(nbytes))) // nbytes : int64
arr |> resize(int(newSize)) // newSize : int64
// Good
unsafe(memcpy(dst, src, nbytes))
arr |> resize(newSize)
The container half is receiver-gated on array/table, which keeps string
resize silent (it is int-only — the advice would not compile) along with
same-named user functions on other receivers. A uint64 operand gets a
“widen with int64(...)” variant instead (the container overloads have no
uint64 arm). Only a cast that is the whole argument fires; compound
sizes like resize(int(n) + 1) are LINT021’s business. The ranged
erase(at, count) reports once per call — both bounds share the fix, and
its int64 overload is homogeneous, so a mixed call widens the sibling
argument too.
3.38.6.20. LINT019 — stale nolint directive
A // nolint:CODE directive that suppressed no diagnostic during the run is dead
weight: it buries the next real finding on that line and does not survive the code
moving. The lint runner records every suppression the passes consume and reports the
leftovers after all passes complete. In the standalone runner’s serial mode
(-j 1) the stale scan runs once at the END of the run over every file, so a
directive in a generic body is credited by ANY compile in the run that instantiates
it — including files that sort after it. Parallel workers still scan per file
(each worker sees only its own consumption), so serial mode is the authoritative
staleness determination.
Two escape hatches for directives that are live only outside the current compile:
add LINT019 to the code list (// nolint:PERF020,LINT019) when the rule fires
only in downstream compiles — generic bodies (instantiations elsewhere fire at the
generic’s own line: uint64(data) in a generic hash is a PERF020 hit only for the
uint64 instantiation), macro-template lines whose diagnostics surface at expansion
sites, or option-gated rules — and run-disabled codes are skipped automatically, since
their rules never had the chance to prove the directive.
Never remove a reported directive from a generic body on the scan’s word alone —
the per-file scan cannot see which instantiations elsewhere consume it. Tag it with
LINT019 instead; removal is only safe where the author knows no other compile
reaches the line.
// Bad — PERF006 no longer fires here; the directive outlived its rule hit
arr |> push(v) // nolint:PERF006
// Good — remove it; or, on a macro-template line:
body |> push <| qmacro_expr() { // nolint:LINT004,LINT019
var $i(tmp) = clone_type($i(td_var));
}
3.38.6.21. LINT020 — 64-bit bound narrowed into a 32-bit range constructor
range(int(n)) truncates the bound above 231 before the loop even
starts. range64 and urange64 take the 64-bit value directly, and the
loop variable then indexes arrays, fixed arrays and (unsafe) pointers as-is —
TypeDecl::isIndexExt admits int64/uint64 subscripts natively.
// Bad — n > 2^31 wraps before the loop starts
for (i in range(int(n))) { // n : int64
s += arr[i]
}
// Good — 64-bit the whole way through
for (i in range64(n)) {
s += arr[i]
}
Applies to range, urange and interval (whose int64 form maps
to range64), on any bound of the 1- and 2-arg forms, reporting once per
constructor. Only int(...)/uint(...) casts of 64-bit operands fire —
range(int(someFloat)) is a genuine conversion and stays silent.
Two carve-outs to know when applying the fix: vector components (v[i])
and string indexing accept only 32-bit subscripts, and the slice form
a[range] has no range64 overload. In those rare bodies, narrow the
bound once instead and keep the 32-bit loop.
3.38.6.22. LINT021 — a 64-bit local that is never consumed as 64-bit
An int64/uint64 local whose every use sits inside an
int(...)/uint(...) narrowing cast is a 64-bit detour: the wide value
is computed and thrown away at every sink, truncating silently above
231. Either narrow once at the declaration (or compute in int —
length() instead of long_length()), or lift the sinks to 64-bit
(range64, the int64 resize/reserve/erase overloads).
// Bad — keep is 64-bit, yet every single use narrows
let keep = long_length(pending) - consumed * HOP
rest |> resize(int(max(keep, 0l)))
for (i in range(int(max(keep, 0l)))) { ... }
// Good — 64-bit end-to-end; the casts simply disappear
let keep = long_length(pending) - consumed * HOP
rest |> resize(max(keep, 0l))
for (i in range64(max(keep, 0l))) { ... }
A use counts as narrow-consumed only when the path from the cast to the
variable goes through math that leaves the top unbounded — operators, the
ternary, max and abs. min, clamp and sign cap the value,
which makes the narrow deliberate saturation (int(clamp(x, lo, hi)) is the
canonical spelling), so they disqualify instead — as does any other call under
the cast, a comparison, a write, a capture, or a bare 64-bit use. Only
initialized non-ref locals are tracked — parameters and iteration variables
are out of scope.
Known accepted false positive: a genuinely 64-bit value whose only use narrows
after load-bearing 64-bit math under the cast — int(t / 1000000l) over a
nanosecond timestamp is shape-identical to int(max(keep, 0l)). The rule
cannot tell them apart; // nolint:LINT021 on the declaration line is the
answer there.
3.38.6.23. LINT022 — private declaration is never used
A private declaration is reachable only from its own module, so if nothing
in that module references it the declaration is dead code — it still compiles,
formats and lints clean while nothing outside can ever reach it. The rule
covers functions, structures, classes and enumerations. The same applies to
every symbol of a private module, where the module declaration makes the
whole surface private.
def private rollback(var st : State) { // LINT022 — no caller anywhere
st.claimed |> resize(st.mark)
}
struct private Bucket { // LINT022 — no type mentions it
lo, hi : int
}
def private commit(var st : State) { // fine — called below
st.mark = length(st.claimed)
}
def scan(var st : State) {
commit(st)
}
Function references are collected from resolved calls, @@fn function
pointers, new constructors and operator sites, and — for generic bodies,
which are never inferred and therefore resolve nothing — by call name.
Structures and enumerations are referenced through types rather than
expressions, so they are marked from every type the walk touches: the type of
every expression, every function signature (generics included), every
structure field, every global and every typedef. A generic body carries no
inferred types at all, so the extra sweep over generics matches by spelled
name — the declared type of every local and argument, every cast target and
every new — which is the only trace a private type leaves there. A string literal matching
a declaration’s name counts as a reference, which is what makes a name-based
lookup (find_structure, RTTI, a macro registry) safe.
Global variables are deliberately not checked. Const folding and
static_if erase the very references the check would need: a debug flag read
only in static_if (log_enabled) { ... } leaves no trace of itself once the
branch is elided, and a const global’s uses are replaced by its value. Both
shapes are common and deliberate — a family of typeinfo variant_index
constants, a phase ladder, a switched-off trace scaffold — so a global check
reports code that is neither dead nor removable.
Three self-reference shapes deliberately do not count as a use, or nothing
would ever report: a structure field of the structure’s own type (a linked list
keeping itself alive), a class referenced by its own method fields or by
self inside its own methods, and a compiler-generated body naming its own
subject type — a constructor, finalizer or clone mentions it by construction.
That last exclusion is scoped to the one subject type on purpose: a lambda body
also compiles to a generated function, so skipping generated bodies wholesale
would blind the rule to every type used only inside a lambda.
A declaration of any kind — function, structure, class or enumeration — stays
silent when it carries any annotation ([export], [init],
[test], [enum_total], any macro hook: those are entry points nothing
references from das source, and a macro often consumes the declaration without
ever naming it), when it is a class method (virtual dispatch), or when it is
generic, template-flavoured or compiler-generated.
The rule is on by default under ``daslib/`` and ``utils/`` and off
elsewhere: in other trees an unreferenced private declaration is a cleanup
backlog rather than a gate, and a type-matrix overload family (three kv_dot
overloads covering three element types, say) has members that are legitimately
unreferenced today. Override per module with options _unused_private = true
(or false), or everywhere with LINT022 = true in .lint_config.
One driver-dependent gap is worth knowing. The function half stands down
when heuristic auto-inlining is active (auto_inline_functions, on by
default in optimized builds): that tier splices a small private callee into
its call sites before lint runs and leaves an unreferenced husk behind,
indistinguishable from dead code. Types survive that tier untouched, so they
are always checked. The lint runner compiles with no_optimizations, so it
checks everything; a fixture or module that wants the function half under a
plain compile sets options auto_inline_functions = false.
// nolint:LINT022 on the declaration line is the answer for a symbol kept
on purpose — a debug helper behind a commented-out call, say.
3.38.6.24. LINT023 — mutable (var) by-value argument is written but never read
A by-value parameter — int, float, bool, string, a vector, any
type that is not a struct, array, table or reference — is a local copy. A write
the body never reads back is therefore unobservable: the caller cannot see it,
and nothing inside used it. This is the out-parameter that never reaches its
caller (var why : string assigned on the error path, why still empty at
the call site). Declare it T& if it is an out-parameter; otherwise the write
is dead.
The rule is LINT014’s mirror: LINT014 catches a var nothing writes, this
catches a var nothing reads. Only plain stores keep a parameter reported —
=, :=, <-, compound assignment, a swizzle store (v.x = 1.0),
a statement-level ++/--. Any other appearance is a use and silences the
rule: a read, return, passing it on (to a & slot too — the callee may
read it first), addr, a lambda capture, a write through a pointer
parameter’s pointee (p.x = 1 reaches the caller’s object; reassigning
p itself is a store like any other). Class methods and [extern] stubs,
underscore-prefixed names and [unused_argument] are skipped.
// Bad — the caller's `why` never changes
def parse(text : string; var why : string) : int { // LINT023 on why
if (empty(text)) {
why = "empty input"
return -1
}
return length(text)
}
// Good
def parse(text : string; var why : string&) : int {
if (empty(text)) {
why = "empty input"
return -1
}
return length(text)
}
// Not flagged — a scratch parameter is written and then read
def twice(var n : int) : int {
n = n * 2
return n
}
3.38.7. Performance rules
3.38.7.1. PERF001 — string += in loop
String concatenation with += inside a loop creates O(n2) allocations.
Each iteration allocates a new string of increasing length, copying all previous content.
// Bad — O(n^2)
var result = ""
for (i in range(100)) {
result += "x" // PERF001
}
// Good — O(n)
let result = build_string() $(var writer) {
for (i in range(100)) {
write(writer, "x")
}
}
3.38.7.2. PERF002 — character_at in loop with loop variable
character_at(s, i) is O(n) per call because it internally calls strlen
to validate the index. In a loop iterating over string indices with the loop
variable as the index, this becomes O(n2) total.
// Bad — O(n^2)
for (i in range(length(s))) {
let ch = character_at(s, i) // PERF002
}
// Good — O(n) total, O(1) per access
peek_data(s) $(arr) {
for (i in range(length(arr))) {
let ch = int(arr[i])
}
}
3.38.7.3. PERF003 — character_at anywhere
Informational warning for any use of character_at. Each call does a bounds
check by scanning to the index. For accessing the first character, use
first_character which is O(1). For bulk access in hot paths, consider
peek_data for reads or modify_data for mutations.
let ch = character_at(s, 0) // PERF003 — use first_character(s) instead
let ch2 = first_character(s) // O(1); panics on empty string
3.38.7.4. PERF004 — string interpolation reassignment in loop
str = "{str}{more}" inside a loop has the same O(n2) behavior as
str += "...". Each iteration allocates a new string containing all previous
content.
// Bad — O(n^2)
var result = ""
for (i in range(100)) {
result = "{result}x" // PERF004
}
// Good — O(n)
let result = build_string() $(var writer) {
for (i in range(100)) {
write(writer, "x")
}
}
3.38.7.5. PERF005 — length(string) in while condition
while (i < length(s)) recomputes strlen(s) on every iteration. If s
is not modified in the loop body, this is wasted work. Note that for loops
do not have this problem because for computes its source expression once.
// Bad — strlen every iteration
var i = 0
while (i < length(s)) { // PERF005
i ++
}
// Good — cached length
let slen = length(s)
var i = 0
while (i < slen) {
i ++
}
3.38.7.6. PERF006 — push/emplace in loop without reserve()
Calling push, push_clone, or emplace on an array inside a loop without
a preceding reserve() may trigger repeated reallocations as the array grows.
The rule traces through field access chains (self.items, data.buffer, etc.)
to find the root variable, and distinguishes different field paths — reserve(t.a, N)
does not suppress a warning for t.b |> push(x).
Conditional pushes (inside if/else) and loops with break/continue
are not flagged — the number of items is unpredictable, so reserve would be
guesswork.
// Bad — may realloc each iteration
var result : array<int>
for (i in range(1000)) {
result |> push(i) // PERF006
}
// Good — pre-allocate
var result : array<int>
result |> reserve(1000)
for (i in range(1000)) {
result |> push(i)
}
3.38.7.7. PERF007 — unnecessary string(das_string) in comparison
das_string supports direct comparison with string literals and other
das_string values via == and !=. Wrapping in string() allocates
a new string unnecessarily.
// Bad — unnecessary allocation
if (string(name) == "foo") { ... } // PERF007
// Good — direct comparison
if (name == "foo") { ... }
3.38.7.8. PERF008 — unnecessary get_ptr() for is/as
ExpressionPtr and TypeDeclPtr support is and as
type checks directly. Calling get_ptr() first is unnecessary.
// Bad — get_ptr is redundant
if (get_ptr(expr) is ExprVar) { ... } // PERF008
// Good — direct type check
if (expr is ExprVar) { ... }
3.38.7.9. PERF009 — redundant move-init variable immediately returned
var x <- expr(); return <- x introduces an unnecessary intermediate variable.
The value is moved in and then immediately moved out. Simplify to
return <- expr().
The clone-init flavor — var x := src; return <- x (lowered to
<- clone_to_move(...)) — collapses to return clone_to_move(src), not
return <- src (which would move/destroy the clone source).
// Bad — redundant variable
var inscope result <- make_thing()
return <- result // PERF009
// Good — direct return
return <- make_thing()
// Bad — redundant clone-init
var result := src // PERF009 (clone variant)
return <- result
// Good — clone and move in one
return clone_to_move(src)
3.38.7.10. PERF010 — unnecessary get_ptr() for null comparison
smart_ptr supports == and != against null directly.
Calling get_ptr() first is unnecessary overhead.
// Bad — get_ptr is redundant
if (get_ptr(expr) == null) { ... } // PERF010
// Good — direct comparison
if (expr == null) { ... }
3.38.7.11. PERF011 — unnecessary get_ptr() for field access
smart_ptr auto-dereferences for field access. Calling get_ptr()
first to access a field is unnecessary.
// Bad — get_ptr is redundant
let name = get_ptr(expr).__rtti // PERF011
// Good — direct field access
let name = expr.__rtti
3.38.7.12. PERF012 — string(das_string) passed to strings function
Wrapping a das_string in string() before passing to a function from
the strings module allocates a temporary string unnecessarily. Use
peek(das_string) instead, which provides a zero-allocation read-only
string reference.
// Bad — allocates a temporary string
let pos = find(string(name), "foo") // PERF012
// Good — zero allocation
var pos = -1
peek(name) $(s) {
pos = find(s, "foo")
}
3.38.7.13. PERF013 — a += 1 / a -= 1 should be a++ / a--
a += 1 lowers to a 2-node read-modify-write in interpreted mode. The
postfix ++ / -- collapses to a single SimNode_op1 and reads as
the canonical inc/dec idiom. Applies to the six numeric workhorse scalars
(int, uint, int64, uint64, float, double); vectors
(int2, float3, …) do not support ++/-- so they are
skipped. += -1 is also flagged (same effect as -= 1).
// Bad
a += 1 // PERF013
a -= 1 // PERF013
a += -1 // PERF013
// Good
a ++
a --
3.38.7.14. PERF014 — char-class range check
Hand-rolled char ranges reimplement strings::is_number /
strings::is_alpha. The helpers read clearer and centralise
locale/codepoint behaviour. Only ranges that are exactly equivalent
to a helper are flagged, so the suggested rewrite never changes
behaviour:
c >= '0' && c <= '9'is exactlyis_number(c)(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')is exactlyis_alpha(c)(is_alphais defined as this both-case union)
Both the closed forms above and their De Morgan negations (out-of-range
forms, suggesting !is_number / !is_alpha) are flagged.
Deliberately not flagged:
A single-case range (
c >= 'a' && c <= 'z'on its own) — there is nois_lower/is_upperhelper, so no exact rewrite exists.Hex extras (
'a'..'f'/'A'..'F') —is_hexis broader.An
&&of strict inequalities (c > '0' && c < '9') — an open intersection with different endpoints, distinct from the||strict-inequality complement (c < '0' || c > '9') which is flagged.
// Bad
if (c >= '0' && c <= '9') { ... } // PERF014 (→ is_number)
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { } // PERF014 (→ is_alpha)
if (c < '0' || c > '9') { ... } // PERF014 (negated → !is_number)
// Good
if (is_number(c)) { ... }
if (is_alpha(c)) { ... }
if (c >= 'a' && c <= 'z') { ... } // single case — no exact helper, not flagged
if (!is_number(c)) { ... } // negated form
3.38.7.15. PERF015 — ternary min / max
a < b ? a : b reimplements min(a, b). The math builtins are
vec-friendly and the intent is clearer. All eight orientations of
< / <= / > / >= × T==L,F==R / T==R,F==L are flagged.
// Bad
let smaller = a < b ? a : b // PERF015 — min
let larger = a > b ? a : b // PERF015 — max
// Good
let smaller = min(a, b)
let larger = max(a, b)
3.38.7.16. PERF016 — ternary abs
x < 0 ? -x : x reimplements abs(x). abs exists for every
signed numeric type. Only the four orientations that match abs are
flagged; the negabs shape (x < 0 ? x : -x) is not — it is a
different function.
// Bad
let positive = x < 0 ? -x : x // PERF016
let positive_alt = x > 0 ? x : -x // PERF016
// Good
let positive = abs(x)
3.38.7.17. PERF017 — length(s) == 0 should be empty(s)
For strings, length walks the whole string (strlen); empty
checks one byte. For arrays/tables both are O(1) but empty is the
idiomatic form. Six comparison ops are mapped to either empty(x) or
!empty(x):
length(x) == 0,length(x) <= 0,length(x) < 1→empty(x)length(x) != 0,length(x) > 0,length(x) >= 1→!empty(x)
Vector magnitude (length(float3_var) from the math module) is not
flagged — different semantics, no empty for vectors.
// Bad
if (length(s) == 0) { ... } // PERF017
if (length(arr) > 0) { ... } // PERF017
// Good
if (empty(s)) { ... }
if (!empty(arr)) { ... }
3.38.7.18. PERF018 — for (i in range(length(arr))) should iterate directly
When the loop variable i is used only as arr[i] against the same
array, the index is pure overhead — iterate the array directly.
Detection accepts any of range / urange / range64 / urange64
around length or long_length, peeling at most one cast layer on
either — an ExprCast or a 1-arg workhorse int-cast call — so
range64(long_length(arr)) and urange(uint(length(arr))) both
match. It resolves both the loop’s target and the indexed receiver via
the existing find_expr_path chain walker. Every use of i in the body must be the bare index of
arr[i] against the same path; any arithmetic on i
(arr[i+1] / sliding window) or use of i outside an indexing
expression disqualifies the loop. Bare-variable sibling arrays indexed by
the same i route the loop to PERF029 instead.
// Bad — i used only as arr[i]
for (i in range(length(arr))) { // PERF018
process(arr[i])
}
// Good — direct iteration
for (c in arr) {
process(c)
}
3.38.7.19. PERF029 — parallel-array indexing — zip the arrays
for (i in range(length(X))) walking sibling arrays by subscript hides the
length coupling: one sibling of a diverging length is an out-of-bounds panic
the loop header never shows. The multi-source for iterates every array in
lockstep by construction — no index, no bounds question.
Fires when every use of i is a plain [i] subscript over bare
array-typed variables and at least one of them is not the range source.
The loop header accepts the same spellings as PERF018 —
urange / range64 / urange64 and long_length included.
Tables are excluded (t[i] is a key lookup, not a position); index
arithmetic or i escaping as a value disqualifies, since the zip form
cannot express those. Loops whose i never subscripts the range
source itself also stay silent — there the source is only a bound, and
zipping would change which array limits the walk.
// Bad — xs and ys coupled through i
for (i in range(length(xs))) { // PERF029
ys[i] = xs[i] * 2.0
}
// Good — lockstep by construction
for (x, y in xs, ys) {
y = x * 2.0
}
3.38.7.20. PERF030 — move-assign drops the target’s old contents
a <- b over a heap-carrying bare variable overwrites a without releasing
what it held — a leak on a persistent heap. The force_inscope_pod rewrite
heals the shape (collecting the old contents through builtin_collect_local_and_zero)
and marks every move it generates, so the lint flags exactly the unhealed moves:
policy off, hasUnsafe functions, modules that disallow inscope-pod, or types
whose release is not fully generated (user finalizers — where delete first is
the only correct fix).
Bare-variable targets only: element targets (tab[k] <- v) are dominated by
fresh-slot inserts where nothing leaks. Compiler temps, the return <- r
lowering, and generated moves are excluded — in particular the early-out
relocation that splits var inscope x <- init into a hoisted declaration
plus a generated move (the target is fresh and the finally releases it).
The release credit: a delete of the variable — or of a field chain rooted at
it, the hand-rolled-teardown shape — before the move silences the warning, as
does the lowered form of any of those (_::finalize, builtin`finalize`,
builtin`finalize_dim` for fixed arrays, or a call named finalize).
Credit is per function and per site: it does not cross into or out of deferred
bodies (a lambda’s delete runs later and never credits; an inline block
argument’s counts, though the block may never run — the rule errs silent), and
a conditional fill is best folded into the declaration as var a <- c ? x : y.
// Bad — a's old array is dropped unreleased
a <- make_more() // PERF030
// Good — release first (or enable force_inscope_pod)
delete a
a <- make_more()
3.38.7.21. PERF031 — slice/chop on a loop-invariant string inside a loop
slice(s, i, j) and chop(s, i, n) call strlen on the WHOLE source
string on every call (strings carry no cached length), and each call allocates
a fresh heap string nothing frees. Slicing the same string once per iteration
costs O(length × iterations) — quadratic.
The fix is mechanical. Wrap the loop in peek_data, then pass the view
everywhere the string used to go: slice, chop, find, rfind,
starts_with, ends_with, strip, trim, skip_white_space and
the parse family each
have a byte-view form that takes an array<uint8> — the view peek_data
hands to its block. A view carries its own length, so the strlen happens
once, at peek_data, and the loop is linear. When the window never changes,
hoisting the slice out of the loop works too.
def perf031_bad(text : string) : int {
var total = 0
var i = 0
while (i < 8) {
total += length(slice(text, i, i + 2)) // PERF031 — strlen(text) per iteration
i++
}
return total
}
def perf031_good(text : string) : int {
var total = 0
peek_data(text) $(d : array<uint8> const#) { // one strlen up front
var i = 0
while (i < min(8, length(d))) {
total += length(slice(d, i, i + 2)) // the view carries its length
i++
}
}
return total
}
The receiver must be defined outside the loop to fire — slice(p, 0, 1)
where p is the loop variable stays silent (that cost is bounded by the
element, not an invariant source). Block arguments run inline in the loop, so
the rule deliberately looks inside them (peek_data blocks and friends).
A view receiver never fires: the byte-view forms take their length from the
view, so there is nothing to re-scan.
PERF031 is on everywhere, this repo included: the byte-view sweep rewrote the
in-tree hits onto peek_data views.
3.38.7.22. PERF032 — @exact_size array grown without explicit capacity
@exact_size on an array declaration — a struct field, a global, a local
(var @exact_size buf : array<float>), or a by-ref parameter — declares an
input-scaled buffer: one whose size follows the input (a clip’s frames, an
image’s pixels, a model’s vocabulary) and can cross max_unreserved_size in
a single grow. An unreserved resize rounds capacity up to the next power
of two, and past max_unreserved_size it panics — exactly when a big enough
input arrives, the shape that never shows in small-fixture tests. The annotation is a lint contract, not a runtime flag: on
an @exact_size array every resize / resize_no_init must follow a
reserve or ensure_capacity of the same receiver earlier in the same
function (the scratch one-shots count too). Sizing it through a helper that
reserves internally is transparent — the call is not a resize — and a
helper that takes the buffer by reference marks its own parameter
@exact_size so the resize inside is held to the same contract.
@exact_size on anything that is not an array is reported.
struct EncoderState {
@exact_size x : array<float> // [T x d]: T is the clip length
}
// Bad — 27 minutes of audio put x past the guard: panic
def make_state_bad(var s : EncoderState; tt, d : int64) {
s.x |> resize(tt * d) // PERF032
}
// Good — sized exactly, in one grow
def make_state(var s : EncoderState; tt, d : int64) {
s.x |> reserve(tt * d)
s.x |> resize(tt * d)
}
// Good — a reserving helper; its own parameter carries the contract
def reserve_resize(@exact_size var a : array<float>&; n : int64) {
a |> reserve(n)
a |> resize(n)
}
The order matters — a reserve after the resize does not count. The
receiver is matched by spelling within the function (s.x and s.x), so
a reserve reached through a different alias is not seen; // nolint:PERF032
with the reason is the answer when the capacity is provably established
elsewhere.
3.38.7.23. PERF019 — int(T.a) | int(T.b) on bitfield/enum — collapse to one cast
When T is a type whose values support | directly — bitfields
always do; enums when an operator |(T, T) : T overload exists —
int(T.a) | int(T.b) does two casts where one suffices. Write
int(T.a | T.b) instead: the OR happens on the typed value, then a
single cast lowers to int.
The detection walks both operands of an | ExprOp2, peels one
layer of int(...) (matched as an ExprCall whose
func.name / fromGeneric.name is "int"), and fires when both
inner types are the same bitfield, or the same enum that has an
operator | defined somewhere in the compiling program (probed once
per enum type via program_for_each_module + for_each_function,
result cached on the visitor).
Note on testing: the “canonical” form with two compile-time constants
(int(Mode.read) | int(Mode.write)) is folded by the optimizer to a
single ExprConstInt under normal compile policies, so dastest can’t
observe the rule firing on it. The lint runner sets
no_optimizations = true and no_infer_time_folding = true on
CodeOfPolicies, which preserves the AST and lets the rule fire.
Dastest coverage in utils/lint/tests/perf019_int_cast_collapse.das
uses runtime operands; the constant case is covered by the CI lint gate.
bitfield Mode {
read
write
exec
}
// Bad
var mask = int(Mode.read) | int(Mode.write) // PERF019
def f(m1, m2 : Mode) : int {
return int(m1) | int(m2) // PERF019
}
// Good
var mask = int(Mode.read | Mode.write)
def f(m1, m2 : Mode) : int {
return int(m1 | m2)
}
3.38.7.24. PERF020 — redundant same-type cast
T(x) where x is already of workhorse type T is a no-op. The
cast produces a real ExprCall node — the parser/typer does NOT elide
it — so it costs source noise and one call dispatch for zero work.
Fires for the 15 workhorse cast names: int, int8, int16,
int64, uint, uint8, uint16, uint64, float,
double, string, bitfield, bitfield8, bitfield16,
bitfield64. The match is on the call’s func.name /
fromGeneric.name (so generic instantiations of the cast still
trigger) combined with a strict arg._type.baseType equality check
against the cast’s target type. Const / reference / temporary qualifiers
on the argument are ignored — only baseType matters.
// Bad — a is already int64
def widen(a : int64) : int64 {
return int64(a) // PERF020
}
def field_read(s : SomeStruct) : int64 {
return int64(s.value) // PERF020 — s.value is int64
}
// Good
def widen(a : int64) : int64 {
return a
}
The rule deliberately does NOT cover:
User-named bitfield/enum constructors (
MyBitfield(x),MyEnum(x)). These are parser-synthesized constructors named after the user type, not the bare workhorse name in the table above;MyBitfield(modeVar)does not match.Vector constructors (
int2,float3, …). They primarily take componentwise arguments and are excluded by the single-argument gate.string(das_string)— covered by PERF007 (in comparisons) and PERF012 (passed tostringsfunctions).das_stringhas a distinctbaseType, so it does not collide with this rule.
Cross-type casts (widening, narrowing, signedness change, float ↔ int) are genuine work and do NOT fire.
3.38.7.25. PERF021 — hoist common workhorse cast out of ternary
cond ? T(a) : T(b) where both branches apply the same workhorse
cast T emits two ExprCall nodes that do identical work regardless
of which branch is taken. Hoisting the cast outside the ternary collapses
them to one: T(cond ? a : b).
Uses the same 15-name workhorse cast set as PERF020. The rule fires only when:
Both ternary branches are calls to the same workhorse cast name (after peeling
ExprRef2Value).Both calls share the same target
Type.Both arguments share the same
baseType— so the hoistedT(cond ? a : b)typechecks without an intermediate cast.
If the argument base types differ (e.g. cond ? string(intV) :
string(int64V)), the rule does NOT fire; the rewrite would need a
manual widen on one branch and that is left to the author.
// Bad
def to_str(c : bool; a, b : int) : string {
return c ? string(a) : string(b) // PERF021
}
def widen(c : bool; a, b : int) : int64 {
return c ? int64(a) : int64(b) // PERF021
}
// Good
def to_str(c : bool; a, b : int) : string {
return string(c ? a : b)
}
def widen(c : bool; a, b : int) : int64 {
return int64(c ? a : b)
}
The rewrite is unconditionally safe: the original ternary evaluates
exactly one of a / b, and so does the hoisted form — argument
evaluation count is unchanged. Only the per-branch cast dispatch is
eliminated.
User-named struct / enum / bitfield constructors (MyEnum(x),
Foo(v=x)) and multi-argument vector constructors (float2(x, y))
do not match the workhorse cast set and are intentionally out of scope.
3.38.7.26. PERF022 — for-loop pushing one element per iteration
A loop body that consists of exactly one push(s) or push_clone(s)
of the for-loop iteration variable into a destination array is the
element-at-a-time form of an array concatenation. The bulk overloads
push_from / push_clone_from (in daslib/builtin.das) reserve the
combined capacity up front and skip the per-iteration capacity check.
The rule fires for the iteration-variable shape only — a transform,
if-guard, multi-statement body, or multi-source for does not match,
because those have no direct bulk equivalent.
Compiler folds B |> push(s), B.push(s), and push(B, s) to the
same call shape, so all three forms are detected by the same rule.
// Bad
for (s in src) { // PERF022
dst |> push(s)
}
// Good
dst |> push_from(src)
The bulk forms expect the destination to be array<T> and the source to
be array<T> or a fixed-size C-array T[N]. Range, string, iterator,
and generator sources do not have a bulk overload and are left unflagged.
The same recommendation applies to push_clone:
// Bad
for (s in src) { // PERF022
dst |> push_clone(s)
}
// Good
dst |> push_clone_from(src)
emplace is not in the rule’s scope: a for-loop iteration variable is a
const reference, but emplace requires a mutable reference, so the
hand-rolled shape for (s in src) { dst |> emplace(s) } does not
compile. The emplace_from bulk overload still exists in
daslib/builtin.das for direct calls with a mutable source array.
3.38.7.27. PERF023 — redundant clone_expression before qmacro splice
qmacro, qmacro_block, qmacro_expr, and qmacro_block_to_array
all go through apply_template (daslib/templates_boost.das), which
calls clone_expression on every $e(...) substitution input. Pre-cloning
into a local variable and then splicing the local is wasted work — the same
substitution gets cloned a second time at apply-template time.
// Bad
var defaultExpr = clone_expression(terminatorCall.arguments[1]) // PERF023
preludeStmts |> push <| qmacro_expr() {
let $i(defaultName) = $e(defaultExpr)
}
// Good
preludeStmts |> push <| qmacro_expr() {
let $i(defaultName) = $e(terminatorCall.arguments[1])
}
The rule fires only when every use of the candidate variable lives inside
a $e(...) splice tag — any other use (assignment, passing to a non-splice
consumer, storing into a struct field) means the pre-clone is load-bearing
and the lint stays silent.
Multi-clone cases. When the same source feeds N $e(...) slots in one
qmacro body, the rule still flags every pre-clone. apply_template clones
each substitution independently, so $e(E) repeated N times yields N
independent clones — equivalent to one user-side clone repeated N times via
$e(X):
// Bad — three pre-clones for three splice slots
var takeA = clone_expression(takeExpr) // PERF023
var takeB = clone_expression(takeExpr) // PERF023
var takeC = clone_expression(takeExpr) // PERF023
body = qmacro_block() {
let $i(takeNName) = $e(takeA) <= 0 ? 0 : ($e(takeB) < $i(lenName) ? $e(takeC) : $i(lenName))
}
// Good — inline takeExpr at each splice; apply_template clones each one
body = qmacro_block() {
let $i(takeNName) = $e(takeExpr) <= 0 ? 0 : ($e(takeExpr) < $i(lenName) ? $e(takeExpr) : $i(lenName))
}
For sources with side effects (rare in AST-building code), bind once via plain
let baseE = E (no clone) and splice the local — that preserves single-eval
semantics while still letting apply_template produce N clones.
clone_type is out of scope. Types take a different path through
apply_qrules (the $<TT> tag form emits add_type_ptr_ref, but
clone_type call sites typically feed direct AST construction, not qmacro
splices).
3.38.7.28. PERF024 — redundant pre-clone before [clone]-annotated callee
A function annotated [clone(arg)] clones the named argument internally.
Pre-cloning the value with clone_expression / clone_type /
clone_function / clone_variable / clone_structure before passing
it at that argument position clones twice — drop the pre-clone and pass the
source directly.
Two shapes fire. The direct-splice form wraps the clone right at the call
site (func(clone_*(X))). The var-init form binds the clone to a local
whose only uses are direct arguments at [clone(...)] positions — any other
use (assignment, passing elsewhere, storing into a field) makes the pre-clone
load-bearing and the lint stays silent.
[clone(node)]
def install(var node : ExpressionPtr) { ... }
// Bad — direct splice: callee already clones `node`
install(clone_expression(src)) // PERF024
// Bad — var-init whose only use is the annotated arg
var n = clone_expression(src) // PERF024
install(n)
// Good
install(src)
3.38.7.29. PERF025 — redundant string(...) inside string interpolation
String interpolation already converts every element to a string via the
string builder’s DebugDataWalker. Wrapping an element in string(...)
allocates an intermediate string that the builder then copies — a wasted heap
allocation per interpolation.
// Bad
def f(iv : int) : string {
return "{string(iv)}" // PERF025
}
// Good
def f(iv : int) : string {
return "{iv}"
}
The rule fires only for stringify-equivalent value types — those where
"{x}" and string(x) produce identical text: the signed integer widths
(int / int8 / int16 / int64), float, double,
string, and das_string (e.g. "{string(fn.name)}" where fn.name
is a das_string).
Unsigned integers (uint / uint8 / uint16 / uint64) still warn,
but with an extra note: they interpolate as hex by default
("{42u}" → 0x2a), so dropping the cast changes the output. Use the
:d format tag to keep decimal:
// Bad — string() gives decimal "42"
return "{string(uv)}" // PERF025 + ':d' hint
// Good — same decimal output, no intermediate allocation
return "{uv:d}"
The rule deliberately does NOT fire when dropping string(...) would change
the value shape, not just the numeric format:
string(array<uint8>)reinterprets the bytes as text, whereas"{bytes}"prints the array structure.string(uri)/string(text_range)likewise produce a different representation than direct interpolation.
It also does NOT fire when string(...) is nested as an argument to another
call inside the braces (e.g. "{length(string(x))}") — only direct
interpolation elements are flagged — nor for an explicit hex request
string(x, true).
3.38.7.30. PERF026/027/028 — hot-path contracts
Unlike every other performance rule, these three check nothing until a function declares a contract. They exist for code where an allocation, an environment lookup or a log line is a bug rather than a smell — a decode step, an audio callback, a frame loop.
[hot_path] // all three contracts
def step(var s : Session; pos : int64) { ... }
[no_alloc, no_env, no_io] // or name them individually
def step2(var s : Session) { ... }
[cold_path] // prunes the walk: a one-time init leg
def load_knobs() { ... }
From each annotated root the scan follows direct calls transitively, so a sink several frames deep is still reported, with the call chain in the message and the warning anchored on the line you wrote rather than the daslib internal that actually allocates.
Declaring a contract is free: the five markers are registered by the compiler
as metadata-only annotations, so a file under contract requires nothing. The
checker lives in daslib/perf_lint, which such a file does not require —
verification runs wherever lint runs.
What counts as heap traffic. Every array/table heap operation bottoms out
in a __builtin_array_* / __builtin_table_* extern, so detection matches
that prefix rather than a list of surface names — push / reserve /
resize / erase / insert / delete are all covered, and a newly
added builtin is caught by default. On top of that: new, delete, string
interpolation, lambda capture frames, table indexing (t[k] inserts on
read), and any builtin returning a freshly allocated string.
Declaring a reused buffer. A buffer sized to the current step’s geometry and reused is not an accident, and saying so at the buffer beats a suppression at every call site:
struct Session {
@scratch attq : array<float> // reused per step; sizing it is intentional
logits : array<float> // unmarked: sizing this on a hot path warns
}
// a helper that sizes a caller's buffer marks the PARAMETER, since the
// destination arrives by reference and the call site cannot see the field
def scratch_resize(@scratch var a : array<numT>; need : int64) { ... }
// a clear()-recycled module global is declared the same way (annotation after `var`)
var @scratch g_stage : array<MemRange>
What the scan deliberately ignores. Arguments to panic(...) — a panic is
fatal in daslang, not an exception, so its interpolated message is on the abort
path. Macro-generated subtrees, since a rewritten stub stamps the caller’s line
onto its splice and would otherwise blame every call site for the machinery it
expands into. And indirect calls through a function pointer or lambda, which
cannot be resolved statically — annotate the implementations they reach.
Escape hatches, in order of preference: [cold_path] on the callee when the
leg genuinely runs once; @scratch on a reused destination (field, by-ref
parameter, or module global — sizing calls, table indexing, and reference
bindings to it all count); // nolint
with a reason (honored at either end of a chain, so a suppression written where
the code lives works even when the report anchors elsewhere); and
DAS_LINT_DISABLE=PERF028 for a whole run, which needs no source edit and is
the point when adding a log line to chase a bug.
3.38.8. Style rules
3.38.8.1. STYLE001 — unnecessary <| pipe before block argument
The <| pipe syntax is gen1 style and unnecessary in gen2. Use direct
trailing block syntax instead.
// Bad — gen1 pipe style
build_string() <| $(var w) { // STYLE001
write(w, "hello")
}
// Good — gen2 trailing block
build_string() $(var w) {
write(w, "hello")
}
3.38.8.2. STYLE002 — <| pipe before parameterless block
When the block takes no parameters, both the <| pipe and $() are
unnecessary. Use a direct trailing block.
// Bad — pipe and $() both unnecessary
takes_block() <| $() { // STYLE002
print("done\n")
}
// Good — direct block
takes_block() {
print("done\n")
}
3.38.8.3. STYLE003 — redundant $() on parameterless block
When a block takes no parameters, the $() prefix is unnecessary even
without a pipe. Use a bare trailing block.
// Bad — redundant $()
takes_block() $() { // STYLE003
print("done\n")
}
// Good — bare block
takes_block() {
print("done\n")
}
3.38.8.4. STYLE005 — braces around a single-statement early exit
A single-statement braced if whose body is just a return / break
/ continue is noise. Use either the braceless form if (cond) return X
or postfix return X if (cond). Off by default — opt in with
STYLE005 = true in .lint_config or --enable STYLE005.
The discriminator is AST-only: the parser shares LineInfo between a
synthesized block and its inner terminator for both braceless if (c)
return and postfix-desugared return X if (c), so a real
user-written {...} is detectable as blk.at != inner.at.
// Bad — braces around a single terminator
if (x > 0) { // STYLE005
return x
}
return -x
// Good — braceless
if (x > 0) return x
return -x
// Good — postfix
return x if (x > 0)
return -x
The auto-fixer at utils/fix-lint-errors/ rewrites STYLE005 hits to the
braceless form. Suppress per-line with // nolint:STYLE005.
3.38.8.5. STYLE006 — string(__rtti) comparison should use is
Comparing string(expr.__rtti) == "ExprFoo" is verbose and fragile.
Use the is operator instead, which is type-safe and cleaner.
// Bad — manual RTTI string comparison
if (string(expr.__rtti) == "ExprReturn") { ... } // STYLE006
// Good — is operator
if (expr is ExprReturn) { ... }
3.38.8.6. STYLE010 — if (true) should be a bare block
if (true) is always taken and adds unnecessary noise. Use a bare
block (lexical scope) instead.
// Bad — always true
if (true) { // STYLE010
print("always\n")
}
// Good — bare block
{
print("always\n")
}
3.38.8.7. STYLE011 — variable declaration followed by immediate assignment
A var declaration with no initializer immediately followed by an
assignment to that variable should be combined into a single declaration
with initialization.
The rule excludes var inscope (needs separate declaration for cleanup
semantics), compiler-generated variables, and generic instantiations.
// Bad — split declaration and init
var x : int
x = 5 // STYLE011
// Good — combined
var x = 5
// Bad — clone on next line
var s : string
s := src // STYLE011
// Good — combined
var s := src
3.38.8.8. STYLE012 — array<T> initialized by a run of push/emplace
Declaring an empty array<T> variable immediately followed by two or
more contiguous push or emplace calls that all target the same
variable is a common “I forgot how to initialize an array” pattern. Use
an array literal instead — it is shorter, faster (one allocation with
the right capacity instead of repeated grows), and makes the initial
contents obvious at a glance.
A single push right after the declaration is not flagged,
because the verbose form is often the most readable option for a single
element. push_clone is deliberately excluded — there is no clean
array-literal equivalent.
The rule excludes var inscope, compiler-generated variables, and
generic instantiations (same exclusions as STYLE011).
// Bad — two pushes after empty array declaration
var a : array<int>
a |> push(1) // STYLE012
a |> push(2)
// Good — inferred element type
var a <- [1, 2]
// Good — typed constructor, useful for polymorphic upcasts or
// interface pointers where array literal type inference picks the
// first element's type
var shapes <- array<Shape?>(new Circle(3.0),
new Rectangle(2.0, 5.0),
new Circle(1.0))
// Good — conditional / loop pushes are not flagged
var b : array<int>
for (i in range(10)) {
b |> push(i)
}
3.38.8.9. STYLE013 — struct var then a run of field assignments
A struct var (or a new-allocated struct pointer) given a default /
empty / zero-argument init, immediately followed by two or more contiguous
assignments to its fields, is the long-hand form of a named-argument
constructor. Build it in one expression instead — the warning lists the
exact field names so the rewrite is concrete.
Fires only for the default/empty-init shapes (var a : Foo, var a =
Foo(), var a = new Foo()). A non-empty constructor, a factory call, or
a single field assignment is not flagged. var inscope, compiler-generated
variables, and generic instantiations are excluded.
struct Foo { x : int; y : int }
// Bad — default init then a run of field assignments
var a : Foo // STYLE013
a.x = 1
a.y = 2
// Good — named-argument constructor
var a = Foo(x = 1, y = 2)
3.38.8.10. STYLE014 — comment block exceeds 3 lines at module/public scope
Note
This check is opt-in. Enable it by adding options _comment_hygiene = true
at the top of your file, or pass --comment-hygiene to the standalone utility.
A contiguous run of more than three // or //! comment lines at
module scope or above a public symbol is flagged as
multi-paragraph prose. The convention is “no architectural prose at the
head of a section” — long-form design notes belong in design docs
(.md), not in source.
The block before the file’s first AST decl (the module-leading
docstring, e.g. daslib/regex_boost.das lines 9–18) is always
allowed. Suppress an individual block on its first line:
// Bad — 5 contiguous //! lines on a public function
//! First sentence. // STYLE014
//! Second.
//! Third.
//! Fourth.
//! Fifth.
def foo() { ... }
//!@nolint
//! First sentence — kept verbose intentionally. // suppressed
//! Second.
//! Third.
//! Fourth.
def bar() { ... }
daslib/rst_comment recognises the //!@nolint first line and
strips only that marker line from the emitted doc, so the rest of
the //! block still appears in
doc/source/stdlib/generated/detail/*.rst. The marker’s only job is
to suppress the lint — the rest of the block stays visible. For a
// block (no doc-comment), put // nolint:STYLE014 on the first
line; those blocks never reach the doc generator.
3.38.8.11. STYLE015 — comment block exceeds 1 line inside a def private
Note
This check is opt-in. Enable it by adding options _comment_hygiene = true
at the top of your file, or pass --comment-hygiene to the standalone utility.
Private symbols don’t surface in any doc generator, so multi-line
comment prose inside a def private body is dead weight. Trim to one
line, or suppress with // nolint:STYLE015 on the first line of the
block.
def private bad() {
// First line — explanation // STYLE015
// Second line — fires (>1 line in private)
...
}
def private good() {
// single WHY line — silent
...
}
3.38.8.12. STYLE016 — adjacent guards leading to identical early-exit
Two adjacent if guards with the same exit (return with the same
payload, or break/continue) read as one decision. Combine them
with ||. Two AST shapes are detected:
two adjacent
if (a) { return X }statements in the same blockthe
if (a) { return X } else if (b) { return X }chain
// Bad
if (name == "." || name == "..") { // STYLE016
return
}
if (name |> starts_with("_")) {
return
}
// Good
if (name == "." || name == ".." || name |> starts_with("_")) {
return
}
3.38.8.13. STYLE017 — if (cond) return true; else return false should be return cond
Three lines (or two if-else branches) that just propagate the boolean
condition unchanged. Read better as a single return. Detection covers
both forms:
if (cond) return b1 else return b2(b1 ≠ b2)if (cond) return b1immediately followed byreturn b2(b1 ≠ b2)
// Bad
if (cond) { // STYLE017
return true
} else {
return false
}
// Good
return cond
// Good (negated)
return !cond
3.38.8.14. STYLE018 — redundant boolean comparison
Comparing a bool to a boolean literal is redundant — the bool already IS
the value. Drop the comparison. Both Yoda forms (true == flag) are
detected.
// Bad
if (flag == true) { ... } // STYLE018
if (flag != false) { ... } // STYLE018
if (flag == false) { ... } // STYLE018
if (true == flag) { ... } // STYLE018 (Yoda)
// Good
if (flag) { ... }
if (!flag) { ... }
3.38.8.15. STYLE019 — nested min(max(...)) should be clamp(...)
min(max(x, lo), hi) reads as a clamp; the math builtin says so
directly. Both orientations (and the mirror form) are detected — the
inner call must resolve to the math module’s min / max, not a
user overload.
// Bad
let bounded = min(max(x, lo), hi) // STYLE019
let bounded_alt = max(min(x, hi), lo) // STYLE019 (mirror)
// Good
let bounded = clamp(x, lo, hi)
3.38.8.16. STYLE020 — scalar from_JV should be v ?? defV
daslib/json_boost provides operator ?? overloads for every
supported scalar JsonValue → primitive conversion (int, uint, int8/16/64,
uint8/16/64, float, double, bool, string). The three-arg
from_JV(v, type<T>, defV) form is redundant for scalars — v ?? defV
is one fewer call, reads better, and uses the operator that’s already
there. Vector / table / struct / enum / bitfield overloads have no
matching ?? and stay silent.
Detection walks expr.func.fromGeneric to the root of the template-
instantiation chain (two levels deep for json_boost’s
[template(ent)] generics) and matches the root’s name/module against
from_JV / json_boost. The result-type check uses expr._type,
which is robust under both pre- and post-instantiation argument shapes.
// Bad
let n = from_JV(jv, type<int>, 13) // STYLE020
let s = from_JV(jv, type<string>, "x") // STYLE020
// Good
let n = jv ?? 13
let s = jv ?? "x"
3.38.8.17. STYLE021 — repeated table<string; JsonValue?> inserts → named-tuple JV
Building a JSON object by declaring an empty
var args : table<string; JsonValue?> followed by args |> insert("k",
JV(v)) calls is verbose. The named-tuple JV overload
(daslib/json_boost.das:638) builds the same object in one line.
Detection requires the variable’s static type to be exactly
table<string; JsonValue?>, zero initial value, and a contiguous run
of ≥ 2 insert calls whose key is an ExprConstString and whose
receiver resolves to the same variable. Computed keys disqualify the
chain — such runs fall through to STYLE031 instead
(a table literal accepts computed keys, JV((...)) does not).
// Bad
var args : table<string; JsonValue?>
args |> insert("target", JV(target)) // STYLE021
args |> insert("dx", JV(dx))
args |> insert("dy", JV(dy))
// Good
var args = JV((target = target, dx = dx, dy = dy))
3.38.8.18. STYLE022 — bitfield |= / &= ~ single bit → field assignment
When foo is a bitfield value, foo |= BfT.m and foo &= ~BfT.m
are the mask-arithmetic forms of setting and clearing one named bit.
daslang exposes the same operation as a bool field assignment:
foo.m = true and foo.m = false. The field form names the bit
instead of the mask and drops the ~ for clears.
The rule fires only when the right-hand side resolves to exactly one
named bit. Under lint policies the RHS appears as ExprField
(ExprField(value = ExprVar(BfT), name = "m")); under normal compile
policies the optimizer folds it to ExprConstBitfield — the rule
covers both shapes and, for the folded case, maps the single-bit mask
back to the symbolic bit name via TypeDecl.argNames. Multi-bit
masks (Mode.read | Mode.write) and dynamic RHS are left alone.
The &= form requires the source pattern foo &= ~BfT.m — either
an explicit ExprOp1("~", ExprField) or a single-bit-complement
ExprConstBitfield. A bare foo &= BfT.m (no ~) is not the
bit-clear idiom; it would mask off every other bit, so it stays silent.
bitfield Mode {
read
write
exec
}
// Bad
var f : Mode
f |= Mode.read // STYLE022 → f.read = true
f &= ~Mode.write // STYLE022 → f.write = false
// Good
var f : Mode
f.read = true
f.write = false
3.38.8.19. STYLE023 — int_cast(bf & BfT.m) != 0 → bf.m
Testing a single bit via uint(bf & BfT.m) != 0u (or any of the
int / uint / int64 / uint64 cast forms compared to
0) is the mask-arithmetic counterpart of the boolean field access
daslang exposes on bitfields. bf.m already evaluates to a bool —
read it directly. == 0 becomes !bf.m.
Detection matches both operand orders (cast(...) !=/== 0 and
0 !=/== cast(...)) and accepts all four standard int casts
(int, uint, int64, uint64). The inner expression must be
bitfield & SingleBit where SingleBit resolves to a named bit of
the same bitfield — under lint policies as ExprField(BfT, name),
under normal compile as ExprConstBitfield with a single-bit mask.
Multi-bit masks (Mode.read | Mode.write) are left alone since the
!= 0 semantics differ from any single field read.
bitfield Mode {
read
write
exec
}
struct Io { flags : Mode }
// Bad
if (uint(io.flags & Mode.read) != 0u) { ... } // STYLE023 → io.flags.read
if (int(io.flags & Mode.write) == 0) { ... } // STYLE023 → !io.flags.write
// Good
if (io.flags.read) { ... }
if (!io.flags.write) { ... }
3.38.8.20. STYLE024 — redundant unsafe wrap
An unsafe(...) expression or unsafe { ... } block whose body contains
no operation that actually requires unsafe is pure noise. Drop the wrap.
The check walks the wrapped subtree for inherently-unsafe leaves
(reinterpret / upcast casts, delete, addr, table indexing, variant
writes, calls flagged unsafeOperation). When none are present, the wrap
is flagged. Macro-generated subtrees are skipped by design.
// Bad — nothing inside needs unsafe
let d = unsafe(x + y) // STYLE024
// Good
let d = x + y
3.38.8.21. STYLE025 — block-form unsafe should be expression-form
When an unsafe { ... } block contains exactly one statement that needs
unsafe, the block scope is too broad. Narrow it to the expression form
unsafe(<sub-expr>) wrapping just the operation that requires it. When two
or more statements need unsafe the block is justified and stays silent.
// Bad — only the reinterpret needs unsafe
unsafe { // STYLE025
let a = compute()
let p = reinterpret<Foo?>(raw)
}
// Good
let a = compute()
let p = unsafe(reinterpret<Foo?>(raw))
3.38.8.22. STYLE026 — nested unsafe block
An unsafe { ... } block directly inside another unsafe scope is
redundant — the outer wrap already covers it. Drop the inner block. Closure,
lambda, and generator bodies are not “nested” for this rule: they execute in
a separate context the outer wrap does not reach.
// Bad — inner unsafe is already covered
unsafe {
foo()
unsafe { // STYLE026
delete p
}
}
// Good
unsafe {
foo()
delete p
}
3.38.8.23. STYLE027 — array/table var + push/insert loop → comprehension
A var of type array<T> or table<K; V> with an empty default init,
immediately followed by a for loop whose body only push-es (array) or
insert-s / index-assigns (table) into it, is the imperative form of a
comprehension. Rewrite it.
The loop body must consist solely of pushes / inserts / at-assigns targeting
the variable (nested for / if filters allowed up to the rule’s
budget). var inscope, compiler-generated variables, and generic
instantiations are excluded.
// Bad — empty var then a push-only loop
var a : array<int> // STYLE027
for (x in src) {
a |> push(x * x)
}
// Good — comprehension
var a <- [for (x in src); x * x]
3.38.8.24. STYLE028 — redundant self-> on a method call
Inside a class method, self->method(...) lowers to the same invoke the
compiler produces for a bare method(...) call. Drop self-> and call
the method directly (or write self.method(...) if you prefer an explicit
receiver).
Source inspection confirms the literal self-> spelling before flagging —
the post-inference AST cannot distinguish self->m(), self.m(), and
bare m().
class Widget {
def draw() {
self->layout() // STYLE028
}
def layout() { ... }
}
// Good
class Widget {
def draw() {
layout()
}
def layout() { ... }
}
3.38.8.25. STYLE029 — transitive-only require
A non-public require X whose only referenced symbols come from modules
that X re-exports (require ... public) — none from X itself — is
an indirect dependency. Require those modules directly and drop X. Skipped
when X provides macros or an [init] (requiring it has a side effect
beyond symbol visibility).
// Bad — only Y's symbols are used; X just re-exports Y
require X // STYLE029
// ... uses only symbols from Y (which X re-exports)
// Good — depend on Y directly
require Y
3.38.8.26. STYLE030 — entirely-unused require
A non-public require X where no symbol from X (or any module it
re-exports) is referenced anywhere in the file. Remove it. Skipped when X
provides any macro or an [init], or only re-exports builtins used through
it. Suppress a deliberate keep with // nolint:STYLE030.
// Bad — nothing from strings is used
require strings // STYLE030
// Good — remove the require
3.38.8.27. STYLE031 — table var initialized by a run of insert / []=
Declaring an empty table<K;V> (or a table<K> set) and populating
it with ≥ 2 contiguous t |> insert(k, v) calls or t[k] = v
assignments is the table counterpart of STYLE012. A table (set) literal
move-assign expresses the same construction in one statement.
Detection anchors on the uninitialized table declaration and accepts a
mixed run of exact-arity inserts (3-arg map form, 2-arg set form) and
t[k] = v at-assigns (map only). Computed keys are fine — a
runtime-duplicate key in a literal is last-wins, identical to sequential
inserts. A run containing a duplicate constant key stays silent:
sequential inserts overwrite, but a literal rejects duplicate constant
keys at compile time (error 30706), so the rewrite would not compile.
table<string; JsonValue?> runs with constant keys are owned by
STYLE021 (the JV((k1=..., k2=...)) form is the stronger suggestion).
// Bad
var t : table<string; int>
t |> insert("a", 1) // STYLE031
t["b"] = 2
var s : table<int>
s |> insert(5) // STYLE031 (set flavor)
s |> insert(7)
// Good
var t <- { "a" => 1, "b" => 2 }
var s <- { 5, 7 }
3.38.8.28. STYLE032 — array var filled by a single push_from is a clone
An empty var w : array<T> immediately followed by a single
w |> push_from(src) (or push_clone_from(src)) where src is itself
an array<T> is just a verbose clone of src. := clones the whole
array (each element, for push_clone_from) in one step:
// Bad
var w : array<uint>
w |> push_from(src) // STYLE032
return <- w
// Good
var w := src
return <- w
// Even better, when w is immediately returned (see PERF009 clone variant)
return clone_to_move(src)
Only the immediately-following statement is inspected, and a C-array
(fixed-array) source stays silent — var w := cArray would not yield an
array<T>. A nested source whose element type does not match — e.g.
array<array<T>> into a flat array<T> — binds the recursive-flatten
push_from overload (a flatten, not a clone), so it stays silent too: the
match requires the source’s element type to equal the destination’s. An
intervening reserve / guard, a non-empty initializer, or a single-element
push all keep the rule quiet.
3.38.8.29. STYLE033 — array filled by a run of push_from / push_clone_from
A run of two or more push_from(src) / push_clone_from(src) calls
(each src an array<T>) is the N ≥ 2 generalization of STYLE032. Into a
fresh-empty array the run is a concatenation — concat expresses it as one
expression (concat lives in daslib/linq, so the rewrite needs
require daslib/linq). Into an already-live array the run collapses to a
single variadic call:
require daslib/linq
// Bad — fresh-empty target (a concatenation)
var c : array<int>
c |> push_clone_from(a) // STYLE033
c |> push_clone_from(b)
return <- c
// Good
var c <- concat(a, b)
return <- c
// Bad — existing target (collapse the run)
dst |> push_clone_from(a) // STYLE033
dst |> push_clone_from(b)
// Good
dst |> push_clone_from(a, b)
A single-source run stays STYLE032 (var w := src). The
suggested shapes have arity limits — concat takes up to 8 sources and the
variadic push_from / push_clone_from overloads up to 4 — so a longer run
has no single-call form and stays silent. C-array sources, and nested sources
that flatten (array<array<T>> into a flat array<T> — the recursive-flatten
overload, not a copy), stay silent, the same as STYLE032 (the source’s element
type must equal the destination’s).
The existing-array collapse matches a plain-variable receiver (dst |> …)
only; a chain receiver (obj.arr |> …) is out of scope for now — a planned
follow-up, mirroring STYLE012’s separate chain form.
3.38.8.30. STYLE034 — reinterpret<T?>(addr(x)) collapses to addr<T?>(x)
addr<T?>(x) is pure sugar for reinterpret<T?>(addr(x)), with one
unsafe() covering both halves — the spelled-out form needs two gates:
// Bad — two unsafe gates for one operation
let p = unsafe(reinterpret<int?>(unsafe(addr(f)))) // STYLE034
// Good
let p = unsafe(addr<int?>(f))
Pointer targets only: a pointer→integer pun such as
reinterpret<uint64>(addr(x)) has no addr<T?> spelling and stays
silent. So does a reinterpret whose operand is not an addr(...). The
sugar’s own desugared output is exempt (it carries the fromAddrSugar
cast flag), so addr<T?>(x) never re-flags itself.
3.38.8.31. STYLE035 — numeric variable compared with a cast character literal
Character literals are int. A non-int numeric variable compared with a
built-in cast of one — b == uint8('('), c == uint('\n') — pays a cast
at every compare to work around the variable’s own declaration. Declare the
variable int and compare with the literal directly.
// Bad
var b : uint8 = bytes[0]
if (b == uint8('(')) { print("paren\n") }
// Good
let c : int = int(bytes[0])
if (c == '(') { print("paren\n") }
The rule looks through the cast to a plain variable read of the cast’s type; fields, indexes, and call results are not reported — the fix changes the variable’s declaration, and there is no declaration to change for those.
3.38.8.32. STYLE036 — inert type contract on a cast target
-const, -&, -[], -#, ==const and ==& are substitution
contracts. They do work only while a generic binds, and type inference clears
them once it consumes them. A cast target that is already concrete has nothing
to consume the contract, so it does nothing at all — void? is void?
regardless of -const.
// Bad — the -const strips nothing
let p = unsafe(addr<void? -const>(x))
// Good
let p = unsafe(addr<void?>(x))
Because inference clears a contract it actually consumed, a flag still set at lint time is itself the proof that the contract was inert — the rule is exact rather than heuristic.
The one exclusion is an auto or still-unresolved alias target, where
substitution has not happened yet: reinterpret<ARGT -const> inside a
generic really does strip const from whatever ARGT binds. A concrete
typedef is not such a case — with typedef CI = int const,
reinterpret<CI? -const> keeps the const, so the contract is inert there
too and the rule correctly fires.
3.38.8.33. STYLE037 — cyclomatic complexity over the limit
A function with too many independent decision points is hard to read, test, and modify. The score starts at 1 per function (or block-argument closure) and adds 1 for each of:
if/elifbranch (including the postfix formreturn X if (cond))forandwhileloopternary
?:try/recovermatcharmcomprehension loop and its
wherefilter
Not counted: && / || short-circuit operators, the null-safe chain
operators ?? / ?. / ?[ / ?as, static_if branches, and
macro-generated control flow.
// Bad — one function absorbing every case
def classify(x : int) : int { // STYLE037 when the total passes the limit
if (x == 1) return 10
if (x == 2) return 20
// ... dozens more decision points ...
}
// Good — split into focused helpers, or table the data
let CLASS_OF <- { 1 => 10, 2 => 20 }
A block-argument closure gets its own score and its own warning (at the closure), so a complex block does not bill its host; extract it into a named function. Lambda and generator bodies are lowered to generated functions before the lint pass runs and are not checked — the same pre-existing rule that keeps every style rule out of macro-generated functions.
Override the limit per module with options _cyclomatic_complexity = N;
N = 0 disables the rule for the module. Suppress a single deliberate keep
with // nolint:STYLE037 on the def line.
3.38.8.34. STYLE038 — function longer than the line limit
An overgrown function is hard to navigate, review, and test even when its
branching stays simple — a straight-line emitter or a giant literal table has a
low cyclomatic complexity and is still unreadable. STYLE038 is the length half
of that pair: it measures physical lines, from the def line through the
body’s closing brace, counting comments and blank lines.
The default limit is 80 lines, chosen from this codebase’s own distribution:
across ~34,000 functions the median is 7 lines, p90 is 31 and p95 is 52, so 80
sits near p97 and flags roughly the worst 2%. For reference, other linters
default to 50 (ESLint max-lines-per-function, SwiftLint warning), 60
(golangci-lint funlen, detekt LongMethod) and 150 (Checkstyle
MethodLength).
// Bad — one function carrying a hundred lines of straight-line work
def emit_everything(var w : StringBuilderWriter) { // STYLE038
// ... 100+ lines ...
}
// Good — one function per emitted section
def emit_everything(var w : StringBuilderWriter) {
emit_header(w)
emit_body(w)
emit_footer(w)
}
The rule checks functions only. A closure’s physical span always sits inside its host function’s, so the host trips first and a separate closure check could only ever double-report the same lines.
Override the limit per module with options _function_length = N; N = 0
disables the rule for the module — the right escape for a legitimately dense
file such as a code emitter or a ported kernel. Suppress a single deliberate
keep with // nolint:STYLE038 on the def line.
3.38.8.35. STYLE039 — non-ASCII byte in a string literal
Emitted strings and error messages flow into logs and consoles that are not
UTF-8 aware, where an em-dash or a multiplication sign turns into mojibake.
The rule reports the first byte at or above 0x80 in a string literal —
interpolation chunks included — unless the source spells it with ASCII escapes
("\xEF\xBB\xBF" is already log-safe). Lambda, local-function, and generator
bodies are checked like any other user code.
// Bad — typography in an emitted string
def report_bad() : string {
return "shader failed — see log" // STYLE039 (em-dash)
}
// Good — plain ASCII
def report_good() : string {
return "shader failed - see log"
}
The gate is tri-state: a module-local options _ascii_strings wins in either
direction; else STYLE039 = true in .lint_config turns it on everywhere;
else the default — on only for sources under daslib/ or modules/, the
shipped-library trees whose strings reach every consumer’s logs. Suppress an
intended character (box drawing, localized text, a unicode demo) with
// nolint:STYLE039 on the line, or options _ascii_strings = false for a
whole intentionally non-ASCII module.
3.38.8.36. STYLE040 — duplicated statement region a helper could absorb
A run of statements that appears verbatim somewhere else in the same module, where a helper function could absorb it as-is. Variable names may differ — structure, types, called functions and literal values may not.
// Bad — the same four statements twice, only the names differ
def alpha(var acc : array<int>; base : int) { // STYLE040
acc |> push(base * 2)
acc |> push(base + 7)
acc |> push(base - 1)
acc |> push(base * base)
}
def beta(var out : array<int>; seed : int) {
out |> push(seed * 2)
out |> push(seed + 7)
out |> push(seed - 1)
out |> push(seed * seed)
}
// Good — one helper, two calls
def spread(var dst : array<int>; n : int) {
dst |> push(n * 2)
dst |> push(n + 7)
dst |> push(n - 1)
dst |> push(n * n)
}
Detection is the classic AST clone algorithm: a Merkle hash per node picks candidate pairs, and each pair is then confirmed by an exact lockstep walk that compares node class, payload text and type text under a variable bijection — so a hash collision can never produce a finding, and an AST class the engine does not know is treated as opaque and never matches.
The rule fires only when the extraction is mechanically valid. It stays silent
when the region declares a local that is read after it (that needs a return
value, a different refactor), when a return / break / continue /
goto would escape the region, when it contains an assume alias the rest
of the block consumes, when a var inscope inside it would have its
finalization moved, when a written free variable is not a ref or ref type (the
write would not survive the call), and when any statement performs an operation
whose unsafe authorization comes from an enclosing scope — a reinterpret
or upcast cast, a delete, an addr, an unsafe deref, or a call the
compiler marks unsafe — because the same statements inside a plain helper fail
to compile. The message lists the suggested parameter list, derived from the
region’s free variables and spelled without alias decoration so it can be
pasted straight into a def.
The rule is on by default under ``daslib/`` and ``utils/`` — the trees this
repo keeps free of duplicated regions — and off elsewhere, so a PR touching an
unrelated file never inherits findings it did not create. Override per module
with options _duplicate_regions = true (or false), or everywhere with
STYLE040 = true in .lint_config. It walks and hashes the whole module
once per compile, which measures under 1% of a lint pass even on the largest
modules in this tree. Tune the thresholds per module with options _dupe_min_nodes = N
(default 20 AST nodes) and options _dupe_min_statements = N (default 2);
0 on either leaves the default in place. Suppress a deliberate repetition
with // nolint:STYLE040.
3.38.8.37. STYLE041 — a bool flag set-then-returned is a return value in disguise
A var flag = false set true on some paths, then consumed by a single
if (flag) return X right after the statement that set it, is a return
value with extra steps — and the loop often keeps running after the answer is
known. Return directly at each set site and drop the flag.
// Bad — the flag carries the answer to the next statement
def first_bad(xs : array<int>) : int {
var found = false // STYLE041 (reported here)
for (x in xs) {
if (x < 0) {
found = true
break
}
}
if (found) return -1
return 0
}
// Good — return the answer where it is known
def first_good(xs : array<int>) : int {
for (x in xs) {
if (x < 0) return -1
}
return 0
}
Extra reads are allowed only as if (flag) break / continue plumbing
inside the setting statement — those die with the flag. Everything else is
exempt by design, and the check fails closed: a set inside a $(...)
callback (the walk-abort idiom — a flag is the only way to get a value out of
a block-callback walk; guard the callback’s first line on it instead), negative
polarity (if (!found) search-miss, whose fix is extraction), a set that is
not the last action on its path, a consuming statement that does not
immediately follow the setter, a return payload not provably identical at the
set site, and any reference the analysis cannot classify — a capture, a
flag && other read, an argument pass — all keep the rule silent.
Init-true separator flags never match. Suppress a deliberate keep with
// nolint:STYLE041 on the declaration line.
3.38.9. Tests
Lint tests are in utils/lint/tests/:
bin/Release/daslang.exe dastest/dastest.das -- --test utils/lint/tests
See also
daslib/lint.das (paranoid lint source),
daslib/perf_lint.das (performance lint source),
daslib/style_lint.das (style lint source),
daslib/dupe_detect.das (STYLE040 duplicate-region engine),
utils/lint/main.das (unified standalone utility)