lint

package
v1.16.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 8, 2026 License: BSD-3-Clause Imports: 9 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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).

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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 ArgCount

func ArgCount(sexpr *lisp.LVal) int

ArgCount returns the number of arguments in an s-expression (excluding the head).

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

func HeadSymbol(sexpr *lisp.LVal) string

HeadSymbol returns the symbol name at the head of an s-expression, or "".

func SourceOf

func SourceOf(v *lisp.LVal) *lisp.LVal

SourceOf returns the best source location for a node. Prefers the node's own source, falls back to first child's source.

func UserDefined

func UserDefined(exprs []*lisp.LVal) map[string]bool

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.

func Walk

func Walk(exprs []*lisp.LVal, fn func(node *lisp.LVal, parent *lisp.LVal, depth int))

Walk calls fn for every node in the tree, depth-first. parent is nil for top-level expressions.

func WalkSExprs

func WalkSExprs(exprs []*lisp.LVal, fn func(sexpr *lisp.LVal, depth int))

WalkSExprs calls fn for every unquoted s-expression (potential function call or special form) in the tree.

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.

func (*Linter) LintFile

func (l *Linter) LintFile(source []byte, filename string) ([]Diagnostic, error)

LintFile analyzes a single source file and returns all diagnostics.

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) Report

func (p *Pass) Report(d Diagnostic)

Report records a diagnostic finding.

func (*Pass) ReportWithNotes added in v1.16.12

func (p *Pass) ReportWithNotes(d Diagnostic, notes ...string)

ReportWithNotes records a diagnostic with additional hint text.

func (*Pass) Reportf

func (p *Pass) Reportf(source *token.Location, format string, args ...interface{})

Reportf is a convenience for reporting a diagnostic at a position.

type Position

type Position struct {
	File string `json:"file"`
	Line int    `json:"line"`
	Col  int    `json:"col,omitempty"`
}

Position identifies a location in source code.

func (Position) String

func (p Position) String() string

String returns the position in file:line format.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL