Linting and Fixing in Vim with Remark
The Remark utility can be used to highlight issues with your Markdown files and to automatically fix many of them. Figuring out how to configure Remark to work well with Vim and ALE was a bit of a challenge, so I thought I would share my setup plus some tips.
TL;DR
settings:
rule: '-'
fences: true
listItemIndent: one
plugins:
- remark-gfm
- remark-preset-lint-recommended
- remark-preset-lint-markdown-style-guide
- - remark-lint-list-item-indent
- space
- - remark-lint-ordered-list-marker-value
- ordered
- remark-lint-strikethrough-marker
- remark-lint-checkbox-content-indent
- - remark-lint-checkbox-character-style
- {checked: 'x', unchecked: ' '}
- remark-lint-linebreak-style
- - remark-lint-unordered-list-marker-style
- '*'
- - remark-lint-no-missing-blank-lines
- exceptTightLists: true
- - remark-lint-link-title-style
- false
- remark-lint-first-heading-level
- remark-lint-no-heading-indent
- remark-lint-no-heading-like-paragraph
- remark-lint-no-duplicate-headings-in-section
- remark-lint-no-paragraph-content-indent
Linting vs Fixing
Not all Markdown is created equal. It is quite easy to create Markdown that is either confusing in its raw form or which produces unexpected results when converted to HTML.
We can try to address this problem by following a set of rules like the Markdown Style Guide. Moreover, there exist automated “linting” facilities like Remark that can warn us when we stray from these rules.
Some “linting” warnings identify inconsistencies or errors that don’t require any user decisions or input to resolve. For example, warnings about using the incorrect bullet marker or consecutive newlines. These warnings lend themselves to automated “fixing”, a process also supported by Remark.
In Remark, “linting” and “fixing” are two separate processes with separate configurations. Since I want to use both processes in Vim, I needed to find complimentary, or at least non-conflicting, configurations. In particular, I’ll need to ensure that:
-
The “fixing” routine only fixes the problems that I want it to fix. I don’t want this routine to, say, backslash all the square brackets in my carefully laid out checkbox tasklist.
-
The “linting” routine highlights most (if not all) of the issues that are fixed by the “fixing” routine – providing a preview of what the “fixing routine is going to change”. I don’t want the “fixing” routine to introduce unexpected changes.
-
The “fixing” routine does not (re)introduce Markdown that is just going trigger a “linting” warning. I don’t want it to, say, convert all my unordered lists to use
-
if the “linting” routine is going to insists I use*
characters.
Where to start?
Remark is a general framework for processing Markdown and not just for converting it to HTML. It is built out of layered components so that different parts can be used in different contexts. Some relevant ones include:
-
mdast
: a specification for representing a various Markdown flavours in an Abstract Syntax Tree. -
mdast-util-from-markdown
: a utility to parse Markdown to an AST. -
remark-parse
: a parser and compiler that converts Markdown to an AST using the above. -
mdast-util-to-markdown
: a utility to serializemdast
to Markdown. -
remark-stringify
: a serializer to convert an AST back into markdown using the above. -
remark-lint
: a library that examines an AST for issues based on plugins. -
remark
: is a markdown processor powered by plugins and using all the above. -
unified-engine
: a framework for processing files and configurations. -
unified-args
: a framework for building command-line tools. -
unified
: a framework for building text processing tools using the above. -
remark-cli
: a command-line interface based onunified
andremark
Support for additional markup and output (like footnotes, frontmatter, directives, ToCs, GFM, MDX) is provided by various plugins.
For ALE integration with Vim, I needed a command line interface, so the last package in the above stack was what I wanted.
npm install -g remark-cli
This gave me a remark
command, installed in a user-global context, which
I could use on arbitrary Markdown files.
Unfortunately, the documentation for Remark is spread across all of the above packages and I found that understanding the application and its plugins, plus all the relevant settings and configuration options, required tracking through a lot of cross-references.
Fixing Markdown
Out of the box, the remark
command gives us an automatic fixing utility.
When you run it on a Markdown file, it will parse the content into an AST using
remark-parse
, then serializes that AST back into text using
remark-stringify
. The result is usually a more strictly
formatted version of the original Markdown, with things like bullet characters
and block spacing being applied consistently.
Various details of this “reformatting” can be controlled by settings passed to
the serializer (see mdast-util-to-markdown
for
details). The defaults for most settings are reasonable and avoid many
issues. Of particular note are the following settings which minimize ambiguity
and seem to give stable behaviour:
bullet: '*'
bulletOrdered: '.'
emphasis: '*'
strong: '*'
fence: '`'
quote: '"'
The only ones I ended up changing from the default where:
rule: '-'
fences: true
listItemIndent: one
Using -
instead of *
for a rule marker helps avoid a couple of edge cases
where the intent is ambiguous. Always using fences for code blocks, and
a single space after bullets, enforces a consistency that matches well with the
available linting rules.
This formatting can also be influenced by plugins which extend the parser and compiler.
Since I often use Markdown tables (an extension to Commonmark provided by
Github Flavoured Markdown), I added the remark-gfm
plugin
so that the fixing routine will ensure my table columns are nicely padded and
aligned. I also get support for checkboxes (which I occasionally use) and
strike-through markers (which I seldom use). There are some options to this
module, but the defaults seem to work well.
As you will see below, there are corresponding linting rules that highlight issues with the GFM extensions. These rules have to be loaded after the plugin since they require the content to be appropriately parsed before it can be interpreted by the linting routines.
Linting Markdown
At this stage, despite having a remark-lint
module installed, I did not have
any linting capabilities. This is because remark-lint
passes off all the
actual linting to configurable plugins that target specific errors or
warnings. These must be installed and loaded before any linting can occur.
There are currently 67 official rule plugins covering common issues. In addition, Remark provides 3 preset “meta” packages that load and configure commonly used combinations:
-
remark-preset-lint-consistent
: rules that enforce consistency -
remark-preset-lint-recommended
: rules that prevent mistakes or stuff that fails across vendors. -
remark-preset-lint-markdown-style-guide
: rules that enforce the Markdown Style Guide
The table below shows some of the relationships between the different rules and
presets. I’ve omitted the remark-lint-
prefix and used the following
key to keep the table compact.
- MSG: Rules provided by the Markdown Style Guide preset
- Con: Rules provided by the Consistent preset
- Rec: Rules provided by the Recommended preset
- GFM: Rules that require the Github Flavoured Markdown plugin
- Fix: Rules that are “fixed” by the serializer
You can see from the above table that many of issues raised by these linting rules can be automatically resolved by the serializer. Noting the overlap of the MSG and Recommended presets with the rules that are automatically fixed by the serializer, I realized I could get a reasonably compact rule set by including those two presets, the GFM plugin, and just six extra rules. This also included some rules that could not be fixed by the serializer, but they all seemed to be sensible enough and I had no problems including them.
Fortunately the default linting rule settings, the MSG preset overrides, and the default serializer settings, were fairly complimentary, and there were only a couple of instances where I had to override settings to get the desired behaviour. The following is a trimmed down version of the configuration that I use. The full version includes detailed comments on all the official rules as a quick reference in case I need to tweak anything.
settings:
rule: '-'
fences: true
listItemIndent: one
plugins:
- remark-gfm
- remark-preset-lint-recommended
- remark-preset-lint-markdown-style-guide
- - remark-lint-list-item-indent
- space
- - remark-lint-ordered-list-marker-value
- ordered
- remark-lint-strikethrough-marker
- remark-lint-checkbox-content-indent
- - remark-lint-checkbox-character-style
- {checked: 'x', unchecked: ' '}
- remark-lint-linebreak-style
- - remark-lint-unordered-list-marker-style
- '*'
- - remark-lint-no-missing-blank-lines
- exceptTightLists: true
In the above, I override the preset rule settings by loading the associated rule with the desired settings after the preset has been loaded.
Unfortunately there was one rule that I could not configure to consistently
match the serializer output:
link-title-style
. Since this was loaded via
the MSG preset, I ended up suppressing this rule by explicitly setting it to
false
.
- - remark-lint-link-title-style
- false
What about the rest of the rules?
Getting headings and sections wrong while I’m writing is going to upset my composition, and fixing them could require significant rework, so I like to keep these under control at by adding the following rules:
- remark-lint-first-heading-level
- remark-lint-no-heading-indent
- remark-lint-no-heading-like-paragraph
- remark-lint-no-duplicate-headings-in-section
- remark-lint-no-paragraph-content-indent
While all of the rules have some benefit, loading too many can slow down the linting routine while you are editing, so I relegated any additional rules to post-commit and CI hooks.
To use the rules mentioned above I had to install a few packages:
npm install -g \
remark-gfm \
remark-preset-lint-recommended \
remark-preset-lint-markdown-style-guide \
remark-lint-list-item-indent \
remark-lint-ordered-list-marker-value \
remark-lint-strikethrough-marker \
remark-lint-checkbox-content-indent \
remark-lint-checkbox-character-style \
remark-lint-linebreak-style \
remark-lint-unordered-list-marker-style \
remark-lint-no-missing-blank-lines \
remark-lint-link-title-style \
remark-lint-first-heading-level \
remark-lint-no-heading-indent \
remark-lint-no-heading-like-paragraph \
remark-lint-no-duplicate-headings-in-section \
remark-lint-no-paragraph-content-indent
Configuration
Configuration for remark
is handled by unified-engine
framework. This supports configuration files in multiple formats and a search up
the file-system hierarchy. The current directory is searched for a file named
either
.remarkrc
(JSON), or.remarkrc.js
(JS), or.remarkrc.yml
(YAML).
If a matching file is not found, the parent directory is searched, and so on. With this setup, you can have optional project level configurations and a catch-all configuration in, say, your home directory. Note that if a lower level configuration file is found, the search stops and any higher level configuration files are ignored, so there is no convenient merging of configurations.
The documentation suggests there is a “configuration cascade” and that settings may be extended or overridden, but unfortunately, it does not include any details. After some experiments and some code diving, it seems this cascade does not work for many of the linting rules and so its not very useful for us.
Integrating with Vim via ALE
If you install remark-cli
, ALE will automatically detect it and start
linting accordingly. If you run :ALEInfo
, you will see “remark_lint” in the
list of “Available Linters”.
There is some term juggling going on here. ALE refers to both the “fixer” and the “linter” as “remark-lint” (possibly for historical reasons). But note that the
g:ale_markdown_remark_lint_executable
variable is set toremark
, so “remark-lint” is the one that we want, and it will use the right command.
You can configure ALE to use remark-lint
as a “fixer” via a global
setting, but I prefer do this via a buffer local setting in
~/.vim/ftplugin/markdown.vim
:
let b:ale_fixers=['remark-lint']
I also explicitly limit ALE to using remark-lint
because I don’t
want other installed “linters” being picked up and confusing my set up.
let b:ale_linters=['remark-lint']
Finally, I have ALEFix
bound to \f
as part of my global .vimrc
so fixing my
buffer is only two key strokes away:
nmap <silent> <leader>f <Plug>(ale_fix)
Local overrides
If you are working on a project that uses remark
as part of its testing/CI
chain, ALE will usually detect this and use the corresponding configuration.
This may not work well if the linting process takes too long, so you may want to
tweak the “live” linting to omit some plugins.
If you use localvimrc files, you could override a plugin with:
let b:ale_markdown_remark_lint_options = '-u remark-lint-no-html=false'
Or you could force the use of the global executable and configuration with
let b:ale_markdown_remark_lint_use_global = 1
let b:ale_markdown_remark_lint_options = '-r ~/.remarkrc'
At the time of writing, very few of the linting plugins support passing in settings via the command line (PRs pending). Until that is fixed, the only way to alter the settings for an existing rule is to use an edited copy of the whole configuration file.
No Tabs
One of the potential linting rules, no-tabs
, warns
about using raw tab characters. With Vim I avoid needing to use this rule
by adding the following lines to my ~/.vim/ftplugin/markdown.vim
file:
setlocal tabstop=2
setlocal shiftwidth=2
setlocal shiftround " Indent/outdent to nearest tabstop
setlocal expandtab " Convert all tabs typed to spaces
This makes it pretty difficult to accidentally enter a tab character unless I explicitly want to (say, for a Makefile snippet).
Long lines
One issue that often arises is whether or not to wrap long lines. The Markdown Style Guide suggests doing this at 80 chars, but some markdown processors (like GitLab) interpret these as hard-breaks rather than re-flowing the text as expected (the specification says you need 2 spaces or a backslash at the end of a line for a hard-break).
I avoid this issue (and many arguments with other developers) by disabling the
maximum-line-length
plugin in my
remarkrc.yml
with
- - remark-lint-maximum-line-length
- false
and adding the following settings to my ~/.vim/ftplugin/markdown.vim
:
setlocal linebreak " Wrap long lines at word boundaries
setlocal formatoptions-=t " Dont auto-wrap text using textwidth
setlocal columns=80 " Constrain window width to trigger soft wrap
" ^ increase this if you use number or error columns
This gives me reasonable soft-wrapping behaviour and makes editing Markdown files with very long lines bearable.
Conclusion
Once I worked out all the wrinkles, I found Remark to a valuable addition to my linting setup with Vim. It has certainly has been catching many errors while writing this blog.
Having the live feedback has helped to train me away from using bad Markdown and to avoid creating structures that were not going to work in the long-run.
Having an automated fixing routine has meant I could temporarily ignore linting warnings, since I knew I could easily fix them in bulk later. A great example of this is fixing the padding for large GFM tables.
Knowing that linting was always there has meant I could, say, ignore tracking down a reference until I was sure I was going to keep the sentence that contained it. I could use the linting warnings as a kind of automatic TODO list of issues I needed to fix up at some point.
Anyway, I hope you have found this useful. Please feel free to leave any comments or corrections below.