en Tutorial Editor Lint - chiba233/yume-dsl-token-walker GitHub Wiki
Tutorial: Editor Lint + Auto-fix
Home | Lint | Structural Query
Build a complete lint pipeline that validates DSL source, reports diagnostics with source positions, and auto-fixes common mistakes — the kind of thing you'd wire into a CodeMirror / Monaco editor or a CI check.
What you'll learn:
- Write lint rules with
LintRuleandLintContext - Attach auto-fixes to diagnostics
- Apply fixes atomically with
applyLintFixes - Override severity and disable rules per-project
- Handle rule errors with
onRuleErrorandfailFast - Wire lint to the same parser config as your runtime
Prerequisites: npm install yume-dsl-rich-text yume-dsl-token-walker
Step 1: A simple rule — no empty tags
The most basic rule: flag $$bold()$$ (inline tag with zero children) as a warning.
import { type LintRule } from "yume-dsl-token-walker";
export 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,
});
}
});
},
};
ctx.walk visits every node in the structural tree with depth/parent context. ctx.report emits a diagnostic. The span is the source range — required for editor squigglies and auto-fix.
Step 2: A rule with auto-fix
Add a fix to the diagnostic — applyLintFixes will use it:
export const noEmptyTagWithFix: 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: "" }],
},
});
}
});
},
};
Each Fix has one or more TextEdits. Each edit replaces a source range with new text. Empty newText = delete.
Step 3: A nesting depth rule
const MAX_DEPTH = 3;
export const maxNestingDepth: LintRule = {
id: "max-nesting-depth",
severity: "error",
check: (ctx) => {
ctx.walk(ctx.tree, (node, visitCtx) => {
if (
visitCtx.depth >= MAX_DEPTH &&
(node.type === "inline" || node.type === "block") &&
node.position
) {
ctx.report({
message: `Tag nesting too deep (depth ${visitCtx.depth}, max ${MAX_DEPTH})`,
span: node.position,
node,
});
}
});
},
};
Step 4: Run lint and apply fixes
import { lintStructural, applyLintFixes } from "yume-dsl-token-walker";
const source = `Hello $$bold()$$ world $$italic($$underline($$strike($$code(deep)$$)$$)$$)$$`;
// Run all rules
const diagnostics = lintStructural(source, {
rules: [noEmptyTagWithFix, maxNestingDepth],
});
console.log(diagnostics);
// [
// { ruleId: "no-empty-tag", severity: "warning", message: "Empty inline tag: $$bold()$$", ... },
// { ruleId: "max-nesting-depth", severity: "error", message: "Tag nesting too deep ...", ... },
// ]
// Apply all fixable diagnostics
const fixed = applyLintFixes(source, diagnostics);
// The empty $$bold()$$ is removed; the nesting error has no fix so it's left alone
How applyLintFixes works:
- Only diagnostics with a
fixfield are considered - Fixes are atomic per-fix — if any edit in a fix overlaps with a previously accepted edit, the entire fix is skipped
- First-wins by source offset
- Edits applied in reverse order so earlier offsets stay valid
Step 5: Wire to your parser config
If your runtime parser uses custom syntax or handlers, pass the same config to lint — otherwise lint may accept structures your parser would reject:
import { createParser, createSimpleInlineHandlers } from "yume-dsl-rich-text";
const parser = createParser({
handlers: createSimpleInlineHandlers(["bold", "italic"]),
allowForms: ["inline"], // block and raw disabled
});
const diagnostics = lintStructural(source, {
rules: [noEmptyTagWithFix],
parseOptions: {
handlers: parser.parse("", {}).length >= 0 ? undefined : undefined, // just use:
...parser, // Wrong! parseOptions is StructuralParseOptions, not Parser
},
});
The correct way — pass the same options you gave to createParser:
const parserOptions = {
handlers: createSimpleInlineHandlers(["bold", "italic"]),
allowForms: ["inline"] as const,
};
const parser = createParser(parserOptions);
const diagnostics = lintStructural(source, {
rules: [noEmptyTagWithFix],
parseOptions: parserOptions, // same config
});
Step 6: Severity overrides and disabling rules
const diagnostics = lintStructural(source, {
rules: [noEmptyTagWithFix, maxNestingDepth],
overrides: {
"no-empty-tag": "off", // disable entirely
"max-nesting-depth": "warning", // downgrade from error to warning
},
});
Step 7: Error handling
If a rule throws during check, the default behavior is to silently skip it and continue with other rules.
// Observe errors
const diagnostics = lintStructural(source, {
rules,
onRuleError: ({ ruleId, error }) => {
console.warn(`Rule "${ruleId}" crashed:`, error);
},
});
// Or fail fast — abort on first error
try {
const diagnostics = lintStructural(source, { rules, failFast: true });
} catch (error) {
// error.message includes the rule id
// error.cause is the original error (when it was an Error instance)
}
failFast takes precedence over onRuleError.
Complete example: CI lint check
import { lintStructural, type LintRule } from "yume-dsl-token-walker";
const rules: LintRule[] = [noEmptyTagWithFix, maxNestingDepth];
function lintFile(source: string): boolean {
const diagnostics = lintStructural(source, { rules, failFast: true });
for (const d of diagnostics) {
const pos = d.span.start;
console.log(
`${d.severity.toUpperCase()} [${d.ruleId}] ${d.message} (${pos.line}:${pos.column})`,
);
}
const hasErrors = diagnostics.some((d) => d.severity === "error");
return !hasErrors;
}
const ok = lintFile(source);
process.exit(ok ? 0 : 1);
Next steps
- Lint API reference — LintContext, LintOptions, Diagnostic, Fix, TextEdit
- Tutorial: Blog Renderer — rendering pipeline
- Tutorial: Game Dialogue — typed command output