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:

  1. Write lint rules with LintRule and LintContext
  2. Attach auto-fixes to diagnostics
  3. Apply fixes atomically with applyLintFixes
  4. Override severity and disable rules per-project
  5. Handle rule errors with onRuleError and failFast
  6. 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 fix field 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