Skip to content

fix: Unicode-aware word boundaries in word replacements - #855

Merged
Beingpax merged 1 commit into
Beingpax:mainfrom
justadityaraj:fix/word-replacement-unicode-boundaries
Aug 2, 2026
Merged

Beingpax merged 1 commit into
Beingpax:mainfrom
justadityaraj:fix/word-replacement-unicode-boundaries

Conversation

@justadityaraj

@justadityaraj justadityaraj commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #756.

Problem: The word-boundary lookarounds in WordReplacementService use an ASCII-only class ([a-zA-Z0-9]), so every non-ASCII letter (ß, ö, é, Cyrillic, etc.) is treated as a word boundary. A rule like ERN → EAN then matches the tail of the German word vergrößern (the character before "ern" is ß) and corrupts it to vergrößEAN, exactly as reported. Introduced in 620a843 when \b was replaced with the ASCII lookarounds.

Fix: The word-character class is now Unicode letters/marks/digits, minus the non-spaced scripts (Han, Hiragana, Katakana, Hangul, Thai), mirroring the existing usesWordBoundaries exemptions, so Latin triggers sitting flush against CJK/Thai text still match. The subtraction uses scx= (Script_Extensions) rather than Script= so shared marks like the prolonged sound mark ー (U+30FC, Script=Common) stay exempt and triggers flush against katakana loanwords (サーバーapi) keep matching. The lookaround structure is kept (not reverted to \b), so triggers ending in punctuation like c++ keep working, which is why 620a843 moved off \b in the first place.

3 changed code lines in one file; all three call sites route through this method.

Verification (standalone harness that copies the pattern construction verbatim, 19 cases):

Before:

vergrößern  + (ERN→EAN)  -> vergrößEAN   WRONG
café        + (caf→X)    -> Xé           WRONG
котёнок     + (кот→…)    -> СОБАКАёнок   WRONG
ern१        + (ern→EAN)  -> EAN१         WRONG

After: those stay intact, and existing behavior is preserved:

the ern here -> the EAN here   (standalone word still replaced)
(ern)        -> (EAN)          (punctuation still a boundary)
use c++ now  -> use CPP now    (punctuation-ending trigger still works)
これはapiです -> これはAPIです   (Latin trigger flush against CJK still replaced)
サーバーapi   -> サーバーAPI     (prolonged sound mark ー still exempt, via scx)
サーバーapi     -> サーバーAPI       (halfwidth forms too)
한api국      -> 한API국
ไทยapiไทย   -> ไทยAPIไทย
modern       -> modern         (ASCII interior still protected)
ern2         -> ern2
Full 19-case harness (run: swift word_replacement_boundary_check.swift, add --old for the current pattern)
// Repro + regression harness for VoiceInk issue #756
// Mirrors WordReplacementService.applyReplacements' boundary-regex path verbatim.
// Run: swift word_replacement_boundary_check.swift
import Foundation

// Boundary classes under test
let asciiClass = "[a-zA-Z0-9]" // current production (line 43)
let unicodeClass = "[[\\p{L}\\p{M}\\p{N}]-[\\p{scx=Han}\\p{scx=Hiragana}\\p{scx=Katakana}\\p{scx=Hangul}\\p{scx=Thai}]]" // proposed fix

// Verbatim copy of the service's boundary-path replacement (case-insensitive lookarounds)
func replace(_ text: String, trigger: String, with replacementText: String, wordChar: String) -> String {
    let escaped = NSRegularExpression.escapedPattern(for: trigger)
    let pattern = "(?<!\(wordChar))\(escaped)(?!\(wordChar))"
    guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else {
        fatalError("pattern failed to compile: \(pattern)")
    }
    let range = NSRange(text.startIndex..., in: text)
    return regex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: replacementText)
}

struct Case {
    let name: String
    let text: String
    let trigger: String
    let replacement: String
    let expected: String   // correct behavior (what the fix must produce)
    let oldIsBroken: Bool  // does the current ASCII class get this wrong?
}

