lint

package
v1.61.2 Latest Latest
Warning

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

Go to latest
Published: Sep 10, 2026 License: BSD-3-Clause Imports: 16 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, including names bound by let/let*/flet/labels/macrolet. Binding lists, 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)
			headNode := sexpr.Cells[0]
			if argc < spec.min {
				pass.Report(Diagnostic{
					Message: fmt.Sprintf("%s requires at least %d argument(s), got %d", head, spec.min, argc),
					Pos:     posFromSource(astutil.SourceLoc(headNode)),
					EndPos:  endPosFromNode(headNode),
					Notes:   []string{helpNote},
				})
			}
			if spec.max >= 0 && argc > spec.max {
				pass.Report(Diagnostic{
					Message: fmt.Sprintf("%s accepts at most %d argument(s), got %d", head, spec.max, argc),
					Pos:     posFromSource(astutil.SourceLoc(headNode)),
					EndPos:  endPosFromNode(headNode),
					Notes:   []string{helpNote},
				})
			}
		})
		return nil
	},
}

AnalyzerBuiltinArity checks for wrong argument counts to known builtin functions.

View Source
var AnalyzerComparatorMutation = &Analyzer{
	Name:     "comparator-mutation",
	Severity: SeverityError,
	Doc: "Report a mutating call inside a stable-sort or insert-sorted predicate.\n\n" +
		"A comparator runs an unspecified number of times in an unspecified order, " +
		"so writes to shared state or input elements are unsafe. This conservative " +
		"check also reports writes to callback-local scratch values. Every mutating " +
		"builtin is reported whatever it writes to: " +
		"assoc!, dissoc!, append!, append-bytes!, set! and stable-sort itself, which " +
		"sorts in place despite carrying no `!`.\n\n" +
		"Two spellings of the predicate are followed: an inline lambda, and a plain " +
		"symbol naming a defun in the same file. The symbol hop is ONE level deep -- " +
		"the named defun's own body is scanned, but a call it makes to a third " +
		"function is not followed, so a comparator that mutates two hops away is not " +
		"reported.\n\n" +
		"Data is skipped whole, in all three spellings: a reader-quoted form, an " +
		"explicit (quote ...) form, and a quasiquote template -- except for the " +
		"(unquote ...) and (unquote-splicing ...) subtrees inside a template, which " +
		"are evaluated where they stand and so are still checked. That applies to the " +
		"sort form itself as much as to its predicate's body, and a defun written " +
		"inside data defines nothing, so a symbol naming one resolves to no callback.",
	Run: func(pass *Pass) error {
		run := newMutationRun(pass)
		walkEvaluatedSExprs(pass.Exprs, func(sexpr *lisp.LVal) {
			form := mutationHead(sexpr)
			idx, ok := comparatorPredicateArg[form]
			if !ok || idx >= len(sexpr.Cells) {
				return
			}
			cb := run.resolveCallback(sexpr.Cells[idx])
			if cb == nil {
				return
			}
			run.query(mutationQuery{form: form}, cb.sites)
		})
		run.reportQueries()
		return nil
	},
}

AnalyzerComparatorMutation reports a call to a mutating builtin inside a sort comparator.

A comparator is called an unspecified number of times and in an unspecified order. This conservative policy rejects every mutating builtin, including writes to callback-local scratch values; it does not prove impurity.

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(astutil.SourceLoc(src)),
				EndPos:  endPosFromNode(src),
				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 loc := astutil.SourceLoc(clauseSrc); loc == nil || loc.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(astutil.SourceLoc(clauseSrc)),
						EndPos:  endPosFromNode(clauseSrc),
						Notes:   []string{"cond clauses must be lists: (cond ((test1) body1) ((test2) body2) (else default))"},
					})
					continue
				}
				if len(clause.Cells) == 0 {
					pass.Reportf(astutil.SourceLoc(clauseSrc), "cond clause %d is empty", i)
					continue
				}

				if clause.Cells[0].Type == lisp.LSymbol && isCondDefault(clause.Cells[0].Str) {
					if i != last {
						pass.Reportf(astutil.SourceLoc(clauseSrc), "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
			}
			headNode := sexpr.Cells[0]
			argc := ArgCount(sexpr)
			if argc < 2 {
				pass.Report(Diagnostic{
					Message: fmt.Sprintf("%s requires at least a name and formals list (got %d argument(s))", head, argc),
					Pos:     posFromSource(astutil.SourceLoc(headNode)),
					EndPos:  endPosFromNode(headNode),
				})
				return
			}
			name := sexpr.Cells[1]
			if name.Type != lisp.LSymbol {
				pass.Report(Diagnostic{
					Message: fmt.Sprintf("%s name must be a symbol, got %s", head, name.Type),
					Pos:     posFromSource(astutil.SourceLoc(headNode)),
					EndPos:  endPosFromNode(headNode),
				})
			}
			formals := sexpr.Cells[2]
			if formals.Type != lisp.LSExpr {
				pass.Report(Diagnostic{
					Message: fmt.Sprintf("%s formals must be a list, got %s", head, formals.Type),
					Pos:     posFromSource(astutil.SourceLoc(headNode)),
					EndPos:  endPosFromNode(headNode),
				})
			}
		})
		return nil
	},
}

