lint

package
v1.17.2 Latest Latest
Warning

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

Go to latest
Published: Feb 12, 2026 License: BSD-3-Clause Imports: 14 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",
	Severity: SeverityError,
	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",
	Severity: SeverityInfo,
	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",
	Severity: SeverityError,
	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",
	Severity: SeverityError,
	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",
	Severity: SeverityError,
	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",
	Severity: SeverityWarning,
	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",
	Severity: SeverityError,
	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",
	Severity: SeverityWarning,
	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",
	Severity: SeverityError,
	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",
	Severity: SeverityWarning,
	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) == "in-package" && depth == 0 {
				seen = make(map[string]bool)
				return
			}
			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.

View Source
var AnalyzerShadowing = &Analyzer{
	Name:     "shadowing",
	Severity: SeverityInfo,
	Doc:      "Report when a local binding shadows a name from an enclosing scope.\n\nRequires semantic analysis (--workspace flag). Shadowing is valid but can cause confusion, especially when it hides a builtin or outer variable.",
	Run: func(pass *Pass) error {
		if pass.Semantics == nil {
			return nil
		}
		for _, sym := range pass.Semantics.Symbols {
			if sym.Scope == nil || sym.Scope == pass.Semantics.RootScope {
				continue
			}
			if sym.Scope.Parent == nil {
				continue
			}
			outer := sym.Scope.Parent.Lookup(sym.Name)
			if outer == nil {
				continue
			}

			if outer.External {
				continue
			}
			pass.Report(Diagnostic{
				Message: fmt.Sprintf("%s '%s' shadows %s from enclosing scope", sym.Kind, sym.Name, outer.Kind),
				Pos:     posFromSource(sym.Source),
				Notes:   []string{fmt.Sprintf("rename '%s' to avoid confusion with the outer %s", sym.Name, outer.Kind)},
			})
		}
		return nil
	},
}

AnalyzerShadowing reports when a local binding shadows a name from an enclosing scope. This is informational — shadowing is valid but can cause confusion. Requires semantic analysis (pass.Semantics != nil).

View Source
var AnalyzerUndefinedSymbol = &Analyzer{
	Name:     "undefined-symbol",
	Severity: SeverityError,
	Doc:      "Report symbols that cannot be resolved in any enclosing scope.\n\nRequires semantic analysis (--workspace flag). Keywords and qualified symbols are excluded. Builtins, special operators, and macros are pre-populated.",
	Run: func(pass *Pass) error {
		if pass.Semantics == nil {
			return nil
		}
		for _, u := range pass.Semantics.Unresolved {
			pass.Report(Diagnostic{
				Message: fmt.Sprintf("undefined symbol: %s", u.Name),
				Pos:     posFromSource(u.Source),
				Notes:   []string{fmt.Sprintf("'%s' is not defined in any enclosing scope; did you mean a different name?", u.Name)},
			})
		}
		return nil
	},
}

AnalyzerUndefinedSymbol reports symbols that could not be resolved in any enclosing scope. Requires semantic analysis (pass.Semantics != nil).

View Source
var AnalyzerUnnecessaryProgn = &Analyzer{
	Name:     "unnecessary-progn",
	Severity: SeverityInfo,
	Doc:      "Warn when `progn` wraps the body of a form that already supports multiple expressions.\n\nForms like `defun`, `lambda`, `let`, and others evaluate their body as an implicit progn. Wrapping the body in an explicit `(progn ...)` is redundant. This does not flag `progn` inside `if` branches or `defmacro`, where it is needed.",
	Run: func(pass *Pass) error {
		WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) {
			head := HeadSymbol(sexpr)
			bodyStart, ok := implicitPrognForms[head]
			if !ok {
				return
			}

			bodyExprs := len(sexpr.Cells) - bodyStart
			if bodyExprs != 1 {
				return
			}
			body := sexpr.Cells[bodyStart]
			if HeadSymbol(body) != "progn" {
				return
			}
			src := SourceOf(body)
			var msg string
			if head == "progn" {
				msg = "nested progn is redundant"
			} else {
				msg = fmt.Sprintf("progn is unnecessary in %s body (it supports multiple expressions)", head)
			}
			pass.Report(Diagnostic{
				Message: msg,
				Pos:     posFromSource(src.Source),
				Notes:   []string{fmt.Sprintf("remove the progn and move its contents directly into the %s body", head)},
			})
		})

		WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) {
			if HeadSymbol(sexpr) != "cond" {
				return
			}
			for i := 1; i < len(sexpr.Cells); i++ {
				clause := sexpr.Cells[i]
				if clause.Type != lisp.LSExpr || len(clause.Cells) < 2 {
					continue
				}

				if len(clause.Cells) == 2 && HeadSymbol(clause.Cells[1]) == "progn" {
					src := SourceOf(clause.Cells[1])
					pass.Report(Diagnostic{
						Message: "progn is unnecessary in cond clause body (it supports multiple expressions)",
						Pos:     posFromSource(src.Source),
						Notes:   []string{"remove the progn and move its contents directly into the cond clause"},
					})
				}
			}
		})
		return nil
	},
}

AnalyzerUnnecessaryProgn warns when progn is used as the sole body expression in a form that already supports multiple body expressions.

View Source
var AnalyzerUnusedFunction = &Analyzer{
	Name:     "unused-function",
	Severity: SeverityWarning,
	Doc:      "Warn about top-level functions and macros that are defined but never referenced.\n\nRequires semantic analysis (--workspace flag). Exported symbols and functions with underscore prefix are excluded.",
	Run: func(pass *Pass) error {
		if pass.Semantics == nil {
			return nil
		}
		for _, sym := range pass.Semantics.Symbols {
			if sym.References > 0 {
				continue
			}
			if sym.Kind != analysis.SymFunction && sym.Kind != analysis.SymMacro {
				continue
			}

			if sym.Scope != pass.Semantics.RootScope {
				continue
			}

			if sym.Exported {
				continue
			}

			if len(sym.Name) > 0 && sym.Name[0] == '_' {
				continue
			}
			pass.Report(Diagnostic{
				Message: fmt.Sprintf("unused %s: %s", sym.Kind, sym.Name),
				Pos:     posFromSource(sym.Source),
				Notes:   []string{"if this is a public API, add it to an (export ...) form"},
			})
		}
		return nil
	},
}

AnalyzerUnusedFunction warns about functions and macros that are defined at the top level but never referenced. Requires semantic analysis.

View Source
var AnalyzerUnusedVariable = &Analyzer{
	Name:     "unused-variable",
	Severity: SeverityWarning,
	Doc:      "Warn about variables and parameters that are defined but never referenced.\n\nRequires semantic analysis (--workspace flag). Skips variables with underscore prefix (conventional \"ignored\" marker) and top-level (global scope) variables.",
	Run: func(pass *Pass) error {
		if pass.Semantics == nil {
			return nil
		}
		for _, sym := range pass.Semantics.Symbols {
			if sym.References > 0 {
				continue
			}
			if sym.Kind != analysis.SymVariable && sym.Kind != analysis.SymParameter {
				continue
			}

			if sym.Scope == pass.Semantics.RootScope {
				continue
			}

			if len(sym.Name) > 0 && sym.Name[0] == '_' {
				continue
			}
			pass.Report(Diagnostic{
				Message: fmt.Sprintf("unused %s: %s", sym.Kind, sym.Name),
				Pos:     posFromSource(sym.Source),
				Notes:   []string{fmt.Sprintf("if '%s' is intentionally unused, prefix it with '_'", sym.Name)},
			})
		}
		return nil
	},
}

AnalyzerUnusedVariable warns about variables and parameters that are defined but never referenced. Requires semantic analysis (pass.Semantics != nil).

View Source
var AnalyzerUserArity = &Analyzer{
	Name:     "user-arity",
	Severity: SeverityError,
	Doc:      "Check argument counts for calls to user-defined functions and macros.\n\nRequires semantic analysis (--workspace flag). Only checks calls to functions with known signatures (Source != nil). Complements builtin-arity which covers builtins.",
	Run: func(pass *Pass) error {
		if pass.Semantics == nil {
			return nil
		}

		skipNodes := aritySkipNodes(pass.Exprs)

		WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) {
			if skipNodes[sexpr] {
				return
			}
			head := HeadSymbol(sexpr)
			if head == "" {
				return
			}
			sym := pass.Semantics.RootScope.Lookup(head)
			if sym == nil || sym.Signature == nil || sym.Source == nil {
				return
			}
			if sym.External {
				return
			}
			if sym.Kind != analysis.SymFunction && sym.Kind != analysis.SymMacro {
				return
			}
			argc := ArgCount(sexpr)
			minArity := sym.Signature.MinArity()
			maxArity := sym.Signature.MaxArity()
			if argc < minArity {
				src := SourceOf(sexpr)
				pass.Report(Diagnostic{
					Message: fmt.Sprintf("%s requires at least %d argument(s), got %d", head, minArity, argc),
					Pos:     posFromSource(src.Source),
					Notes:   []string{fmt.Sprintf("defined at %s", sourceString(sym.Source))},
				})
			}
			if maxArity >= 0 && argc > maxArity {
				src := SourceOf(sexpr)
				pass.Report(Diagnostic{
					Message: fmt.Sprintf("%s accepts at most %d argument(s), got %d", head, maxArity, argc),
					Pos:     posFromSource(src.Source),
					Notes:   []string{fmt.Sprintf("defined at %s", sourceString(sym.Source))},
				})
			}
		})
		return nil
	},
}

AnalyzerUserArity checks argument counts for calls to user-defined functions and macros whose signatures are known from the same file. Requires semantic analysis (pass.Semantics != nil).

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 BuildAnalysisConfig added in v1.17.0

func BuildAnalysisConfig(cfg *LintConfig) (*analysis.Config, error)

BuildAnalysisConfig constructs an analysis.Config from a LintConfig. It scans the workspace, extracts stdlib exports, and merges embedder registry symbols. This is exported for callers (like the CLI stdin path) that need to build the config separately from file linting.

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

	// Severity is the default severity for diagnostics from this analyzer.
	Severity Severity

	// 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"`

	// Severity is the severity level of the diagnostic.
	Severity Severity `json:"severity"`

	// 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 LintConfig added in v1.17.0

type LintConfig struct {
	// Workspace is the root directory for cross-file scanning.
	// Empty string disables workspace scanning.
	Workspace string

	// Registry provides Go-registered symbols (builtins, special ops, macros)
	// from an embedder's environment. These are merged with stdlib and
	// workspace symbols for semantic analysis. When nil, only stdlib symbols
	// are used.
	Registry *lisp.PackageRegistry

	// Excludes are glob patterns for files to skip during workspace scanning.
	Excludes []string

	// StdlibExports provides pre-extracted stdlib package exports. When nil,
	// LintFiles uses the default stdlib (loaded via lisplib.LoadLibrary).
	// Embedders that already have a configured env can pass
	// analysis.ExtractPackageExports(env.Runtime.Registry) here to avoid
	// the overhead of creating a temporary environment.
	StdlibExports map[string][]analysis.ExternalSymbol
}

LintConfig configures the linter for a single run.

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. Semantic analyzers are no-ops because no analysis.Result is provided.

func (*Linter) LintFileWithAnalysis added in v1.17.0

func (l *Linter) LintFileWithAnalysis(source []byte, filename string, cfg *analysis.Config) ([]Diagnostic, error)

LintFileWithAnalysis parses, analyzes, and lints a source file in one call. This is a convenience that runs semantic analysis and passes the result to all analyzers.

func (*Linter) LintFileWithContext added in v1.17.0

func (l *Linter) LintFileWithContext(source []byte, filename string, semantics *analysis.Result) ([]Diagnostic, error)

LintFileWithContext analyzes a source file with optional semantic analysis results. When semantics is nil, semantic analyzers (undefined-symbol, unused-variable, etc.) are no-ops. When non-nil, they use the scope and reference data for deeper checks.

func (*Linter) LintFiles added in v1.17.0

func (l *Linter) LintFiles(cfg *LintConfig, files []string) ([]Diagnostic, error)

LintFiles analyzes source files with full workspace + embedder context. It handles workspace scanning, stdlib/registry symbol extraction, and semantic analysis configuration. The files slice contains resolved file paths (no glob expansion — callers handle that).

When cfg is nil, all files are linted with syntactic checks only.

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

	// Semantics holds the result of semantic analysis, if available.
	// Nil when semantic analysis has not been run (e.g. no --workspace flag).
	// Semantic analyzers should check for nil and return early.
	Semantics *analysis.Result
	// 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.

type Severity added in v1.17.0

type Severity int

Severity indicates the severity level of a lint diagnostic.

const (
	SeverityError Severity
	SeverityWarning
	SeverityInfo
)

func (Severity) MarshalJSON added in v1.17.0

func (s Severity) MarshalJSON() ([]byte, error)

MarshalJSON serializes the severity as a JSON string. An unset severity (zero value) is marshaled as "warning".

func (Severity) String added in v1.17.0

func (s Severity) String() string

func (*Severity) UnmarshalJSON added in v1.17.0

func (s *Severity) UnmarshalJSON(data []byte) error

UnmarshalJSON deserializes a severity from a JSON string.

Jump to

Keyboard shortcuts

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