en Lint - chiba233/yume-dsl-token-walker GitHub Wiki
Lint
Structural Query | Structural Slice | Home
A minimal lint framework for validating DSL source against custom rules. Rules operate on the structural parse tree and report diagnostics with optional auto-fixes.
When to use:
- Enforce conventions (no empty tags, max nesting depth, required attributes)
- Catch mistakes before rendering (unclosed tags are already handled by the parser, but semantic rules are up to you)
- Auto-fix common issues (remove empty tags, rename deprecated tags)
Demo: "no empty tags" with auto-fix
A complete rule that detects empty inline tags and offers to remove them:
import { lintStructural, applyLintFixes, type LintRule } from "yume-dsl-token-walker";
const noEmptyTag: LintRule = {
id: "no-empty-tag",
severity: "warning",
check: (ctx) => {
ctx.walk(ctx.tree, (node) => {
if (node.type === "inline" && node.children.length === 0 && node.position) {
ctx.report({
message: `Empty inline tag: ${node.tag}`,
span: node.position,
node,
fix: {
description: "Remove empty tag",
edits: [{ span: node.position, newText: "" }],
},
});
}
});
},
};
const source = "Hello $$bold()$$ world";
const diagnostics = lintStructural(source, { rules: [noEmptyTag] });
// diagnostics[0].message === "Empty inline tag: bold"
const fixed = applyLintFixes(source, diagnostics);
// fixed === "Hello world"
How lintStructural works
- Parses the source with
trackPositions: true(always — you don't need to set it) - Runs each rule's
check(ctx)function with aLintContext - Collects all diagnostics reported via
ctx.report() - Returns diagnostics sorted by source offset
const lintStructural: (source: string, options: LintOptions) => Diagnostic[];
If you use custom syntax or handlers, pass them in parseOptions — otherwise lint may accept structures your parser would reject:
const diagnostics = lintStructural(source, {
rules: [noEmptyTag],
parseOptions: {
handlers: myHandlers,
syntax: mySyntax,
allowForms: ["inline"],
},
});
How applyLintFixes works
Applies fixable diagnostics to source text. Only diagnostics with a fix field are considered.
const applyLintFixes: (source: string, diagnostics: Diagnostic[]) => string;
Key design: atomic per-fix, first-wins.
- Each
Fixhas one or moreTextEdits. If any edit within a fix overlaps with a previously accepted edit, the entire fix is skipped — not just the overlapping edit. This prevents compound fixes from leaving the source in an invalid intermediate state. - When two fixes' edits overlap, the fix whose earliest edit comes first in source order wins. The later fix is rejected entirely.
- A fix whose own edits overlap internally (malformed fix) is also rejected.
- Within accepted fixes, edits are applied in reverse source order so earlier offsets remain valid.
Rule errors: failFast and onRuleError
If a rule throws during check, the error is silently ignored by default — other rules continue running and you get diagnostics from the rules that succeeded.
Two opt-in mechanisms:
onRuleError — observe and continue
const diagnostics = lintStructural(source, {
rules,
onRuleError: ({ ruleId, error }) => {
console.warn(`Rule "${ruleId}" failed:`, error);
},
});
// other rules still ran, their diagnostics are returned
failFast — abort immediately
try {
const diagnostics = lintStructural(source, {
rules,
failFast: true,
});
// safe to trust: no rule crashed
} catch (error) {
// one of the lint rules itself failed
// error.message includes the rule id
// error.cause is the original error (when it was an Error instance)
}
failFast takes precedence over onRuleError — if both are set, the error is thrown immediately without calling onRuleError.
Demo: multi-rule lint with severity overrides
import { lintStructural, type LintRule } from "yume-dsl-token-walker";
const noEmptyTag: LintRule = {
id: "no-empty-tag",
severity: "warning",
check: (ctx) => {
ctx.walk(ctx.tree, (node) => {
if (node.type === "inline" && node.children.length === 0 && node.position) {
ctx.report({ message: `Empty: ${node.tag}`, span: node.position, node });
}
});
},
};
const maxDepth: LintRule = {
id: "max-depth",
severity: "error",
check: (ctx) => {
ctx.walk(ctx.tree, (node, visitCtx) => {
if (visitCtx.depth >= 3 && node.type === "inline" && node.position) {
ctx.report({
message: `Nesting too deep (${visitCtx.depth})`,
span: node.position,
node,
});
}
});
},
};
const diagnostics = lintStructural("$$bold($$italic($$underline($$strike(x)$$)$$)$$)$$", {
rules: [noEmptyTag, maxDepth],
overrides: {
"no-empty-tag": "off", // disable this rule entirely
"max-depth": "warning", // downgrade from error to warning
},
});
LintContext
Passed to each rule's check function:
interface LintContext {
source: string;
tree: StructuralNode[];
report: (info: ReportInfo) => void;
findFirst: (nodes: StructuralNode[], predicate: StructuralPredicate) => StructuralNode | undefined;
findAll: (nodes: StructuralNode[], predicate: StructuralPredicate) => StructuralNode[];
walk: (nodes: StructuralNode[], visitor: StructuralVisitor) => void;
}
| Field | Description |
|---|---|
source |
Original source text |
tree |
Structural tree (always parsed with trackPositions: true) |
report |
Emit a diagnostic — call this for every issue found |
findFirst |
Same as the standalone findFirst |
findAll |
Same as the standalone findAll |
walk |
Same as the standalone walkStructural |
Type reference
LintRule
interface LintRule {
id: string;
severity?: DiagnosticSeverity; // default: "warning"
check: (ctx: LintContext) => void;
}
LintOptions
interface LintOptions {
rules: LintRule[];
overrides?: Record<string, DiagnosticSeverity | "off">;
parseOptions?: Omit<StructuralParseOptions, "trackPositions">;
onRuleError?: (context: { ruleId: string; error: unknown }) => void;
failFast?: boolean;
}
Diagnostic
interface Diagnostic {
ruleId: string;
severity: DiagnosticSeverity;
message: string;
span: SourceSpan;
node?: StructuralNode;
fix?: Fix;
}
DiagnosticSeverity
type DiagnosticSeverity = "error" | "warning" | "info" | "hint";
Fix / TextEdit
interface Fix {
description: string;
edits: TextEdit[];
}
interface TextEdit {
span: SourceSpan;
newText: string; // empty string to delete
}
Follows the LSP TextEdit model — each edit specifies a source range to replace. Use empty newText to delete, or a range with start === end to insert.
ReportInfo
type ReportInfo = Omit<Diagnostic, "ruleId" | "severity"> & {
severity?: DiagnosticSeverity;
};
Argument to ctx.report(). ruleId is added by the runner; severity defaults to the rule's severity.