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:

  1. the CLI code
  2. the file checker
  3. the base rule.

Now it's just a matter of letting that rules folder grow.

Last updated: 2026-06-29 Mon 17:20