Writing a linter is fun: introducing Marcdouane
The other day I had some inquiries about how markdownlint worked and
ended up reading the code and not exactly liking all of it. Which is
fair, because the project is a couple months short of being 14 year
old and boasts above 16 millions total downloads on Rubygems.
Some time ago I had this idea of writing my own linter so I went back to that folder and ended up coding away for the weekend, and using some great gems along the way which is why I'm writing this post and introducing another Markdown linter to the world: Marcdouane.
What's in it? Not much, but it's backed by some really good gems that let you write the code that you need and not much more.
dry/cli
I've always been a big fan of the DRY gems though some of them are too smart for me and I'm still stuck at trying to understand their READMEs.
However dry/cli is not one of them, and it's nice to get the nice
banner and help for free:
13:32 ~/build/freesteph/sandbox/marcdouane £ ./bin/marcdouane check --help
Command:
marcdouane check
Usage:
marcdouane check FILES
Description:
Check the Markdown of one or more file
Arguments:
FILES # REQUIRED Files to check against
Options:
--[no-]verbose # Verbose output, default: false
--config=VALUE # Configuration file
--help, -h # Print this help
Having every command stowed into a subclass of Dry::CLI::Command
provides structure and lots of leeway to implement the execution
logic.
dry/configurable
Most projects, particularly linters, need to be configured in some
way. This is a breeze with dry/configurable which allows me to do
some Rubocop-style configuration like this:
LineLength:
maximum_line_length: 42
module Marcoudane
module Rules
class Rule
extend Dry::Configurable
# [...]
end
class LineLength < Rule
setting :maximum_line_length, default: 80, reader: true
# [...]
end
end
end
# somewhere in the CLI:
def parse_config!(path)
config = YAML.load_file(path)
config.each do |klass, hash|
hash.each do |key, value|
rule_class = "Marcdouane::Rules::#{klass}"
const_get(rule_class).class_eval do
self.config[key.to_sym] = value
end
end
end
end
Nice and declarative, very dry.
dry/events
When I first wrote the linter I was relying on error-raising to signal errors on the linted file. This doesn't really work because it forces a fail-fast behaviour: raising an error means stopping on the first occurence of an error (say, the first line that is over 80 chars) and in turn stopping on the first file that fails.
This can be worked around with a bunch of rescue blocks but it's a lot
easier to not fail like a drama queen and just notify that a line
contains an error. Pub/sub works well here, so I dropped in
dry/events:
module Marcdouane
module Rules
class Rule
include Dry::Events::Publisher[:marcdouane]
def initialize(file, options)
# [...]
register_event("rule.error")
end
def error!(machine_line_number, message = nil)
# [...]
publish("rule.error", msg: msg, line_number: machine_line_number + 1)
end
end
end
end
module Marcdouane
class FileChecker
def run_rule
# [...]
rule.subscribe("rule.error") do |event|
print_error(file, rule, event[:line_number], event[:msg])
@exit_code = 1
end
end
end
end
Nice and tidy.
Inkmark
I obviously needed some Markdown parsing gem to enable checks beyond
the usual regex search. I thought I'd try inkmark which is the new
kid on the block.
Since my linter is far from being concerned about AI (and that's what Inkmark was built for) I'm not exactly leveraging its power but it's fast and the traversal API is nice:
module Marcdouane
module Rules
# Ensure that every child header is always a direct descendant of
# the previous header (i.e its level increments by 1).
class EnsureHeadersCascade < Rule
ERROR_MESSAGE = "Header levels should increment one at a time"
def check!
previous_level = nil
@markdown.on(:heading) do |header|
previous_level ||= header.level
if header.level > previous_level && header.level != previous_level + 1
error!(line_number_from_byte_range(header.byte_range))
else
previous_level = header.level
end
end
@markdown.walk
end
end
end
end
Cucumber/Aruba
I went from hating Cucumber (for absolutely zero valid reasons) some years ago to using it on almost every project I own.
Some time ago I discovered Aruba along the way, which helps you test
command-line applications with Cucumber, RSpec or Minitest. It
provides some incredibly helpers like When I run `some
command` then it should fail/pass with: <docstring>.
This makes my whole ruleset incredibly easy to test:
Feature: Built-in Markdown Rules
Rule: The document starts with a top-level header
Example: The document does not start with a top-level header
Given a file named "foo.md" with:
"""
## This is a file starting with a level-2 header
"""
When I run `marcdouane check "foo.md"`
Then it should fail with:
"""
foo.md:1: [StartWithTopLevelHeader] The file should start with a top-level header
"""
Zero custom steps, this is all provided by Aruba, and has made development an absolute breeze (see the whole test suite).
Conclusion
Coding a linter is really fun: you're given a bunch of standard computer-science problems and, as always, the goal is to assemble them nicely.
Using the gems above has made it incredibly fun because the whole system really fits in 3 files:
Now it's just a matter of letting that rules folder grow.