DEV Community

Kazu
Kazu

Posted on

Keeping "add a rule" a one-line change: inside gomarklint''s rule engine

"Just add one more check" — and before you know it, a switch grows another branch, the config grows another case, and you're copy-pasting the same setup you already wrote somewhere else. If you've ever worked on code where rules or checks keep piling up, you probably recognize this kind of rot.

gomarklint is a Markdown linter, and it currently ships 24 checks (rules). Yet adding a new one touches just a function, one line in a table, and two spots in the config. The switch doesn't grow. No wiring to add. The cost of adding a rule stays flat no matter how many there are. This post takes that apart from the inside — how that state is kept.

First, let's run gomarklint

Before we go inside, let's see what the tool actually does, once. gomarklint is simple: it reads Markdown files, finds structural and link problems, and points at them with a file:line. Say you have a README with a link that goes nowhere.

# My Project

See the [docs]() for details.
Enter fullscreen mode Exit fullscreen mode

Run gomarklint on it and you get:

$ gomarklint README.md

Errors in README.md:
  README.md:3: [error] no-empty-links: link has empty destination: [docs]()

✖ 1 issues found
✓ Checked 1 file(s), 3 line(s) in 1ms
Enter fullscreen mode Exit fullscreen mode

Because an [error] was printed, the exit code is non-zero. In CI, this is where the build fails.

When this post says "rule," it means one of those checks that produces a single line of output like that. "Find empty links." "Check that heading levels don't skip." "Check for duplicate headings." There are 24 such checks today, and each one returns "which line, and what's wrong." gomarklint's own job is to collect those findings (the LintError you'll see in code shortly), sort them, and print them.

