Documentation
¶
Overview ¶
Package lint provides static analysis for ELPS lisp source files.
The linter is modeled after go vet: each check is an independent Analyzer that receives a parsed AST and reports diagnostics. The framework handles parsing, running analyzers, collecting results, and formatting output.
Analyzers are composable and extensible — embedders can define custom checks alongside the built-in set.
Index ¶
- Variables
- func AnalyzerDoc() string
- func AnalyzerNames() []string
- func ArgCount(sexpr *lisp.LVal) int
- func FormatJSON(w io.Writer, diags []Diagnostic) error
- func FormatText(w io.Writer, diags []Diagnostic)
- func HeadSymbol(sexpr *lisp.LVal) string
- func SourceOf(v *lisp.LVal) *lisp.LVal
- func UserDefined(exprs []*lisp.LVal) map[string]bool
- func Walk(exprs []*lisp.LVal, fn func(node *lisp.LVal, parent *lisp.LVal, depth int))
- func WalkSExprs(exprs []*lisp.LVal, fn func(sexpr *lisp.LVal, depth int))
- type Analyzer
- type Diagnostic
- type Linter
- type Pass
- type Position
Constants ¶
This section is empty.
Variables ¶
var AnalyzerBuiltinArity = &Analyzer{ Name: "builtin-arity", Doc: "Check argument counts for calls to known builtin functions and special forms.\n\nELPS builtin functions have well-defined argument signatures. This check catches calls with too few or too many arguments before runtime. User-defined functions that shadow builtin names are automatically excluded. Formals lists and threading macro children are also excluded.", Run: func(pass *Pass) error { userDefs := UserDefined(pass.Exprs) skipNodes := aritySkipNodes(pass.Exprs) WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if skipNodes[sexpr] { return } head := HeadSymbol(sexpr) if head == "" { return } if userDefs[head] { return } spec, ok := builtinArityTable[head] if !ok { return } argc := ArgCount(sexpr) helpNote := fmt.Sprintf("see (help '%s) or `elps doc %s` for usage", head, head) if argc < spec.min { src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: fmt.Sprintf("%s requires at least %d argument(s), got %d", head, spec.min, argc), Pos: posFromSource(src.Source), Notes: []string{helpNote}, }) } if spec.max >= 0 && argc > spec.max { src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: fmt.Sprintf("%s accepts at most %d argument(s), got %d", head, spec.max, argc), Pos: posFromSource(src.Source), Notes: []string{helpNote}, }) } }) return nil }, }
AnalyzerBuiltinArity checks for wrong argument counts to known builtin functions.
var AnalyzerCondMissingElse = &Analyzer{ Name: "cond-missing-else", Doc: "Warn when a cond expression has no default clause.\n\nWithout an else or (true ...) clause, cond returns nil when no condition matches. This is a common source of unexpected nil values. Add (else ...) or (true ...) as the last clause to handle the default case.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if HeadSymbol(sexpr) != "cond" { return } if ArgCount(sexpr) == 0 { return } last := sexpr.Cells[len(sexpr.Cells)-1] if last.Type != lisp.LSExpr || len(last.Cells) == 0 { return } head := last.Cells[0] if head.Type == lisp.LSymbol && isCondDefault(head.Str) { return } src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: "cond has no default (else) clause", Pos: posFromSource(src.Source), Notes: []string{"add (else ...) or (true ...) as the last clause to handle unmatched cases"}, }) }) return nil }, }
AnalyzerCondMissingElse warns when a cond has no default (else or true) clause.
var AnalyzerCondStructure = &Analyzer{ Name: "cond-structure", Doc: "Check for malformed `cond` clauses.\n\nEach `cond` clause must be a non-empty list. The `else` clause, if present, must be last. Common mistakes include bare values instead of lists, or misplaced `else`.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if HeadSymbol(sexpr) != "cond" { return } src := SourceOf(sexpr) last := len(sexpr.Cells) - 1 for i := 1; i < len(sexpr.Cells); i++ { clause := sexpr.Cells[i] clauseSrc := SourceOf(clause) if clauseSrc.Source == nil || clauseSrc.Source.Line == 0 { clauseSrc = src } if clause.Type != lisp.LSExpr { pass.Report(Diagnostic{ Message: fmt.Sprintf("cond clause %d is not a list", i), Pos: posFromSource(clauseSrc.Source), Notes: []string{"cond clauses must be lists: (cond ((test1) body1) ((test2) body2) (else default))"}, }) continue } if len(clause.Cells) == 0 { pass.Reportf(clauseSrc.Source, "cond clause %d is empty", i) continue } if clause.Cells[0].Type == lisp.LSymbol && isCondDefault(clause.Cells[0].Str) { if i != last { pass.Reportf(clauseSrc.Source, "cond else clause must be last (is clause %d of %d)", i, last) } } } }) return nil }, }
AnalyzerCondStructure checks for malformed `cond` clauses.
var AnalyzerDefunStructure = &Analyzer{ Name: "defun-structure", Doc: "Check for malformed `defun`/`defmacro` definitions.\n\nA `defun` requires a symbol name and a formals list. An empty body (no-op) is valid. Common mistakes include non-symbol names or a non-list formals argument.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { head := HeadSymbol(sexpr) if head != "defun" && head != "defmacro" { return } src := SourceOf(sexpr) argc := ArgCount(sexpr) if argc < 2 { pass.Reportf(src.Source, "%s requires at least a name and formals list (got %d argument(s))", head, argc) return } name := sexpr.Cells[1] if name.Type != lisp.LSymbol { pass.Reportf(src.Source, "%s name must be a symbol, got %s", head, name.Type) } formals := sexpr.Cells[2] if formals.Type != lisp.LSExpr { pass.Reportf(src.Source, "%s formals must be a list, got %s", head, formals.Type) } }) return nil }, }
AnalyzerDefunStructure checks for malformed `defun` and `defmacro` forms.
var AnalyzerIfArity = &Analyzer{ Name: "if-arity", Doc: "Check that `if` has exactly 3 arguments: condition, then-branch, else-branch.\n\nA missing else branch is a common source of subtle nil-return bugs. Extra arguments are silently ignored at parse time but indicate a structural error.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if HeadSymbol(sexpr) != "if" { return } argc := ArgCount(sexpr) if argc == 3 { return } src := SourceOf(sexpr) if argc < 3 { pass.Report(Diagnostic{ Message: fmt.Sprintf("if requires 3 arguments (condition, then, else), got too few (%d)", argc), Pos: posFromSource(src.Source), Notes: []string{"use cond for multi-branch conditionals, or provide an else branch"}, }) } else { pass.Report(Diagnostic{ Message: fmt.Sprintf("if requires 3 arguments (condition, then, else), got too many (%d)", argc), Pos: posFromSource(src.Source), Notes: []string{"if takes exactly (condition then-expr else-expr); use progn to group multiple expressions"}, }) } }) return nil }, }
AnalyzerIfArity checks that `if` has exactly 3 arguments (condition, then, else).
var AnalyzerInPackageToplevel = &Analyzer{ Name: "in-package-toplevel", Doc: "Warn when `in-package` is used inside nested expressions.\n\n`in-package` only has meaningful effect at the top level of a file. Using it inside a `defun`, `let`, `lambda`, or other nested form is almost certainly a mistake.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if HeadSymbol(sexpr) == "in-package" && depth > 0 { src := SourceOf(sexpr) pass.Reportf(src.Source, "in-package should only be used at the top level") } }) return nil }, }
AnalyzerInPackageToplevel warns when `in-package` is used inside nested expressions (function bodies, let forms, etc.) where it has no useful effect.
var AnalyzerLetBindings = &Analyzer{ Name: "let-bindings", Doc: "Check for malformed `let`/`let*` binding lists.\n\nThe first argument to `let` or `let*` must be a list of (symbol value) pairs. Common mistakes include forgetting the outer list: `(let (x 1) ...)` instead of `(let ((x 1)) ...)`.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { head := HeadSymbol(sexpr) if head != "let" && head != "let*" { return } if ArgCount(sexpr) < 1 { src := SourceOf(sexpr) pass.Reportf(src.Source, "%s requires a binding list and body", head) return } bindings := sexpr.Cells[1] src := SourceOf(sexpr) if bindings.Type != lisp.LSExpr { pass.Reportf(src.Source, "%s bindings must be a list, got %s", head, bindings.Type) return } for i, binding := range bindings.Cells { if binding.Type != lisp.LSExpr { pass.Report(Diagnostic{ Message: fmt.Sprintf("%s binding %d is not a list (did you forget the outer parentheses?)", head, i+1), Pos: posFromSource(bindingSource(binding, src)), Notes: []string{"correct form: (let ((x 1) (y 2)) body...)"}, }) continue } if len(binding.Cells) == 0 { pass.Reportf(bindingSource(binding, src), "%s binding %d is empty", head, i+1) continue } if binding.Cells[0].Type != lisp.LSymbol && HeadSymbol(binding.Cells[0]) != "unquote" { pass.Reportf(bindingSource(binding, src), "%s binding %d: first element must be a symbol, got %s", head, i+1, binding.Cells[0].Type) continue } if len(binding.Cells) != 2 { pass.Reportf(bindingSource(binding, src), "%s binding %d (%s): expected 2 elements (symbol value), got %d", head, i+1, binding.Cells[0].Str, len(binding.Cells)) } } }) return nil }, }
AnalyzerLetBindings checks for malformed `let` and `let*` binding lists.
var AnalyzerQuoteCall = &Analyzer{ Name: "quote-call", Doc: "Warn when set is called with an unquoted symbol argument.\n\nThe first argument to set should be a quoted symbol: (set 'x 42). Writing (set x 42) evaluates x first, which is rarely intended. This check does not flag set!, which takes an unquoted symbol by design.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { head := HeadSymbol(sexpr) if head != "set" && head != "defconst" { return } if ArgCount(sexpr) < 1 { return } arg := sexpr.Cells[1] if arg.Type == lisp.LSymbol && !arg.Quoted { src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: fmt.Sprintf("%s first argument should be quoted: (set '%s ...) not (set %s ...)", head, arg.Str, arg.Str), Pos: posFromSource(src.Source), Notes: []string{fmt.Sprintf("did you mean (%s '%s ...)?", head, arg.Str)}, }) } }) return nil }, }
AnalyzerQuoteCall warns when set or defconst is called with an unquoted symbol as the first argument, which is almost always a mistake.
var AnalyzerRethrowContext = &Analyzer{ Name: "rethrow-context", Doc: "Warn when `rethrow` is used outside a `handler-bind` form.\n\n`rethrow` re-raises the current error being handled by handler-bind, preserving the original stack trace. Calling it outside any handler-bind always produces an error at runtime.", Run: func(pass *Pass) error { walkRethrowContext(pass.Exprs, 0, func(sexpr *lisp.LVal) { src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: "rethrow used outside handler-bind", Pos: posFromSource(src.Source), Notes: []string{"rethrow can only be called from within a handler-bind handler"}, }) }) return nil }, }
AnalyzerRethrowContext warns when `rethrow` is used outside of a `handler-bind` form. At runtime, rethrow can only be called from within a handler-bind handler; calling it elsewhere always produces an error.
var AnalyzerSetUsage = &Analyzer{ Name: "set-usage", Doc: "Warn when `set` is used to reassign an already-bound symbol.\n\nThe first `set` creating a new binding is fine — ELPS has no `defvar`, so `set` is the standard way to create top-level bindings. However, subsequent `set` calls on the same symbol should use `set!` to clearly signal mutation intent.", Run: func(pass *Pass) error { seen := make(map[string]bool) WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if HeadSymbol(sexpr) != "set" { return } if ArgCount(sexpr) < 1 { return } arg := sexpr.Cells[1] name := "" if arg.Type == lisp.LSymbol { name = arg.Str } else if arg.Type == lisp.LSExpr && arg.Quoted && len(arg.Cells) > 0 && arg.Cells[0].Type == lisp.LSymbol { name = arg.Cells[0].Str } if name == "" { return } if seen[name] { src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: fmt.Sprintf("use set! instead of set to mutate '%s (already bound)", name), Pos: posFromSource(src.Source), Notes: []string{"set creates a new binding; set! mutates an existing one"}, }) } seen[name] = true }) return nil }, }
AnalyzerSetUsage warns when `set` is used to reassign a symbol that was already bound by a prior `set` in the same file. The first `set` creating a binding is fine (ELPS has no `defvar`), but subsequent mutations of the same symbol should use `set!` to signal intent.
Functions ¶
func AnalyzerDoc ¶
func AnalyzerDoc() string
AnalyzerDoc returns a formatted documentation string for all analyzers.
func AnalyzerNames ¶
func AnalyzerNames() []string
AnalyzerNames returns a sorted list of all default analyzer names.
func FormatJSON ¶
func FormatJSON(w io.Writer, diags []Diagnostic) error
FormatJSON writes diagnostics as JSON.
func FormatText ¶
func FormatText(w io.Writer, diags []Diagnostic)
FormatText writes diagnostics in go vet text format.
func HeadSymbol ¶
HeadSymbol returns the symbol name at the head of an s-expression, or "".
func SourceOf ¶
SourceOf returns the best source location for a node. Prefers the node's own source, falls back to first child's source.
func UserDefined ¶
UserDefined returns the set of names defined or bound in the source that shadow builtins. This includes:
- Function/macro names from defun/defmacro
- Parameter names from defun/defmacro/lambda formals lists
The result is file-global (not scope-aware), which is conservative: it may suppress a valid finding but will never produce a false positive.
Types ¶
type Analyzer ¶
type Analyzer struct {
// Name is a short identifier for this check (e.g. "set-usage").
Name string
// Doc is a human-readable description. The first line is a short summary.
Doc string
// Run executes the check. It should call pass.Report() for each finding.
Run func(pass *Pass) error
}
Analyzer defines a single lint check.
func DefaultAnalyzers ¶
func DefaultAnalyzers() []*Analyzer
DefaultAnalyzers returns the built-in set of lint checks.
type Diagnostic ¶
type Diagnostic struct {
// Pos is the source location of the problem.
Pos Position `json:"pos"`
// Message is a human-readable description of the problem.
Message string `json:"message"`
// Analyzer is the name of the check that found this problem.
Analyzer string `json:"analyzer"`
// Notes are optional hint text lines for the user.
Notes []string `json:"notes,omitempty"`
}
Diagnostic is a single reported problem.
func (Diagnostic) String ¶
func (d Diagnostic) String() string
String returns the diagnostic in go vet style: file:line: message (analyzer) with optional note lines appended.
type Linter ¶
type Linter struct {
Analyzers []*Analyzer
}
Linter runs a set of analyzers over source files.
type Pass ¶
type Pass struct {
// Analyzer is the currently running check.
Analyzer *Analyzer
// Filename is the source file being analyzed.
Filename string
// Exprs are the top-level parsed expressions.
Exprs []*lisp.LVal
// contains filtered or unexported fields
}
Pass provides context to a running analyzer.
func (*Pass) ReportWithNotes ¶ added in v1.16.12
func (p *Pass) ReportWithNotes(d Diagnostic, notes ...string)
ReportWithNotes records a diagnostic with additional hint text.