DEV Community

Cover image for How I Built Inline Disable Comments for a Go Markdown Linter
Kazu
Kazu

Posted on

How I Built Inline Disable Comments for a Go Markdown Linter

If you've used ESLint, you've probably written // eslint-disable-next-line at least once. It's one of those small features that makes a linter actually usable in the real world — because no rule is right 100% of the time.

I recently built the same feature for gomarklint, a fast Markdown linter written in Go. This post walks through the design decisions and implementation details.

The Problem

gomarklint enforces rules like "no bare URLs," "headings must not skip levels," and "lines must not exceed 120 characters." These rules are useful globally, but sometimes a specific line is legitimately different:

  • A changelog entry that intentionally contains a bare URL
  • A generated code block where line length doesn't matter
  • A one-off exception that would be wrong to suppress project-wide

What we want is a way to suppress violations inline, scoped to exactly the lines that need it — just like markdownlint does with HTML comments:

<!-- markdownlint-disable MD013 -->
A very long line that is intentionally long...
<!-- markdownlint-enable MD013 -->
Enter fullscreen mode Exit fullscreen mode

gomarklint uses the same HTML comment approach, with its own prefix:

<!-- gomarklint-disable MD013 -->
Enter fullscreen mode Exit fullscreen mode

What We Need to Support

There are four directive types, covering both block-level and per-line use cases:

Directive Scope
<!-- gomarklint-disable --> Disables all rules from this line onward
<!-- gomarklint-disable MD013 --> Disables specific rules from this line onward
<!-- gomarklint-enable --> Re-enables all rules (ends a block disable)
<!-- gomarklint-enable MD013 --> Re-enables specific rules
<!-- gomarklint-disable-line --> Disables all rules on this line only
<!-- gomarklint-disable-line MD013 --> Disables specific rules on this line only
<!-- gomarklint-disable-next-line --> Disables all rules on the next line only
<!-- gomarklint-disable-next-line MD013 --> Disables specific rules on the next line only

The Data Model

The core challenge is representing "which rules are disabled on line N" efficiently. I ended up with three types.

lineDisable

This struct describes the disable state for a single line:

type lineDisable struct {
    allDisabled bool
    names       []string
}
Enter fullscreen mode Exit fullscreen mode

The interesting part is that names plays two different roles depending on allDisabled:

  • If allDisabled is false: names is the list of disabled rules
  • If allDisabled is true: names is the list of exceptions (rules that are not suppressed)

This lets the same struct handle both "disable everything" and "disable only MD013" without needing separate types. The isRuleDisabled method encodes this logic:

func (ld lineDisable) isRuleDisabled(ruleName string) bool {
    if ld.allDisabled {
        for _, r := range ld.names {
            if r == ruleName {
                return false // explicitly re-enabled
            }
        }
        return true
    }
    for _, r := range ld.names {
        if r == ruleName {
            return true
        }
    }
    return false
}
Enter fullscreen mode Exit fullscreen mode

disabledSet

This is just a map from absolute line numbers to their disable state:

type disabledSet map[int]lineDisable
Enter fullscreen mode Exit fullscreen mode

"Absolute line number" matters here because gomarklint strips YAML frontmatter before linting. We need to track an offset so that line numbers stay consistent with what the user sees in their editor.

blockState

This tracks the current block-level disable state as we scan through lines:

type blockState struct {
    allDisabled bool
    exceptions  []string // re-enabled rules when allDisabled=true
    rules       []string // named disabled rules when allDisabled=false
}
Enter fullscreen mode Exit fullscreen mode

blockState is a temporary, mutable value — it gets updated as we encounter disable and enable directives, and its current state is applied to each line we visit.

Parsing: Step 1 — Extracting a Directive from a Line

Before we can build the disable map, we need to extract directives from individual lines. parseDirectiveLine handles this:

