Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ TYPECHECK_SRCS = $(wildcard $(SRC_DIR)/backends/compiler/type_*.c)
# Borrow/ownership checker — shared so the LSP can surface its diagnostics
BORROWCHECK_SRCS = $(SRC_DIR)/backends/compiler/borrow_check.c

# Static lint / diagnostics pass (compiler-only)
LINTCHECK_SRCS = $(SRC_DIR)/backends/compiler/lint.c

COMMON_SRCS = $(FRONTEND_SRCS) $(MODULES_SRCS) $(TYPECHECK_SRCS) $(BORROWCHECK_SRCS) $(SHARED_SRCS)
SRCS = $(COMMON_SRCS) $(TOOL_SRCS) $(INTERP_SRCS)

Expand Down Expand Up @@ -578,6 +581,7 @@ analyze-clean:
COMPILER_SRCS = $(SRC_DIR)/backends/compiler/main.c \
$(wildcard $(SRC_DIR)/backends/compiler/codegen*.c) \
$(BORROWCHECK_SRCS) \
$(LINTCHECK_SRCS) \
$(TYPECHECK_SRCS) \
$(SHARED_SRCS) \
$(FRONTEND_COMPILER_SRCS)
Expand Down Expand Up @@ -866,6 +870,11 @@ test-compiler: compiler
test-borrow: compiler
@bash tests/borrow/run_borrow_tests.sh

# Run static lint / diagnostics tests
.PHONY: test-lint
test-lint: compiler
@bash tests/lint/run_lint_tests.sh

# Check that interpreter tests compile (does not check output parity)
.PHONY: compile-check
compile-check: compiler
Expand Down Expand Up @@ -903,7 +912,7 @@ test-memory: compiler

# Run all test suites
.PHONY: test-all
test-all: test test-compiler test-borrow parity test-contracts test-bundler test-lsp test-memory test-formatter test-cli
test-all: test test-compiler test-borrow test-lint parity test-contracts test-bundler test-lsp test-memory test-formatter test-cli

# ========== RELEASE BUILD ==========

Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Welcome to the Hemlock programming language documentation!
- [File I/O](advanced/file-io.md) - File operations and resource management
- [Memory Ownership](advanced/memory-ownership.md) - Memory ownership semantics and lifetime management
- [Borrow Checker](advanced/borrow-checker.md) - Rust-like ownership analysis (use-after-free, double-free, leaks)
- [Static Lint](advanced/lint.md) - Catch unreachable code, dead branches, self-assignment, and more before shipping
- [Signal Handling](advanced/signals.md) - POSIX signal handling
- [Command-Line Arguments](advanced/command-line-args.md) - Access program arguments
- [Command Execution](advanced/command-execution.md) - Execute shell commands
Expand Down
126 changes: 126 additions & 0 deletions docs/advanced/lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Static Lint (Diagnostics)

The Hemlock compiler (`hemlockc`) ships a **static lint pass** — a small
analysis that flags code which is well-typed and memory-safe but almost
certainly a mistake. Where the [type checker](../language-guide/type-system.md)
proves values fit their types and the [borrow checker](borrow-checker.md) tracks
resource ownership, the linter catches the everyday slip-ups you want to find
*before you ship a binary*: unreachable code, dead branches, self-assignment,
modulo-by-zero, and unused variables.

Like the borrow checker, it is **advisory by design.** It emits **warnings**,
not errors, and never changes what your program does. You can make it strict,
make it fatal, or turn it off.

```
Source (.hml)
Parse → AST
Resolve
Lint ← this pass (warnings by default, sees your code as written)
Optimize → Type Check → Borrow Check
C Code Generation
```

The lint pass deliberately runs **before** the optimizer, so it diagnoses the
source you actually wrote rather than what is left after dead-code elimination.
Only your program's own top-level module is analysed; imported modules are
linted when *they* are compiled, so a clean build never buries you in warnings
about library code you did not write.

---

## What it catches

| Diagnostic | When | Default |
|------------|------|---------|
| **unreachable code** | a statement follows one that always diverges (`return`/`break`/`continue`/`throw`/`panic()`, an `if`/`else` whose arms all diverge, or an infinite `loop`/`while (true)` with no `break` bound to it) | on |
| **dead branch** | an `if`/`while` condition is a constant (`if (false)`, `while (0)`) | on |
| **redundant branch** | an `if (true) { … } else { … }` whose `else` can never run | on |
| **self-assignment** | `x = x`, `obj.f = obj.f`, or `a[i] = a[i]` — a no-op | on |
| **self-comparison** | `x < x` / `x > x` (always false), `x && x` / `x \|\| x` (redundant) | on |
| **modulo by zero** | `x % 0` with a literal zero divisor — traps at runtime | on |
| **duplicate field** | `{ a: 1, a: 2 }` — the later value silently wins | on |
| **duplicate case** | a repeated literal `switch` case — the second is unreachable | on |
| **unused variable** | a `let`/`const` that is never read in its scope | strict only |

Every diagnostic is something the compiler is **certain** about. The analysis is
intentionally conservative — for example, self-assignment is only reported when
both sides are side-effect-free, so `a[next()] = a[next()]` is left alone, and
`while (true)` (a deliberate idiom) is never flagged. Self-comparison exempts
`==`, `!=`, `<=`, and `>=` entirely: for floats every comparison with NaN is
false, so `x == x` is *not* always true and `x != x` is the canonical NaN
check — only `<` and `>` (false even for NaN) are reported. There are no false
positives to silence.

