diff --git a/packages/delisp-core/src/index.ts b/packages/delisp-core/src/index.ts index 972a28c6..23b9faa8 100644 --- a/packages/delisp-core/src/index.ts +++ b/packages/delisp-core/src/index.ts @@ -28,6 +28,8 @@ export function readSyntax(source: string): Syntax { return convertSyntax(readFromString(source)); } +export { lintModule } from "./linter"; + export { findSyntaxByOffset, findSyntaxByRange } from "./syntax-utils"; export { diff --git a/packages/delisp-core/src/linter.ts b/packages/delisp-core/src/linter.ts new file mode 100644 index 00000000..a63c305c --- /dev/null +++ b/packages/delisp-core/src/linter.ts @@ -0,0 +1,59 @@ +import { Identifier, Module } from "./syntax"; +import { isModule, traverseModule } from "./syntax-utils"; +import { printHighlightedExpr } from "./error-report"; + +function noUnusedVars(m: Module): void { + const used: Set = new Set(); + + traverseModule( + m, + (current, scope) => { + if (isModule(current)) { + return; + } else if (current.node.tag === "variable-reference") { + const binding = scope[current.node.name]; + if (binding) { + used.add(binding.identifier); + } + } else if (current.node.tag === "export") { + const binding = scope[current.node.value.name]; + if (binding) { + used.add(binding.identifier); + } + } + }, + (current, scope) => { + Object.entries(scope) + .filter(([_, binding]) => !used.has(binding.identifier)) + .filter(([_, binding]) => binding.node === current) + .forEach(([name, binding]) => { + console.warn( + printHighlightedExpr( + `"${name}" is defined but never used (no-unused-vars)`, + binding.identifier.location + ) + ); + }); + } + ); +} + +function noEmptyLet(m: Module): void { + traverseModule(m, curr => { + if (!isModule(curr) && curr.node.tag === "let-bindings") { + if (curr.node.bindings.length === 0) { + console.warn( + printHighlightedExpr( + `no variables bound in let expression (no-empty-let)`, + curr.location + ) + ); + } + } + }); +} + +export function lintModule(m: Module): void { + noUnusedVars(m); + noEmptyLet(m); +} diff --git a/packages/delisp-core/src/syntax-utils.ts b/packages/delisp-core/src/syntax-utils.ts index 328650a1..2bf59573 100644 --- a/packages/delisp-core/src/syntax-utils.ts +++ b/packages/delisp-core/src/syntax-utils.ts @@ -1,6 +1,8 @@ import { assertNever, InvariantViolation } from "./invariant"; import { + isDefinition, isExpression, + Identifier, ExpressionF, Expression, Module, @@ -154,6 +156,106 @@ function syntaxChildren(s: Syntax): Array> { } } +function moduleChildren(m: Module): Array> { + return m.body; +} + +function expressionBindings(e: Expression): Identifier[] { + switch (e.node.tag) { + case "function": + return e.node.lambdaList.positionalArgs; + case "let-bindings": + return e.node.bindings.map(b => b.variable); + default: + return []; + } +} + +function syntaxBindings(s: Syntax): Identifier[] { + if (isExpression(s)) { + return expressionBindings(s); + } else { + switch (s.node.tag) { + case "definition": + case "export": + case "type-alias": + return []; + default: + return assertNever(s.node); + } + } +} + +function moduleBindings(m: Module): Identifier[] { + return moduleChildren(m) + .filter(isDefinition) + .map(d => d.node.variable); +} + +type ASTNode = Module | Syntax; +export function isModule(x: ASTNode): x is Module { + return "tag" in x && x.tag === "module"; +} + +export interface Scope { + [varName: string]: { + node: ASTNode; + identifier: Identifier; + }; +} +type Visitor = (node: ASTNode, scope: Scope) => void; + +function createSyntaxScope( + s: Syntax, + parentScope: Scope = {} +): Scope { + return syntaxBindings(s).reduce( + (scope, binding) => ({ + ...scope, + [binding.name]: { node: s, identifier: binding } + }), + parentScope + ); +} + +function createModuleScope(m: Module): Scope { + return moduleBindings(m).reduce( + (scope, binding) => ({ + ...scope, + [binding.name]: { node: m, identifier: binding } + }), + {} + ); +} + +function traverseSyntax( + s: Syntax, + parentScope: Scope, + onEnter: Visitor, + onExit: Visitor +): void { + const scope = createSyntaxScope(s, parentScope); + onEnter(s, scope); + syntaxChildren(s).forEach(c => { + traverseSyntax(c, scope, onEnter, onExit); + }); + onExit(s, scope); +} + +const noop = () => {}; +export function traverseModule( + m: Module, + onEnter: Visitor = noop, + onExit: Visitor = noop +): void { + const moduleScope = createModuleScope(m); + onEnter(m, moduleScope); + moduleChildren(m).forEach(s => { + traverseSyntax(s, moduleScope, onEnter, onExit); + }); + onExit(m, moduleScope); +} + function syntaxPathFromRange( s: Syntax, start: number, diff --git a/packages/delisp/src/cmd-lint.ts b/packages/delisp/src/cmd-lint.ts index e6ef7ea0..2bbb2f1e 100644 --- a/packages/delisp/src/cmd-lint.ts +++ b/packages/delisp/src/cmd-lint.ts @@ -2,11 +2,12 @@ import { CommandModule } from "yargs"; import * as fs from "./fs-helpers"; -import { readModule } from "@delisp/core"; +import { lintModule, readModule } from "@delisp/core"; async function lintFile(file: string): Promise { const content = await fs.readFile(file, "utf8"); - readModule(content); + const m = readModule(content); + lintModule(m); } export const cmdLint: CommandModule = {