Skip to content

Neovim markdown format-on-save corrupts inline code spans, and the prettier version is whatever PATH offers #135

Description

@jasonaguilon-wf

What happens

dot_config/nvim/lua/custom/plugins/markdown.lua formats markdown on save with
Prettier at --prose-wrap always --print-width 80. When a document contains an
inline code span that crosses a line break, Prettier rewraps it and loses the
spaces around the span, so saving the file silently changes what the document
says.

Real example, from a planning document in another repo. Before:

        `pivotedIdentityLossy: true` marker is emitted on each
        `_sheets[]` entry whose `conditions` is empty and whose

After a save:

          true`marker is emitted on each`\_sheets[]`entry whose`conditions`is empty

The code span boundaries move, so the rendered output changes. Older Prettier
versions also escape underscores (\_sheets[]), which is cosmetic in prose but
wrong inside a span.

The nasty part: the corruption is idempotent. Formatting again produces the
same broken output, so prettier --check passes afterwards and nothing
downstream flags it. Upstream says the same thing in
prettier/prettier#19116.

Why it matters here

Format-on-save is enabled for markdown, so this happens while writing, not at
some reviewable gate:

opts.format_on_save = function(bufnr)
  local ft = vim.bo[bufnr].filetype
  if ft == 'markdown' then return { timeout_ms = 3000, lsp_format = 'never' } end

In the repo where I hit this, 108 inline code spans across 18 files cross a
line break, so the exposure is not one unlucky paragraph.

Upstream state

Open, not fixed: prettier/prettier#19116 (filed May 2026 against 3.8.3), with
prettier/prettier#19248 and prettier/prettier#18990 pending. Related and much
older: prettier/prettier#11372.

I tested three formatters on the same file:

Tool escapes introduced code spans jammed
prettier 3.1.0 2 1
prettier 3.6.2 2 1
prettier 3.9.6 (current) 0 1
mdformat --wrap 80 1 1

Upgrading fixes the escaping and not the jamming. --prose-wrap preserve does
not fix it either: Prettier still normalizes spans that already cross lines, and
that run produced 2 jammed spans.

Second, separate problem: version drift

The formatter resolves its binary with conform.util.from_node_modules 'prettier',
which searches upward for node_modules/.bin/prettier and otherwise falls back
to PATH. In a repo with no node_modules, what actually runs depends on how
Neovim was started:

  • Homebrew prettier on PATH: 3.1.0
  • mise shim inside a repo pinning prettier: 3.6.2
  • current release: 3.9.6

So the same file formats differently depending on the shell that launched the
editor. That is worth fixing regardless of the wrapping bug.

Options

  1. Pin the version explicitly so at least the behaviour is consistent, and take
    3.9.6 to lose the escaping class.
  2. Turn off format-on-save for markdown and format deliberately, so a rewrite is
    something you choose and can inspect.
  3. Rely on per-repo .prettierignore for documents that must not be rewritten.
    I confirmed Prettier honours the ignore file through --stdin-filepath, so
    this does protect the editor path, but it is opt-in per repo and does nothing
    for documents elsewhere.
  4. Evaluate a formatter with a preservation guarantee. jcreinhold/mdwright
    states that fmt refuses any rewrite that would change the rendered DOM, and
    preserves emphasis delimiters and list markers by default. It is a cargo
    install, which also suits the aqua/vfox/cargo backend preference in the mise
    security notes. It is young, so it wants review before trusting it.

Reproducing

The trigger is an inline code span that crosses a line break inside a list
item. A span that sits on one line is handled correctly, so the wrapping alone
is not enough to provoke it.

printf '%s\n' \
  '- item:' \
  '  - value already contains `" - "` (e.g., `COSO Internal Control' \
  '    - Integrated Framework`), so splitting on the separator is' \
  '    unsafe. A machine-readable `pivotedIdentityLossy: true` marker is' \
  '    emitted on each `_field[]` entry whose `conditions` is empty.' \
  > /tmp/repro.md
prettier --parser markdown --prose-wrap always --print-width 80 /tmp/repro.md

Output, with prettier 3.1.0:

- item:
  - value already contains `" - "` (e.g., `COSO Internal Control
    - Integrated
      Framework`), so splitting on the separator is unsafe. A machine-readable `pivotedIdentityLossy:
      true`marker is emitted on each`\_field[]`entry whose`conditions` is empty.

Three things went wrong in one pass: the spans lost their surrounding spaces,
an underscore got escaped inside a span, and the last two lines run past the
80 column target that prompted the reformat.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions