Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/delisp-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
59 changes: 59 additions & 0 deletions packages/delisp-core/src/linter.ts
Original file line number Diff line number Diff line change
@@ -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<Identifier> = 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);
}
102 changes: 102 additions & 0 deletions packages/delisp-core/src/syntax-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { assertNever, InvariantViolation } from "./invariant";
import {
isDefinition,
isExpression,
Identifier,
ExpressionF,
Expression,
Module,
Expand Down Expand Up @@ -154,6 +156,106 @@ function syntaxChildren<I>(s: Syntax<I>): Array<Expression<I>> {
}
}

function moduleChildren<I>(m: Module<I>): Array<Syntax<I>> {
return m.body;
}

function expressionBindings<I>(e: Expression<I>): 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<I>(s: Syntax<I>): Identifier[] {
if (isExpression(s)) {
return expressionBindings(s);
} else {
switch (s.node.tag) {
case "definition":

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should definition also include a binding?

@tkers tkers Apr 9, 2019

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are global bindings, so handled by the moduleBindings. This function returns the bindings that only exist within the node.

case "export":
case "type-alias":

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get the rule is unused-variables, but maybe it would make sense to extend it to unused types as well?

return [];
default:
return assertNever(s.node);
}
}
}

function moduleBindings<I>(m: Module<I>): Identifier[] {
return moduleChildren(m)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have expected this in syntaxBindings actually.

.filter(isDefinition)
.map(d => d.node.variable);
}

type ASTNode<I> = Module<I> | Syntax<I>;
export function isModule<I>(x: ASTNode<I>): x is Module<I> {
return "tag" in x && x.tag === "module";
}

export interface Scope<I> {
[varName: string]: {
node: ASTNode<I>;
identifier: Identifier;
};
}
type Visitor<I> = (node: ASTNode<I>, scope: Scope<I>) => void;

function createSyntaxScope<I>(
s: Syntax<I>,
parentScope: Scope<I> = {}
): Scope<I> {
return syntaxBindings(s).reduce(
(scope, binding) => ({
...scope,
[binding.name]: { node: s, identifier: binding }
}),
parentScope
);
}

function createModuleScope<I>(m: Module<I>): Scope<I> {
return moduleBindings(m).reduce(
(scope, binding) => ({
...scope,
[binding.name]: { node: m, identifier: binding }
}),
{}
);
}

function traverseSyntax<I>(
s: Syntax<I>,
parentScope: Scope<I>,
onEnter: Visitor<I>,
onExit: Visitor<I>
): 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<I>(
m: Module<I>,
onEnter: Visitor<I> = noop,
onExit: Visitor<I> = noop
): void {
const moduleScope = createModuleScope(m);
onEnter(m, moduleScope);
moduleChildren(m).forEach(s => {
traverseSyntax(s, moduleScope, onEnter, onExit);
});
onExit(m, moduleScope);
}

function syntaxPathFromRange<I>(
s: Syntax<I>,
start: number,
Expand Down
5 changes: 3 additions & 2 deletions packages/delisp/src/cmd-lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const content = await fs.readFile(file, "utf8");
readModule(content);
const m = readModule(content);
lintModule(m);
}

export const cmdLint: CommandModule = {
Expand Down