### Examples

```hemlock
fn classify(n: i32): string {
if (n > 0) {
return "positive";
print("never runs"); // warning: unreachable code: control always
} // leaves via the return on line 3
return "other";
}

fn dead(): i32 {
if (false) { // warning: condition is always false; this
return 1; // branch never executes
}
let x = 10;
x = x; // warning: self-assignment: 'x = x' has no effect
return x % 0; // warning: modulo by zero: traps at runtime
}
```

With `--lint-strict`, unused locals are flagged too. Names beginning with `_`
and function bindings are exempt by convention:

```hemlock
fn f(): i32 {
let used = 1;
let unused = 2; // warning: variable 'unused' is declared but never used
let _scratch = 3; // exempt: leading underscore
return used;
}
```

---

## Flags

| Flag | Effect |
|------|--------|
| *(default)* | Lint on; advisory warnings, build still succeeds |
| `--lint-strict` | Also flag unused variables |
| `--lint-error` | Treat every lint finding as an error and **fail the build** |
| `--no-lint` | Disable the lint pass entirely |

These compose with `--check`, which runs the static analyses (type, borrow,
lint) and stops before code generation:

```sh
hemlockc --check --lint-strict app.hml # report everything, compile nothing
hemlockc --lint-error app.hml -o app # refuse to build if anything is flagged
```

Use `--lint-error` in CI to keep unreachable code, dead branches, and
self-assignments out of a release binary, while leaving the default advisory
behaviour for everyday local builds.

---

## Why warnings, not errors

This mirrors Hemlock's stance everywhere else: *explicit over implicit, and the
programmer keeps control.* A dead branch or a `% 0` might be a placeholder you
are actively working on; the compiler points it out but does not stop you. When
you are ready to enforce a clean bill of health, opt in with `--lint-error`.

See also: [Borrow Checker](borrow-checker.md) ·
[Compiler Optimizations](compiler-optimizations.md)
83 changes: 83 additions & 0 deletions include/compiler/lint.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Hemlock Compiler - Static Lint / Diagnostics Pass
*
* An opt-out static-analysis pass that runs after type checking and borrow
* checking and before code generation. Where the type checker proves that
* values fit their declared types and the borrow checker tracks resource
* ownership, the lint pass flags code that is well-typed and memory-safe but
* almost certainly a mistake — the kind of thing you want caught before you
* ship a binary:
*
* - unreachable code (statements after return/break/continue/throw/panic,
* after an if/else where both arms always diverge, or
* after an infinite loop with no break bound to it)
* - dead branches (`if`/`while` conditions that are constant)
* - self-assignment (`x = x`, `obj.f = obj.f`, `a[i] = a[i]`)
* - self-comparison (`x < x`, `x > x`, `x && x`, `x || x` — always
* false / redundant; ==//!=/<=/>= are exempt since
* NaN makes them type-dependent)
* - modulo by zero (`x % 0` with a literal zero — traps at runtime)
* - duplicate fields (`{ a: 1, a: 2 }` — the later value silently wins)
* - duplicate cases (repeated literal switch case — unreachable)
* - unused variables (strict mode: a let/const that is never read)
*
* Only the program's own top-level module is analysed; imported modules are
* linted when they are compiled in their own right, so a clean build never
* drowns the user in warnings about library code they did not write.
*
* In keeping with Hemlock's "unsafe is a feature, explicit over implicit"
* philosophy these are reported as advisory warnings and do not fail the
* build unless --lint-error is passed. The analysis is intentionally
* conservative: every diagnostic is something the compiler is confident about,
* so there are no false positives to silence.
*/

#ifndef HEMLOCK_LINT_H
#define HEMLOCK_LINT_H

#include "frontend/ast.h"

/* A single diagnostic. Mirrors BorrowDiag/TypeCheckError so the LSP can
* consume all three uniformly. */
typedef struct LintDiag {
int line;
int column;
int end_column;
char *message; /* owned; freed by lint_free */
int is_error; /* 0 = warning, 1 = error (lint-error mode) */
struct LintDiag *next;
} LintDiag;

/* Configuration / result handle for one analysis run. */
typedef struct LintContext {
/* Configuration */
const char *filename;
const char *source; /* optional, for future column resolution */
int strict; /* enable the noisier checks (unused variables) */
int errors_are_fatal; /* promote warnings to errors (affects exit code) */
int collect; /* collect diagnostics instead of printing to stderr */

/* Internal: line of the statement under analysis, used as a fallback when
* an expression node carries no source line of its own. */
int cur_line;

/* Results */
int warning_count;
int error_count;
LintDiag *diags;
LintDiag *diags_tail;
} LintContext;

/* Create / destroy an analysis context. */
LintContext *lint_new(const char *filename);
void lint_free(LintContext *ctx);

/* Collect diagnostics instead of printing them (for LSP integration). */
void lint_enable_collection(LintContext *ctx, const char *source);

/* Run the analysis over a whole program. Returns the number of *errors*
* (0 unless errors_are_fatal is set and problems were found). Warnings do
* not contribute to the return value. */
int lint_program(LintContext *ctx, Stmt **stmts, int stmt_count);

#endif /* HEMLOCK_LINT_H */
Loading
Loading