CJK-aware hard wrapping for Neovim. UAX #14 line breaking plus kinsoku shori
(禁則処理), wired to gq through 'formatexpr'. Markdown and MDX aware. No
runtime dependencies.
類比授權規則(analogy-warranting rule,
AWR):陳述已知共有特徵與被推出特徵之間
所成立的邏輯關係。
Worth thirty seconds before you install anything.
If your complaint is that CJK looks wrong on screen — a line that stops short and leaves a wide gap at the right margin — you do not need this plugin. You need one option:
:setlocal nolinebreak'linebreak' breaks only at 'breakat' characters, and 'breakat' is
ASCII-only. A run of Chinese or Japanese is therefore a single unbreakable
token: when it does not fit in the space left on the line, Neovim moves the
whole run down and leaves the gap you are looking at. With 'linebreak' off,
Neovim wraps at the exact screen column instead — which is what CJK typesetting
does anyway. The cost is that Latin words can be cut mid-word.
That is a display setting. Nothing on disk changes.
This plugin does the opposite: it writes the line breaks into your file.
Reach for it once you have decided you want hard-wrapped source — for line-level
git diff on prose, for a 72-column plain-text convention, for mail. Neovim
cannot produce that for CJK at all: the built-in formatter breaks only at
whitespace, so gq on a Chinese paragraph does nothing whatsoever.
Hard-wrapped CJK Markdown renders a spurious space at every wrap point
unless your publishing pipeline removes segment breaks between CJK characters.
CommonMark turns a soft line break into \n, and HTML collapses that to a
space — between two Han characters, that is a visible gap that was never in your
source.
This is not fixable in the editor. CSS Text Level 3 made the segment break transformation rules UA-defined in 2020; Firefox removes the break, Chrome and Safari make no guarantee.
Fix your pipeline before you start hard wrapping:
| Ecosystem | Plugin |
|---|---|
| remark / Astro / Next.js MDX | remark-join-cjk-lines |
| markdown-it / VitePress | markdown-it-cjk-breaks |
| Gatsby | gatsby-remark-remove-cjk-breaks |
With that in place, wrapping is invisible in your published output — which is exactly the guarantee this plugin is built around.
Because soft wrap cannot be fixed from a plugin — only traded off, which is
what nolinebreak above does.
Neovim's 'linebreak' breaks only at characters in 'breakat', and the manual
is blunt about the limit:
This option lets you choose which characters might cause a line break if 'linebreak' is on. Only works for ASCII characters.
Chinese and Japanese have no inter-word spaces, so a CJK paragraph is one enormous unbreakable token. Mix in a Latin fragment and it becomes the only break candidate, which is why you get wrap points in the middle of a clause.
The decision lives in Neovim's C rendering code and there is no callback for it
— no 'breakatfunc', nothing. Upstream attempts have stalled: vim/vim#6598
was closed unmerged in November 2024 after four years, and neovim/neovim#13967
has been open since 2021. 'formatexpr' is the one hook Lua can reach, and it
drives hard wrapping.
lazy.nvim
{ "wayne930242/kinsoku.nvim", ft = { "markdown", "mdx" }, opts = {} }mini.deps
add("wayne930242/kinsoku.nvim")
require("kinsoku").setup()Then wrap with gqip, gqq, or gq over a visual selection, exactly as you
already do. Set 'textwidth' to whatever width you like.
setup() is optional. These are the defaults:
require("kinsoku").setup({
filetypes = { "markdown", "mdx" }, -- where to install 'formatexpr'
fallback_width = 80, -- used only when 'textwidth' is 0
})That is the whole surface. The plugin sets a buffer-local 'formatexpr' and
touches nothing else — 'textwidth', 'formatoptions', 'linebreak',
'wrap' and 'breakat' stay exactly as you configured them. If you already
have t in 'formatoptions', wrapping while you type works too, through the
same hook.
The rule is narrower than "wherever UAX #14 permits", on purpose:
A break is placed only where re-joining the two lines reproduces the original text exactly.
Two boundaries survive that round trip — after a run of spaces, and between two East Asian Wide characters. Everything else is refused, because a rejoin would insert a space your reader would see:
| Boundary | Break? | Why |
|---|---|---|
類 | 比 |
yes | both wide; rejoin adds nothing |
rule, | AWR |
yes | after a space; rejoin puts it back |
規則 | AWR |
no | rejoin renders 規則 AWR |
analogy- | warranting |
no | rejoin renders analogy- warranting |
られる | ——根拠 |
no | em dash is Ambiguous width, not Wide |
On top of that, kinsoku shori applies. Closing punctuation 。、)」 never
starts a line, opening brackets 「(【 never end one, and —— …… are never
split. Resolution is by push-out (追い出し) — a monospace grid cannot compress
spacing, so pulling content back (追い込み) is not available.
Emoji are safe: ZWJ sequences, skin-tone modifiers, variation selectors, combining marks and flag pairs are never cut apart, because Neovim does not expose grapheme cluster segmentation to Lua and naive breaking would shred them.
Fenced and indented code, front matter, tables, headings, thematic breaks, MDX
import/export statements, JSX blocks, and directive markers are left
byte-identical. Inside a line, code spans, link destinations, $…$ math, inline
JSX and directive attribute blocks are never broken. Anything the scanner cannot
classify is protected rather than reflowed, so the failure mode is "did not
wrap", never "corrupted your file".
Wrapping is idempotent: running gq twice produces the same bytes as running it
once.
- Fixing soft wrap. Out of reach from Lua; the honest fix is a Neovim core patch.
- 盤古之白 — inserting spaces between CJK and Latin. A text transformation orthogonal to line breaking, and contentious among CJK writers.
- Korean-specific handling. Korean has inter-word spaces and already wraps correctly.
- Thai, Lao, Khmer, Myanmar. These need dictionary segmentation. They fall back to breaking at spaces — the same as Neovim does today, so nothing regresses.
MIT