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 -->
gomarklint uses the same HTML comment approach, with its own prefix:
<!-- gomarklint-disable MD013 -->
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
}
The interesting part is that names plays two different roles depending on allDisabled:
- If
allDisabledis false:namesis the list of disabled rules - If
allDisabledis true:namesis 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
}
disabledSet
This is just a map from absolute line numbers to their disable state:
type disabledSet map[int]lineDisable
"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
}
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
}
}
- Find
<!--and-->to extract the comment body - Check for the
gomarklint-prefix - 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
}
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→ resetblockStateentirely - Named
enableinside an all-disabled block → add to exceptions list - Named
enableinside 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:
- All-disabled beats named-disable. If a line is already fully disabled, adding named rules has no effect.
-
Line-specific directives beat block-level. A
disable-lineordisable-next-linealways 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...)}
}
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
}
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
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
}
}
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
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
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)