func parseDirectiveLine(line string) (directive string, ruleNames []string) {
    start := strings.Index(line, "<!--")
    if start == -1 {
        return "", nil
    }
    end := strings.Index(line[start:], "-->")
    if end == -1 {
        return "", nil
    }
    inner := strings.TrimSpace(line[start+4 : start+end])
    const prefix = "gomarklint-"
    if !strings.HasPrefix(inner, prefix) {
        return "", nil
    }
    parts := strings.Fields(inner[len(prefix):])
    if len(parts) == 0 {
        return "", nil
    }
    switch parts[0] {
    case "disable", "enable", "disable-line", "disable-next-line":
        return parts[0], parts[1:]
    default:
        return "", nil
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Find <!-- and --> to extract the comment body
  2. Check for the gomarklint- prefix
  3. Split the rest into the directive keyword and optional rule names

Rule names are everything after the directive keyword — so <!-- gomarklint-disable MD013 MD032 --> yields ["MD013", "MD032"].

Parsing: Step 2 — Building the disabledSet

parseDisableComments scans all lines, maintains a running blockState, and builds the final disabledSet:

func parseDisableComments(lines []string, offset int) disabledSet {
    set := make(disabledSet)
    var bs blockState

    for i, line := range lines {
        absLine := i + 1 + offset
        directive, ruleNames := parseDirectiveLine(line)

        switch directive {
        case "disable":
            if len(ruleNames) == 0 {
                bs = blockState{allDisabled: true}
            } else {
                bs.rules = append(bs.rules, ruleNames...)
            }
        case "enable":
            if len(ruleNames) == 0 {
                bs = blockState{} // full reset
            } else if bs.allDisabled {
                bs.exceptions = append(bs.exceptions, ruleNames...)
            } else {
                bs.rules = removeAll(bs.rules, ruleNames)
            }
        case "disable-line":
            set.addLine(absLine, ruleNames)
        case "disable-next-line":
            if i+1 < len(lines) {
                set.addLine(absLine+1, ruleNames)
            }
        }

        bs.applyTo(set, absLine) // apply current block state to this line
    }

    return set
}
Enter fullscreen mode Exit fullscreen mode

bs.applyTo(set, absLine) is called on every line, not just lines with directives. This is how block-level disabling propagates — the current block state "stamps" each line as we pass it.

disable-line and disable-next-line write directly to set without touching blockState, since they're scoped to one line only.

enable has two behaviors depending on the current block state:

  • Full enable → reset blockState entirely
  • Named enable inside an all-disabled block → add to exceptions list
  • Named enable inside a named-disable block → remove from the rules list

Priority: What Wins When Rules Conflict?

There are cases where a line can be affected by multiple directives. The priority rules are:

  1. All-disabled beats named-disable. If a line is already fully disabled, adding named rules has no effect.
  2. Line-specific directives beat block-level. A disable-line or disable-next-line always takes effect regardless of what the block state says.

addLine enforces rule 1:

func (d disabledSet) addLine(line int, ruleNames []string) {
    existing, exists := d[line]
    if exists && existing.allDisabled && len(existing.names) == 0 {
        return // fully disabled with no exceptions — nothing to add
    }
    if len(ruleNames) == 0 {
        d[line] = lineDisable{allDisabled: true}
        return
    }
    d[line] = lineDisable{names: append(existing.names, ruleNames...)}
}
Enter fullscreen mode Exit fullscreen mode

Integration: Filtering Violations

With the disabledSet built, filtering in collectErrors is a map lookup per violation:

var disabled disabledSet
if strings.Contains(body, "gomarklint-disable") {
    disabled = parseDisableComments(lines, offset)
}

allErrors := l.collectLineErrors(path, lines, offset)
// ... external link checks ...

if len(disabled) > 0 {
    filtered := allErrors[:0]
    for _, e := range allErrors {
        if !disabled.isDisabled(e.Line, e.Rule) {
            filtered = append(filtered, e)
        }
    }
    allErrors = filtered
}
Enter fullscreen mode Exit fullscreen mode

There's a small but meaningful optimization: we only call parseDisableComments if the file body actually contains the string "gomarklint-disable". For files without any directives — the common case — we skip the parsing step entirely. This keeps the hot path fast.

The filter uses the in-place slice trick (filtered := allErrors[:0]) to avoid an extra allocation.

Intentional "Silent Fail" for Invalid Rule Names

What happens if a user writes a typo or a nonexistent rule name?

<!-- gomarklint-disable-line no-bare-url -->
https://example.com
Enter fullscreen mode Exit fullscreen mode

The correct rule name is no-bare-urls (with an s). The result: the directive is silently ignored, and the violation is still reported.

This is intentional. The lookup in isRuleDisabled is a plain string comparison:

for _, r := range ld.names {
    if r == ruleName {
        return true
    }
}
Enter fullscreen mode Exit fullscreen mode

If the name doesn't match any known rule, the function returns false and the violation passes through. There's no registry lookup or error message.

This matches the behavior of markdownlint and most other linters. A warning system for invalid directive names could be added, but for now the behavior is predictable: wrong name → no suppression.

We have explicit E2E tests for this to make sure it stays intentional:

<!-- gomarklint-disable-line no-bare-url -->
https://wrong-rule-name.example.com

<!-- gomarklint-disable-next-line nonexistent-rule -->
https://nonexistent-rule.example.com
Enter fullscreen mode Exit fullscreen mode

Both lines are expected to produce violations — the test fails if they're silently suppressed.

Testing Strategy

The feature is tested at three levels:

Unit tests (disable_comment_test.go) cover parseDirectiveLine and parseDisableComments in isolation — 13+ cases for directive parsing, edge cases for block/line scope interaction, priority rules, and frontmatter offset handling.

Integration tests (linter_test.go) run linter.Run() against in-memory Markdown strings and verify which violations survive filtering. These tests also include a case that confirms parsing is skipped when no directive keyword is present in the file.

E2E tests (e2e_test.go) run the actual binary against a fixture file (disable_comment.md) and check the reported line numbers. This catches any disconnect between the parsing logic and how violations are reported to the user.

The Full Picture

Here's the complete flow, end to end:

Markdown file
    │
    ▼
StripFrontmatter()  →  body string + offset int
    │
    ▼
strings.Contains("gomarklint-disable")?
    │   yes
    ▼
parseDisableComments(lines, offset)
    │   scan each line:
    │     parseDirectiveLine()  →  directive + ruleNames
    │     update blockState
    │     bs.applyTo(set, absLine)
    ▼
disabledSet  (map[int]lineDisable)
    │
    ▼
collectLineErrors()  →  []LintError
    │
    ▼
filter: !disabled.isDisabled(e.Line, e.Rule)
    │
    ▼
sorted []LintError  →  reported to user
Enter fullscreen mode Exit fullscreen mode

The implementation is about 170 lines of Go (internal/linter/disable_comment.go). Most of the logic lives in the data model — the filtering step itself is just a map lookup.


The part I found most interesting was representing "all disabled except these" and "only these are disabled" with the same struct, by flipping what names means depending on allDisabled. It kept the code flat while covering the full directive surface.

Full implementation: gomarklint/internal/linter/disable_comment.go

gomarklint is an open-source Markdown linter written in Go.

Top comments (0)