Here's the real question. How do you keep those 24 checks at "adding one is a one-line change"? We'll open it up in order — the diagnostic type, how rules are laid out, the shared preprocessing that skips code and HTML, and how severity wires into the exit code — and finish by adding a real rule through an actual PR (#106).

Even if you're not writing a linter — a validator, a CI check, a rule-based plugin system — if you write "a mechanism where items keep growing" in Go, this should be a template you can copy directly.

Rules pile up, and code usually rots

Take a check like that "find empty links" one, and now imagine 24 of them. Built naively, the dispatch and configuration rot a little more with every rule you add. The usual four traps:

  • Every new rule makes some switch longer. You can't tell where a new rule should be registered, so you get missed registrations and double registrations.
  • Small setup logic like "don't flag [x]() inside a code block" gets reimplemented in each rule. Fix it in one, and another stays stale.
  • The items you can write in the config file and the default values in code become two sources of truth, and one gets updated while the other drifts.
  • The "error or warning?" decision — the same one that just set our exit code — plus its counting and its effect on the exit code get scattered around, and CI fails or passes not quite as intended.

gomarklint would have stepped on every one of these if left alone. This post walks through how each is avoided, in order.

First, express diagnostics, config, and severity as minimal types

At the center of the engine sit three small types. Everything wobbles if these wobble, so they're kept deliberately plain.

The first is the violation itself.

type LintError struct {
    File     string
    Line     int
    Rule     string
    Message  string
    Severity string
}
Enter fullscreen mode Exit fullscreen mode

The point is that Rule and Severity are in there but are filled in by the engine, not the rule — as we'll see later. A rule function only has to return "which line, and what happened."

The second is per-rule configuration.

type RuleConfig struct {
    Enabled  bool
    Severity RuleSeverity
    Options  map[string]interface{}
}
Enter fullscreen mode Exit fullscreen mode

The third is severity, which has only three values.

const (
    SeverityError   RuleSeverity = "error"   // presence of this makes the exit non-zero
    SeverityWarning RuleSeverity = "warning" // reported, but doesn't fail the run
    SeverityOff     RuleSeverity = "off"     // disabled
)
Enter fullscreen mode Exit fullscreen mode

If there's an error, CI fails. Hold onto just that one fact, and the rest of the branching comes back to it.

Rules are functions, not an interface

This is what changed the most in the engine.

The obvious design is to define a Rule interface and make each rule implement Check(). gomarklint doesn't do that. A rule is just a function, and the functions are laid out in a table.

They even split into two families by signature. One takes just the raw slice of lines — the simple rules.

var simpleRules = []struct {
    name string
    fn   func(string, []string, int) []rule.LintError
}{
    {"final-blank-line", rule.CheckFinalBlankLine},
}
Enter fullscreen mode Exit fullscreen mode

The other takes a shared preprocessing result, *preprocess.Context, which we'll get to. Most rules live on this side now.

var contextRules = []struct {
    name string
    fn   func(string, *preprocess.Context, int) []rule.LintError
}{
    {"no-bare-urls", rule.CheckNoBareURLs},
    {"single-h1", rule.CheckSingleH1},
    {"duplicate-heading", rule.CheckDuplicateHeadings},
    {"unclosed-code-block", rule.CheckUnclosedCodeBlocks},
    {"empty-alt-text", rule.CheckEmptyAltText},
    {"no-empty-links", rule.CheckNoEmptyLinks},
    // …and more
}
Enter fullscreen mode Exit fullscreen mode

Running the rules is then just iterating that table.

for _, r := range contextRules {
    if l.config.IsEnabled(r.name) {
        errs = append(errs, l.withSeverity(r.fn(path, ctx, offset), r.name)...)
    }
}
Enter fullscreen mode Exit fullscreen mode

Only the rules that take options — like heading-level's minLevel or the style of consistent-*, and max-line-length — are kept out of the table and listed below with explicit ifs, because they take extra arguments. The trade is: "the option-free majority goes in a table, the option-taking minority is spelled out."

There's a cost to not using an interface. You can't do dynamic extension like dropping in a rule as a plugin at runtime. But the payoff was bigger.

  • Rules become stateless pure functions. Give input, get output — so tests fall straight out as table-driven.
  • Which rules run is fully visible by reading one slice. Nothing hides behind an abstraction.
  • Registering a new rule is, literally, one line in a slice.

Write "skip inside code blocks" once

Of those traps, the one that hurts most quietly is "the logic to skip inside code blocks scattered across every rule."

The heading rule, the link rule, the emphasis rule — all of them want to say "but not inside a fenced code block." Done naively, each rule starts counting "am I inside a fence right now?" on its own. And then one rule misses indented code blocks, another misses HTML comments — inconsistencies creep in. This actually happened in gomarklint, and we took inventory of it in issue #337.

The answer is preprocess.Scan. It walks the file's lines exactly once, classifies each line into "fenced code / indented code / HTML block / HTML comment," and hands it to every rule as a *preprocess.Context.

ctx := preprocess.Scan(lines)
allErrors := l.collectLineErrors(path, lines, ctx, offset)
Enter fullscreen mode Exit fullscreen mode

The rule side no longer counts anything itself. It just asks the shared verdict.

if inBlockContext(ctx, i) {
    continue // inside code/HTML, so this rule ignores it
}
Enter fullscreen mode Exit fullscreen mode

The clever bit here is that Context deliberately does not offer a single "is this line skippable?" convenience method. It exposes the four contexts individually. There's a reason: max-line-length and no-hard-tabs, for instance, do want to look inside fenced code (line length and tabs should be flagged even there). So "what to skip" is the rule's choice. The inBlockContext helper for the majority is just a thin OR over those four.

func inBlockContext(ctx *preprocess.Context, i int) bool {
    return ctx.InFencedCode(i) || ctx.InIndentedCode(i) ||
        ctx.InHTMLBlock(i) || ctx.InHTMLComment(i)
}
Enter fullscreen mode Exit fullscreen mode

Context is trimmed hard for speed. It borrows the input line slice rather than copying it. The context flags are packed one byte per line, and the "sanitized line" with inline code blanked out is stored in a map only for the lines that differ. Since Scan runs over every file, that one byte matters. But that's a story about designing for speed, so I'll leave the deep dive to the benchmarking post. Here the design win — "write the skip logic once and share it with everyone" — is the real point.

Severity and exit code are wired on the engine side

I said the rule functions fill in neither Rule nor Severity. What fills them is this withSeverity.

func (l *Linter) withSeverity(errs []rule.LintError, ruleName string) []rule.LintError {
    sev := l.config.RuleSeverity(ruleName)
    for i := range errs {
        errs[i].Rule = ruleName
        errs[i].Severity = sev
    }
    return errs
}
Enter fullscreen mode Exit fullscreen mode

The rule body returns only the "facts," and whether it's an error or a warning is decided by the config. The same rule can be an error for one team and a warning for another. Finally, the number of errors is counted, and if there's even one, the exit is non-zero. The basis for the CI gate is consolidated into this one place.

Prevent config/code drift with a contract

The flexibility of .gomarklint.json comes from RuleConfig.UnmarshalJSON accepting three shorthands.

{
  "rules": {
    "no-empty-links": true,                        // enabled / error
    "no-trailing-punctuation": "warning",          // enabled / warning
    "max-line-length": { "enabled": true, "lineLength": 120 }  // full spec
  }
}
Enter fullscreen mode Exit fullscreen mode

true means "enabled / error," "warning" means "enabled / warning," and an object is a full spec. The writer's convenience (the human) is absorbed by one method on the parser side.

Here's the two-sources-of-truth trap. There's a default definition in the Go code too, config.Default(), and the JSON string that gomarklint init emits (DefaultConfigJSON) carries the same list. These two are kept in sync by a manual contract — and the code says so in a comment. It is not auto-generated. When you add a rule, you update both; that's the promise.

Actually adding one: no-empty-links (MD042)

With the tooling in place, let's trace it through a real PR. It's no-empty-links (MD042 in markdownlint terms, empty-link detection), added in issue #106. It catches links whose destination is empty, like [text]().

1. Write one rule function

In internal/rule/no_empty_links.go, write one function with the contextRules signature. The parts we've built so far just work.

func CheckNoEmptyLinks(filename string, ctx *preprocess.Context, offset int) []LintError {
    var errs []LintError

    for i := 0; i < ctx.Len(); i++ {
        if inBlockContext(ctx, i) {
            continue // ignore links inside code/HTML
        }

        // Line with inline code blanked out, so a link-looking
        // `[x]()` inside a code span isn't a false positive.
        line := ctx.Sanitized(i)
        if !strings.Contains(line, "](") {
            continue
        }

        for _, match := range findEmptyLinks(line) {
            errs = append(errs, LintError{
                File:    filename,
                Line:    offset + i + 1,
                Message: fmt.Sprintf("no-empty-links: link has empty destination: %s", match),
            })
        }
    }
    return errs
}
Enter fullscreen mode Exit fullscreen mode

All that's written here is the judgment of "what counts as an empty link" (findEmptyLinks catches [](), [](#), [](<>), and the image variants). "Skip code blocks," "blank out inline code," "adjust the line number by offset" — those all just call the shared parts that already exist. Rule and Severity aren't filled in — the engine adds them later.

2. Add one line to the table

var contextRules = []struct { /* … */ }{
    // …
    {"no-empty-links", rule.CheckNoEmptyLinks}, // ← just this
}
Enter fullscreen mode Exit fullscreen mode

Dispatch is done. No switch, no wiring to add.

3. Register in config (two spots)

Add no-empty-links to both config.Default() and DefaultConfigJSON. This is the "keep in sync by hand" contract in practice.

4. Add tests

Write internal/rule/no_empty_links_test.go table-driven, like the other rules. Because the rule is a pure function, you just line up input lines and expected violations.

Of the four steps, the only one where you really "think" is step 1. The rest is one line, two spots, and a boilerplate test. Even at 24 rules, this shape hasn't broken.

Extensibility isn't adding layers

Many linters get slower and harder to extend as they grow, because every rule leans on a shared, complex framework. gomarklint went the other way. Each rule is an independent, stateless function, and only the thing everyone needs — the code/HTML context check — was pulled out into a single preprocessing pass. Simplicity scales better than abstraction. Even now, with the engine carrying 24 rules, that still holds.

The result is:

  • Adding a rule is one function + one line in a table + two config spots,
  • Which rules run is visible by reading a slice,
  • It scales linearly with repository size.

Extensibility is often assumed to mean stacking layers — but sometimes it's about not stacking them. If you ever want to enforce your own documentation style guide, you can copy this shape directly. Write one function, add one line to the table. That's it.


For the background on parsing and the overall architecture, my earlier post Inside gomarklint: Architecture, Rule Engine, and How to Extend It is also worth a look (the engine has since evolved into the shape described here).

GitHub logo shinagawa-web / gomarklint

Catch broken links before your readers do.

gomarklint

Test codecov Go Report Card Go Reference License: MIT OpenSSF Scorecard OpenSSF Best Practices npm version npm downloads

English | 日本語

gomarklint catching a broken link and structure issues

Catch broken links before your readers do — and keep your Markdown clean while you're at it. 100,000+ lines in ~170ms, single binary, no Node.js required.

Quick install (macOS / Linux):

curl -fsSL https://raw.githubusercontent.com/shinagawa-web/gomarklint/main/install.sh | sh
Enter fullscreen mode Exit fullscreen mode

Download binary (no Go required):

Download the latest binary for your platform from GitHub Releases.

# macOS / Linux
tar -xzf gomarklint_Darwin_x86_64.tar.gz
sudo mv gomarklint /usr/local/bin/
# or install to user-local directory (no sudo required)
mkdir -p ~/.local/bin && mv gomarklint ~/.local/bin/
Enter fullscreen mode Exit fullscreen mode
# Windows (PowerShell)
Expand-Archive -Path gomarklint_Windows_x86_64.zip -DestinationPath "$env:LOCALAPPDATA\Programs\gomarklint"
# Add to PATH (run once)
[Environment]::SetEnvironmentVariable("PATH", $env:PATH + ";$env:LOCALAPPDATA\Programs\gomarklint", "User")
Enter fullscreen mode Exit fullscreen mode

Via Homebrew:

brew install shinagawa-web/tap/gomarklint
Enter fullscreen mode Exit fullscreen mode

Via npm:

npm install -g @shinagawa-web/gomarklint
Enter fullscreen mode Exit fullscreen mode

Via go install:

go install github.com/shinagawa-web/gomarklint/v3@latest
Enter fullscreen mode Exit fullscreen mode
  • Catch broken…

Top comments (0)