@@ -50,6 +50,57 @@ for _, ch in ipairs {
5050 NO_BREAK_END [ch ] = true
5151end
5252
53+ --- ASCII straight quotes are ambiguous (open vs. close determined by
54+ --- surrounding characters, since the same glyph serves both roles).
55+ local AMBIGUOUS_QUOTE = { [' "' ] = true , [" '" ] = true }
56+
57+ --- A character that suggests an adjacent ASCII quote is at a structural
58+ --- boundary (start/end of text, whitespace, or any bracket/punctuation in
59+ --- the kinsoku tables). Used by `classify_quotes` for both prev and next
60+ --- side checks: an opening quote needs an open boundary before it; a
61+ --- closing quote needs a close boundary after it.
62+ --- @param ch string | nil
63+ --- @return boolean
64+ local function quote_at_boundary (ch )
65+ if ch == nil then return true end
66+ if ch :match " ^%s$" then return true end
67+ if NO_BREAK_END [ch ] or NO_BREAK_START [ch ] then return true end
68+ return false
69+ end
70+
71+ --- Classify each ASCII quote (`"`, `'`) in `text` as "open" or "close"
72+ --- based on surrounding characters. Quotes with both or neither side
73+ --- bounded (e.g. ` " `, or `it's`) are left out of the result table.
74+ ---
75+ --- Returned positions are 0-indexed byte offsets into `text`, matching
76+ --- the convention used by `split_segments` and `wrap_words`.
77+ --- @param text string
78+ --- @return table<integer , " open" | " close" >
79+ local function classify_quotes (text )
80+ local roles = {}
81+ local chars = {}
82+ local positions = {}
83+ local pos = 0
84+ for c in text :gmatch " [%z\1 -\127\194 -\253 ][\128 -\191 ]*" do
85+ chars [# chars + 1 ] = c
86+ positions [# positions + 1 ] = pos
87+ pos = pos + # c
88+ end
89+
90+ for i , c in ipairs (chars ) do
91+ if AMBIGUOUS_QUOTE [c ] then
92+ local prev_open = quote_at_boundary (chars [i - 1 ])
93+ local next_close = quote_at_boundary (chars [i + 1 ])
94+ if prev_open and not next_close then
95+ roles [positions [i ]] = " open"
96+ elseif next_close and not prev_open then
97+ roles [positions [i ]] = " close"
98+ end
99+ end
100+ end
101+ return roles
102+ end
103+
53104local has_budoux , budoux = pcall (require , " budoux" )
54105local budoux_parser = has_budoux and budoux .load_default_japanese_parser () or nil
55106local budoux_model = budoux_parser and budoux_parser .model or nil
@@ -544,6 +595,19 @@ function M.wrap_words(text, max_width)
544595 local last_seg_pos = 0
545596
546597 local segments = split_segments (text )
598+ local quote_roles = classify_quotes (text )
599+
600+ --- True iff `s`, occupying bytes `[byte_start, byte_start + #s)` of `text`,
601+ --- ends with a NO_BREAK_END char or an ambiguous quote classified as "open".
602+ local function ends_no_break_end (s , byte_start )
603+ if s == " " then return false end
604+ local lc = last_char (s )
605+ if NO_BREAK_END [lc ] then return true end
606+ if AMBIGUOUS_QUOTE [lc ] and quote_roles [byte_start + # s - # lc ] == " open" then
607+ return true
608+ end
609+ return false
610+ end
547611
548612 for i , seg in ipairs (segments ) do
549613 local seg_width = vim .api .nvim_strwidth (seg .text )
@@ -556,12 +620,19 @@ function M.wrap_words(text, max_width)
556620 -- punctuation (e.g. ".gitignore" starts with "." which is in NO_BREAK_START,
557621 -- but the segment is a filename, not standalone punctuation).
558622 local fc = first_char (seg .text )
559- local is_no_break_start = NO_BREAK_START [fc ] and not (# fc == 1 and # seg .text > 1 )
623+ local is_no_break_start
624+ if AMBIGUOUS_QUOTE [fc ] then
625+ -- Only treat as NO_BREAK_START when classified as a closing quote.
626+ -- This covers standalone `"` segments while leaving multi-char words
627+ -- like `"hello` (opening quote) alone.
628+ is_no_break_start = quote_roles [seg .byte_pos ] == " close"
629+ else
630+ is_no_break_start = NO_BREAK_START [fc ] and not (# fc == 1 and # seg .text > 1 )
631+ end
560632 -- Guard 追い出し: if prev_current ends with a NO_BREAK_END char (e.g. "(",
561633 -- "「"), pushing it as its own line would strand that opener at line end.
562634 -- Fall through to 追い込み so the line overflows but kinsoku is preserved.
563- local prev_ends_no_break_end = prev_current ~= " "
564- and NO_BREAK_END [last_char (prev_current )] or false
635+ local prev_ends_no_break_end = ends_no_break_end (prev_current , current_start )
565636 if is_no_break_start and prev_current ~= " " and not prev_ends_no_break_end then
566637 table.insert (wrapped_lines , prev_current )
567638 table.insert (line_starts , current_start )
@@ -575,7 +646,7 @@ function M.wrap_words(text, max_width)
575646 local sep = (seg .has_leading_space and current ~= " " ) and " " or " "
576647 current = current .. sep .. seg .text
577648 current_width = current_width + # sep + seg_width
578- elseif NO_BREAK_END [ last_char (current )] then
649+ elseif ends_no_break_end (current , current_start ) then
579650 -- Kinsoku: current ends with a NO_BREAK_END char (e.g. "(", "「").
580651 -- Pushing it now would strand the opener at line end. Append seg
581652 -- (overflowing the line) so the opener stays bonded to its content.
@@ -598,7 +669,7 @@ function M.wrap_words(text, max_width)
598669 -- break before it so it doesn't sit at line end.
599670 -- Use >= to be conservative: even if the next segment barely fits, subsequent
600671 -- 追い出し could push it away and strand this char at line end.
601- if NO_BREAK_END [ last_char (seg .text )] and current ~= " " then
672+ if ends_no_break_end (seg .text , seg . byte_pos ) and current ~= " " then
602673 local next_seg = segments [i + 1 ]
603674 local next_width = next_seg and vim .api .nvim_strwidth (next_seg .text ) or 0
604675 if current_width + space_width + seg_width + next_width >= max_width then
650721M .split_ascii_syllables = split_ascii_syllables
651722M .NO_BREAK_START = NO_BREAK_START
652723M .NO_BREAK_END = NO_BREAK_END
724+ M .AMBIGUOUS_QUOTE = AMBIGUOUS_QUOTE
725+ M .classify_quotes = classify_quotes
653726M .split_segments = split_segments
654727M .is_cjk_or_kinsoku = is_cjk_or_kinsoku
655728M .first_char = first_char
0 commit comments