AnalyzerDefunStructure checks for malformed `defun` and `defmacro` forms.

View Source
var AnalyzerDeprecated = &Analyzer{
	Name:     "deprecated",
	Severity: SeverityWarning,
	Semantic: true,
	Doc:      "Report uses of symbols marked deprecated by their docstring.\n\nRequires semantic analysis (--workspace flag). A symbol is deprecated when a paragraph of its docstring begins with \"Deprecated:\", the same convention Go doc comments use; the rest of that paragraph is reported as the notice. Definitions are never flagged, only uses, and a use inside the body of a definition that is itself deprecated is not reported — deprecated code may call deprecated code.",
	Run: func(pass *Pass) error {
		if pass.Semantics == nil {
			return nil
		}
		// Source spans of the definitions that are themselves deprecated. Go's
		// rule is that deprecated code may use deprecated code, so references
		// from inside those bodies are exempt. Built lazily: almost every file
		// has no deprecated reference at all, and the LSP runs this on each
		// keystroke.
		var exempt byteSpans
		exemptBuilt := false
		passFile := analysis.NormalizePath(pass.Filename)

		reported := make(map[int]bool)
		for _, ref := range pass.Semantics.References {
			if ref == nil || ref.Symbol == nil || ref.Source == nil {
				continue
			}
			notice, ok := lisp.DeprecationNotice(ref.Symbol.DocString)
			if !ok {
				continue
			}

			if analysis.NormalizePath(ref.Source.File) != passFile {
				continue
			}
			if !exemptBuilt {
				exempt = deprecatedBodySpans(pass.Exprs)
				exemptBuilt = true
			}
			if exempt.contains(ref.Source.Pos) {
				continue
			}

			if ref.Source.Pos >= 0 {
				if reported[ref.Source.Pos] {
					continue
				}
				reported[ref.Source.Pos] = true
			}

			name := ref.Symbol.Name
			if ref.Node != nil && ref.Node.Type == lisp.LSymbol && ref.Node.Str != "" {
				name = ref.Node.Str
			}
			msg := fmt.Sprintf("use of deprecated %s '%s'", deprecatedKind(ref.Symbol.Kind), name)
			if notice != "" {
				msg += ": " + notice
			}

			decl := ref.Symbol.Source
			if decl != nil && decl.Pos < 0 {
				decl = nil
			}
			// No hand-written nolint note: the CLI appends the suppression
			// hint to every diagnostic (cmd/diagnostic.go), and no other
			// analyzer duplicates it.
			var notes []string
			if decl != nil {
				notes = []string{"deprecated at " + sourceString(decl)}
			}
			pass.Report(Diagnostic{
				Message:    msg,
				Pos:        posFromSource(ref.Source),
				EndPos:     endPosFromNode(ref.Node),
				Notes:      notes,
				Related:    relatedFromSource(decl, "deprecated declaration here"),
				Deprecated: true,
			})
		}
		return nil
	},
}

AnalyzerDeprecated reports uses of symbols whose docstring marks them deprecated, following the convention Go doc comments use: a docstring paragraph beginning with "Deprecated:" (or "DEPRECATED:") deprecates the symbol and the rest of the paragraph says what to use instead. lisp.DeprecationNotice is the canonical detector.

Every symbol kind semantic analysis records a docstring for is covered: same-file defun/defmacro, workspace-scanned definitions, the compiled-in builtins, special operators and macros, and the builtins an embedder registers through a lisp.PackageRegistry (LintConfig.Registry).

Requires semantic analysis (pass.Semantics != nil).

View Source
var AnalyzerDuplicateDefinition = &Analyzer{
	Name:     "duplicate-definition",
	Severity: SeverityWarning,
	Semantic: true,
	Doc:      "Warn when a symbol is defined more than once at the top level.\n\nRequires semantic analysis (--workspace flag). Detects same-file duplicates (two defun with the same name) and cross-file duplicates (a local defun that shadows an imported definition). Only checks defun and defmacro — repeated set is handled by set-usage.",
	Run: func(pass *Pass) error {
		if pass.Semantics == nil {
			return nil
		}

		isDefKind := func(k analysis.SymbolKind) bool {
			return k == analysis.SymFunction || k == analysis.SymMacro
		}

		// Collect local (non-external) root-scope definitions by (name, package).
		type defKey struct {
			name string
			pkg  string
		}
		groups := make(map[defKey][]*analysis.Symbol)

		for _, sym := range pass.Semantics.Symbols {
			if sym.Scope != pass.Semantics.RootScope {
				continue
			}
			if sym.External {
				continue
			}
			if !isDefKind(sym.Kind) {
				continue
			}
			if sym.Source == nil {
				continue
			}
			key := defKey{name: sym.Name, pkg: sym.Package}
			groups[key] = append(groups[key], sym)
		}

		// Build an index of ExtraGlobals for cross-file duplicate checking.
		// This avoids relying on scope lookups which may have been
		// overwritten by local definitions during prescan.
		type extKey struct {
			name string
			pkg  string
		}
		extIndex := make(map[extKey]*analysis.ExternalSymbol)
		for i := range pass.Semantics.ExtraGlobals {
			ext := &pass.Semantics.ExtraGlobals[i]
			if !isDefKind(ext.Kind) || ext.Source == nil {
				continue
			}
			pkg := ext.Package
			if pkg == "" {
				pkg = "user"
			}

			ek := extKey{name: ext.Name, pkg: pkg}
			if _, exists := extIndex[ek]; !exists {
				extIndex[ek] = ext
			}
		}

		cleanFilename := analysis.NormalizePath(pass.Filename)

		for key, syms := range groups {
			first := syms[0]

			for _, sym := range syms[1:] {
				pass.Report(Diagnostic{
					Message: fmt.Sprintf("duplicate definition: %s '%s' already defined", sym.Kind, sym.Name),
					Pos:     posFromSource(sym.Source),
					EndPos:  endPosFromNode(sym.Node),
					Notes:   []string{"first defined at " + sourceString(first.Source)},
					Related: relatedFromSource(first.Source, "first defined here"),
				})
			}

			localPkg := key.pkg
			if localPkg == "" {
				localPkg = "user"
			}
			if ext, ok := extIndex[extKey{name: key.name, pkg: localPkg}]; ok {

				if cleanFilename != "" && analysis.NormalizePath(ext.Source.File) == cleanFilename {
					continue
				}
				pass.Report(Diagnostic{
					Message: fmt.Sprintf("duplicate definition: %s '%s' is also defined externally", first.Kind, first.Name),
					Pos:     posFromSource(first.Source),
					EndPos:  endPosFromNode(first.Node),
					Notes:   []string{"also defined at " + sourceString(ext.Source)},
					Related: relatedFromSource(ext.Source, "also defined here"),
				})
			}
		}
		return nil
	},
}

