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

  1. Parses the source with trackPositions: true (always — you don't need to set it)
  2. Runs each rule's check(ctx) function with a LintContext
  3. Collects all diagnostics reported via ctx.report()
  4. 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 Fix has one or more TextEdits. 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.