let cases: [Case] = [
    // The reported bug (#756): ß treated as a boundary corrupts vergrößern
    Case(name: "german ß interior", text: "vergrößern", trigger: "ERN", replacement: "EAN",
         expected: "vergrößern", oldIsBroken: true),
    // Accented Latin
    Case(name: "accented é interior", text: "café", trigger: "caf", replacement: "X",
         expected: "café", oldIsBroken: true),
    // Cyrillic
    Case(name: "cyrillic interior", text: "котёнок", trigger: "кот", replacement: "СОБАКА",
         expected: "котёнок", oldIsBroken: true),
    // Combining mark after trigger (needs \p{M})
    Case(name: "combining mark after", text: "ern\u{0301}", trigger: "ern", replacement: "EAN",
         expected: "ern\u{0301}", oldIsBroken: true),
    // Non-ASCII digit neighbor (needs \p{N}, not [0-9])
    Case(name: "devanagari digit after", text: "ern१", trigger: "ern", replacement: "EAN",
         expected: "ern१", oldIsBroken: true),

    // Regression guards: correct today, must stay correct
    Case(name: "standalone word", text: "the ern here", trigger: "ern", replacement: "EAN",
         expected: "the EAN here", oldIsBroken: false),
    Case(name: "case-insensitive standalone", text: "ERN", trigger: "ern", replacement: "EAN",
         expected: "EAN", oldIsBroken: false),
    Case(name: "punctuation boundary", text: "(ern)", trigger: "ern", replacement: "EAN",
         expected: "(EAN)", oldIsBroken: false),
    Case(name: "string edges", text: "ern", trigger: "ern", replacement: "EAN",
         expected: "EAN", oldIsBroken: false),
    Case(name: "ascii interior stays", text: "modern", trigger: "ern", replacement: "EAN",
         expected: "modern", oldIsBroken: false),
    Case(name: "ascii digit neighbor stays", text: "ern2", trigger: "ern", replacement: "EAN",
         expected: "ern2", oldIsBroken: false),
    // Why lookarounds exist at all (commit 620a843): triggers ending in punctuation
    Case(name: "punctuation trigger c++", text: "use c++ now", trigger: "c++", replacement: "CPP",
         expected: "use CPP now", oldIsBroken: false),
    // Latin trigger flush against non-spaced scripts must still replace (script exemptions)
    Case(name: "CJK-adjacent latin trigger", text: "これはapiです", trigger: "api", replacement: "API",
         expected: "これはAPIです", oldIsBroken: false),
    Case(name: "hangul-adjacent latin trigger", text: "한api국", trigger: "api", replacement: "API",
         expected: "한API국", oldIsBroken: false),
    Case(name: "thai-adjacent latin trigger", text: "ไทยapiไทย", trigger: "api", replacement: "API",
         expected: "ไทยAPIไทย", oldIsBroken: false),
    // Prolonged sound mark ー (U+30FC) is Script=Common but scx=Hira|Kana, so it must stay
    // exempt so triggers flush against katakana loanwords keep matching (needs scx=)
    Case(name: "prolonged mark before trigger", text: "サーバーapi", trigger: "api", replacement: "API",
         expected: "サーバーAPI", oldIsBroken: false),
    Case(name: "prolonged mark after trigger", text: "apiー", trigger: "api", replacement: "API",
         expected: "APIー", oldIsBroken: false),
    Case(name: "halfwidth prolonged mark (U+FF70)", text: "サーバーapi", trigger: "api", replacement: "API",
         expected: "サーバーAPI", oldIsBroken: false),
    Case(name: "combining dakuten (U+3099) adjacent", text: "か\u{3099}api", trigger: "api", replacement: "API",
         expected: "か\u{3099}API", oldIsBroken: false),
]

var mode = CommandLine.arguments.contains("--old") ? "old" : "new"
let cls = mode == "old" ? asciiClass : unicodeClass
var failures = 0

for c in cases {
    let got = replace(c.text, trigger: c.trigger, with: c.replacement, wordChar: cls)
    let want = (mode == "old" && c.oldIsBroken) ? nil : c.expected // old mode: broken cases just reported
    if mode == "old" {
        let brokenNow = got != c.expected
        let marker = brokenNow == c.oldIsBroken ? "as-documented" : "UNEXPECTED"
        if marker == "UNEXPECTED" { failures += 1 }
        print("[old] \(c.name): \"\(c.text)\" -> \"\(got)\" (\(brokenNow ? "WRONG" : "ok"), \(marker))")
    } else {
        let pass = got == want
        if !pass { failures += 1 }
        print("[new] \(c.name): \"\(c.text)\" -> \"\(got)\" \(pass ? "PASS" : "FAIL (want \"\(want!)\")")")
    }
}

if failures > 0 {
    print("\n\(failures) FAILURE(S)")
    exit(1)
}
print("\nall \(cases.count) cases \(mode == "old" ? "behave as documented" : "PASS")")

The boundary lookarounds used an ASCII-only class ([a-zA-Z0-9]), so any
non-ASCII letter counted as a word boundary and replacement rules could
match inside words: an ERN -> EAN rule corrupted "vergrößern" into
"vergrößEAN" (Beingpax#756).

Word chars are now Unicode letters/marks/digits, minus the non-spaced
scripts — via Script_Extensions so shared marks like the prolonged sound
mark U+30FC stay exempt — mirroring the usesWordBoundaries exemptions.
Punctuation boundaries and punctuation-ending triggers (c++) keep
working, and Latin triggers flush against CJK/Thai text still match.
@Beingpax
Beingpax merged commit f35d50f into Beingpax:main Aug 2, 2026
1 check passed
jonahclarsen pushed a commit to jonahclarsen/VoiceInk that referenced this pull request Aug 16, 2026
…nt-unicode-boundaries

fix: Unicode-aware word boundaries in word replacements
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Word Replacements unintentionally modify parts of unrelated words

2 participants