AnalyzerDuplicateDefinition warns when the same symbol is defined more than once at the top level (e.g. two defun with the same name). Only flags defun/defmacro duplicates — repeated set is already covered by set-usage. Cross-file duplicates are detected when an External symbol matches a local definition. Requires semantic analysis.

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
			}
			head := sexpr.Cells[0]
			if argc < 3 {
				pass.Report(Diagnostic{
					Message: fmt.Sprintf("if requires 3 arguments (condition, then, else), got too few (%d)", argc),
					Pos:     posFromSource(astutil.SourceLoc(head)),
					EndPos:  endPosFromNode(head),
					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(astutil.SourceLoc(head)),
					EndPos:  endPosFromNode(head),
					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(astutil.SourceLoc(src), "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 AnalyzerIterationMutation = &Analyzer{
	Name:     "iteration-mutation",
	Severity: SeverityWarning,
	Doc: "Report a callback that mutates the collection a higher-order builtin is iterating.\n\n" +
		"Covers map, foldl, foldr, select, reject, all? and any?. A mutating call " +
		"(assoc!, dissoc!, append!, append-bytes! or stable-sort) is reported when " +
		"the value it writes through is the collection argument itself -- which " +
		"must be a plain symbol for the check to see it -- or one of the callback's " +
		"own parameters, which holds an element of that collection.\n\n" +
		"set! is NOT on that list, unlike in comparator-mutation: it rebinds a name " +
		"rather than writing through the value the name held, so a callback's " +
		"(set! x 1) leaves the element alone and a (set! xs ...) leaves the sequence " +
		"the builtin is already walking alone.\n\n" +
		"A fold's accumulator is neither: not the collection, and not an element, " +
		"even though the callback receives it as a parameter -- so the usual " +
		"(foldl (lambda (acc x) (assoc! acc k v)) (sorted-map) xs) idiom is clean, and " +
		"so is a mutation of any unrelated binding. Both an inline lambda and a plain " +
		"symbol naming a same-file defun are followed, one hop deep.\n\n" +
		"Known blind spots: the check is syntactic and keeps no scope of its own, so a " +
		"callback parameter that an inner let rebinds is still treated as the element; " +
		"a collection passed as an expression rather than a symbol is invisible; and " +
		"zip is not covered because it takes no callback at all.\n\n" +
		"Data is skipped whole, in all three spellings: a reader-quoted form, an " +
		"explicit (quote ...) form, and a quasiquote template -- except for the " +
		"(unquote ...) and (unquote-splicing ...) subtrees inside a template, which " +
		"are evaluated where they stand and so are still checked.",
	Run: func(pass *Pass) error {
		run := newMutationRun(pass)
		walkEvaluatedSExprs(pass.Exprs, func(sexpr *lisp.LVal) {
			form := mutationHead(sexpr)
			spec, ok := iterationForms[form]
			if !ok || spec.callback >= len(sexpr.Cells) {
				return
			}
			cb := run.resolveCallback(sexpr.Cells[spec.callback])
			if cb == nil {
				return
			}
			collections := make(map[string]bool)
			for _, idx := range spec.collections {
				if idx < len(sexpr.Cells) && sexpr.Cells[idx].Type == lisp.LSymbol {
					name := sexpr.Cells[idx].Str
					collections[name] = true
					run.query(mutationQuery{form: form, target: name}, cb.sites)
				}
			}
			run.iterationScope(form, cb, collections)
		})
		run.elementQueries()
		run.reportQueries()
		return nil
	},
}

AnalyzerIterationMutation reports a callback that mutates the very collection it is iterating, or an element that collection handed it.

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
			}
			headNode := sexpr.Cells[0]
			if ArgCount(sexpr) < 1 {
				pass.Report(Diagnostic{
					Message: head + " requires a binding list and body",
					Pos:     posFromSource(astutil.SourceLoc(headNode)),
					EndPos:  endPosFromNode(headNode),
				})
				return
			}
			bindings := sexpr.Cells[1]
			src := SourceOf(sexpr)

			if bindings.Type != lisp.LSExpr {
				pass.Reportf(astutil.SourceLoc(src), "%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)),
						EndPos:  endPosFromNode(binding),
						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, nor defconst, which quotes its own name argument.",
	Run: func(pass *Pass) error {
		WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) {
			head := HeadSymbol(sexpr)
			if head != "set" {
				return
			}
			if ArgCount(sexpr) < 1 {
				return
			}
			arg := sexpr.Cells[1]

			if arg.Type == lisp.LSymbol && !arg.IsQuoted() {
				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(astutil.SourceLoc(src)),
					EndPos:  endPosFromNode(src),
					Notes:   []string{fmt.Sprintf("did you mean (%s '%s ...)?", head, arg.Str)},
				})
			}
		})
		return nil
	},
}

AnalyzerQuoteCall warns when set is called with an unquoted symbol as the first argument, which is almost always a mistake.

NOTE: defconst is deliberately NOT checked. It is a macro that quotes its own name argument — (defconst x 42) expands to (set 'x 42) — so the correct spelling is the unquoted one. Flagging it reported every correct use, and the suggested "fix" of (defconst 'x 42) makes the program fail at runtime with "lisp:set: first argument is not a symbol: quote".

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(astutil.SourceLoc(src)),
				EndPos:  endPosFromNode(src),
				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.IsQuoted() && 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(astutil.SourceLoc(src)),
					EndPos:  endPosFromNode(src),
					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,
	Semantic: true,
	Doc: "Report when a local binding shadows a name from an enclosing scope.\n\n" +
		"Requires semantic analysis (--workspace flag). Severity follows what is being " +
		"hidden: shadowing a builtin, special operator or macro is a WARNING, because a " +
		"later call to that name silently means something else; shadowing another local " +
		"is INFO.\n\n" +
		"A binding whose initialiser references the name it shadows is NOT reported \u2014 " +
		"(let* ([ctx (default ctx (sorted-map))]) refines one value rather than " +
		"introducing a second meaning, and ELPS offers no other way to default an " +
		"&optional argument (elps#559).",
	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
			}

			if sym.Kind == analysis.SymParameter &&
				(outer.Kind == analysis.SymSpecialOp || outer.Kind == analysis.SymBuiltin) {
				continue
			}

			if !hidesCallable(outer.Kind) && refinesShadowed(sym) {
				continue
			}

			severity := SeverityInfo
			note := fmt.Sprintf("rename '%s' to avoid confusion with the outer %s", sym.Name, outer.Kind)
			if hidesCallable(outer.Kind) {
				severity = SeverityWarning
				note = fmt.Sprintf("rename '%s': while this binding is in scope, a call to %s "+
					"resolves to it rather than to the %s", sym.Name, sym.Name, outer.Kind)
			}
			pass.Report(Diagnostic{
				Message:  fmt.Sprintf("%s '%s' shadows %s from enclosing scope", sym.Kind, sym.Name, outer.Kind),
				Severity: severity,
				Pos:      posFromSource(sym.Source),
				EndPos:   endPosFromNode(sym.Node),
				Notes:    []string{note},
			})
		}
		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,
	Semantic: true,
	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 {
			sev := SeverityError
			notes := []string{fmt.Sprintf("'%s' is not defined in any enclosing scope; did you mean a different name?", u.Name)}
			if u.InsideMacroCall {
				sev = SeverityWarning
				notes = append(notes, "inside a macro call — the macro may introduce this binding at expansion time")
			}
			pass.Report(Diagnostic{
				Severity: sev,
				Message:  "undefined symbol: " + u.Name,
				Pos:      posFromSource(u.Source),
				EndPos:   endPosFromNode(u.Node),
				Notes:    notes,
			})
		}
		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`, `defmacro`, `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, 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(astutil.SourceLoc(src)),
				EndPos:  endPosFromNode(src),
				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(astutil.SourceLoc(src)),
						EndPos:  endPosFromNode(src),
						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,
	Semantic: true,
	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
			}

			if pass.Semantics.WorkspaceRefs != nil {
				key := analysis.SymbolToKey(sym).String()
				if refs, ok := pass.Semantics.WorkspaceRefs[key]; ok {
					hasExternal := false
					for _, ref := range refs {
						if ref.File != pass.Filename {
							hasExternal = true
							break
						}
					}
					if hasExternal {
						continue
					}
				}
			}
			pass.Report(Diagnostic{
				Message:     fmt.Sprintf("unused %s: %s", sym.Kind, sym.Name),
				Pos:         posFromSource(sym.Source),
				EndPos:      endPosFromNode(sym.Node),
				Notes:       []string{"if this is a public API, add it to an (export ...) form"},
				Unnecessary: true,
			})
		}
		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,
	Semantic: true,
	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),
				EndPos:      endPosFromNode(sym.Node),
				Notes:       []string{fmt.Sprintf("if '%s' is intentionally unused, prefix it with '_'", sym.Name)},
				Unnecessary: true,
			})
		}
		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,
	Semantic: true,
	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)

		locallyShadowed := make(map[string]bool)
		for _, sym := range pass.Semantics.Symbols {
			if sym.Scope == nil || sym.Scope == pass.Semantics.RootScope {
				continue
			}

			rootSym := pass.Semantics.RootScope.LookupLocal(sym.Name)
			if rootSym != nil && rootSym.Signature != nil &&
				(rootSym.Kind == analysis.SymFunction || rootSym.Kind == analysis.SymMacro) {
				locallyShadowed[sym.Name] = true
			}
		}

		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
			}
			if locallyShadowed[head] {
				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(astutil.SourceLoc(src)),
					EndPos:  endPosFromNode(src),
					Notes:   []string{"defined at " + sourceString(sym.Source)},
					Related: relatedFromSource(sym.Source, "defined here"),
				})
			}
			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(astutil.SourceLoc(src)),
					EndPos:  endPosFromNode(src),
					Notes:   []string{"defined at " + sourceString(sym.Source)},
					Related: relatedFromSource(sym.Source, "defined here"),
				})
			}
		})
		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).

View Source
var AnalyzerWithCleanupForms = &Analyzer{
	Name:     "with-cleanup-forms",
	Severity: SeverityWarning,
	Doc:      "Warn about a with-cleanup spec list that is empty or holds a bare symbol.\n\nAn empty list guarantees nothing, so the form is indistinguishable from its body alone. A bare symbol is the missing-paren mistake -- `(with-cleanup (release h) ...)` runs neither `release` nor `h`, so the cleanup silently never happens.",
	Run: func(pass *Pass) error {
		WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) {
			if HeadSymbol(sexpr) != "with-cleanup" {
				return
			}

			if ArgCount(sexpr) < 1 {
				return
			}
			spec := sexpr.Cells[1]
			if spec.Type != lisp.LSExpr {

				return
			}
			if len(spec.Cells) == 0 {
				src := SourceOf(sexpr)
				pass.Report(Diagnostic{
					Message: "with-cleanup has no cleanup forms, so it guarantees nothing",
					Pos:     posFromSource(astutil.SourceLoc(src)),
					EndPos:  endPosFromNode(src),
					Notes: []string{
						"cleanup forms go in the first argument: (with-cleanup ((release h)) body...)",
					},
				})
				return
			}
			for _, form := range spec.Cells {
				if form.Type != lisp.LSymbol {
					continue
				}
				src := SourceOf(form)
				pass.Report(Diagnostic{
					Message: fmt.Sprintf("cleanup form %q is a bare symbol and does nothing"+
						" (missing parentheses?)", form.Str),
					Pos:    posFromSource(astutil.SourceLoc(src)),
					EndPos: endPosFromNode(src),
					Notes: []string{
						"the spec is a LIST of forms: (with-cleanup ((release h)) body...)",
						"written as (with-cleanup (release h) ...) the cleanup never runs",
					},
				})
			}
		})
		return nil
	},
}

AnalyzerWithCleanupForms warns about two degenerate spellings of the with-cleanup spec list, both of which run without complaint.

An EMPTY list makes the form a no-op wrapper around its body: it still runs and still returns the same value, so nothing at runtime distinguishes it from the body alone.

A BARE SYMBOL in the list is the missing-paren mistake that let-bindings catches for let, and it is the more dangerous of the two:

(with-cleanup (release h) (work))

parses as a spec list of two forms -- the symbol `release` and the symbol `h` -- neither of which does anything. The release never happens, and the program behaves exactly as if the cleanup had been written correctly right up until the body signals. A real cleanup form is a call; a bare symbol as one is always either this mistake or dead code.

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). A nil sexpr yields 0.

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 CollectFormals added in v1.49.0

func CollectFormals(formals *lisp.LVal, defs map[string]bool)

CollectFormals extracts symbol names from a formals list into defs, skipping the &rest, &optional and &key markers.

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 "". A nil sexpr yields "".

func ShouldFail added in v1.19.0

func ShouldFail(diags []Diagnostic, threshold Severity) bool

ShouldFail returns true if the diagnostics contain at least one finding at or above the given severity threshold.

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. Returns nil for a nil node.

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

	// Semantic indicates that this analyzer requires semantic analysis
	// (pass.Semantics != nil) to produce diagnostics. When true and
	// semantic analysis is not available, nolint directives targeting
	// this analyzer are treated as conditionally valid rather than unused.
	Semantic bool

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

	// EndPos is the end of the diagnostic range. When zero, editors
	// treat the diagnostic as zero-width (a single point). When set,
	// the range [Pos, EndPos) is highlighted.
	EndPos Position `json:"end_pos,omitempty"`

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

	// Related points to additional source locations that help explain the
	// diagnostic, such as the original definition that conflicts with the
	// current one.
	Related []RelatedInformation `json:"related,omitempty"`

	// Unnecessary marks the diagnostic as "unnecessary" code (e.g., unused
	// variables). Editors may render these with faded text.
	Unnecessary bool `json:"unnecessary,omitempty"`

	// Deprecated marks the diagnostic as a use of deprecated code. Editors
	// may render the flagged range with strikethrough text.
	Deprecated bool `json:"deprecated,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

	// IncludeDirs are directory names that override ShouldSkipDir during
	// workspace scanning. A directory matching any entry will be walked
	// even if it would normally be skipped (e.g. "_examples").
	IncludeDirs []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.
	//
	// The map and its slices are copied before use, so a map shared across
	// several runs is neither mutated nor read after the call returns.
	StdlibExports map[string][]analysis.ExternalSymbol

	// MacroExpander optionally expands user-macro calls at analysis time.
	// Embedders that boot a full environment can pass
	// &analysis.EnvMacroExpander{Env: env} to enable accurate symbol
	// resolution inside macro bodies.
	//
	// For most embedders, setting Env is simpler — it automatically creates
	// the expander and loads workspace macros. Use MacroExpander only when
	// you need a custom implementation.
	MacroExpander analysis.MacroExpander

	// Env is an optional runtime environment for macro expansion. When set
	// alongside a Workspace, workspace-defined macros are loaded into the
	// env and a MacroExpander is created automatically. This is the
	// recommended way for embedders to enable macro expansion — just pass
	// the env and elps handles the rest.
	//
	// If MacroExpander is also set, it takes precedence over Env.
	Env *lisp.LEnv
}

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.

cfg is not modified. The filename is stamped on a shallow copy (analysis.ConfigForFile), so one config may be reused across files and shared between goroutines. Issue #444: this used to assign cfg.Filename in place, which left the caller's config naming whichever file ran last and raced two goroutines linting different files through one config. A nil cfg is analysed as a zero config.

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) ReportNode added in v1.26.0

func (p *Pass) ReportNode(node *lisp.LVal, format string, args ...interface{})

ReportNode records a diagnostic spanning a node's source range. It extracts both start and end positions from the node. If the node lacks end position information, it falls back to using the symbol name length as a heuristic.

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 RelatedInformation added in v1.47.1

type RelatedInformation struct {
	Location Position `json:"location"`
	Message  string   `json:"message"`
}

RelatedInformation describes an additional source location associated with a diagnostic.

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 MaxSeverity added in v1.19.0

func MaxSeverity(diags []Diagnostic) Severity

MaxSeverity returns the most severe level found in the given diagnostics. Returns SeverityInfo if diags is empty (i.e., least severe / no problems). Severity ordering: SeverityError > SeverityWarning > SeverityInfo.

func ParseSeverity added in v1.19.0

func ParseSeverity(s string) (Severity, error)

ParseSeverity converts a string to a Severity value. Valid inputs: "error", "warning", "info".

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".

This delegates to String rather than repeating the unset default, so the two cannot drift apart again the way they did in #461.

func (Severity) String added in v1.17.0

func (s Severity) String() string

String renders the severity in the same vocabulary MarshalJSON emits and ParseSeverity/UnmarshalJSON accept: "error", "warning" or "info".

The unset zero value renders as "warning", the documented default (#461). It used to render as "unknown" — a word MarshalJSON never emitted and UnmarshalJSON and ParseSeverity both refuse — so the same diagnostic was a "warning" down the JSON path and an "unknown" down every path that called String (mcpserver builds its wire struct from String), and "unknown" could not be filtered for or read back under either name.

A value outside the enum still renders as "unknown": that is a corrupt Severity rather than an unset one, and there is no default it could be taken to mean.

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