Skip to content

Fix --write-changes silently not fixing a trailing-apostrophe typo#3978

Open
chuenchen309 wants to merge 1 commit into
codespell-project:mainfrom
chuenchen309:fix/write-changes-trailing-apostrophe
Open

Fix --write-changes silently not fixing a trailing-apostrophe typo#3978
chuenchen309 wants to merge 1 commit into
codespell-project:mainfrom
chuenchen309:fix/write-changes-trailing-apostrophe

Conversation

@chuenchen309

Copy link
Copy Markdown

Problem

codespell -w reports FIXED and exits 0 for a misspelling that ends in an apostrophe, but the file is unchanged — the typo survives silently.

$ printf "dont' go there\n" > t.txt
$ codespell --write-changes t.txt
FIXED: t.txt
  t.txt:1: dont' ==> don't
$ echo $?
0
$ cat t.txt
dont' go there

It's also non-idempotent: running codespell -w three times reports FIXED all three times and the file never changes. Since the exit code is 0 for both a real fix and a phantom one, nothing downstream can tell them apart — a CI that runs codespell -w and then codespell stays red with no tool-side remedy.

Cause

_codespell.py:1097 builds the substitution pattern with \b anchors:

lines[i] = re.sub(rf"\b{word}\b", fixword, lines[i])

The word regex (_codespell.py:51) is [\w\-'’]+, so it admits a trailing apostrophe and word can end in one. A trailing \b then needs a word→non-word transition, but the preceding character is already non-word, so the assertion can never hold:

re.sub(r"\bdont'\b", "don't", "dont' go there")  # -> "dont' go there"  (no match)
re.sub(r"\bteh\b",   "the",   "teh cat")         # -> "the cat"

Detection uses the plain word regex and is unaffected, which is why the misspelling is still reported — only the write-back is lost.

This affects 23 keys in the shipped dictionaries (dont', cant', isnt', wasnt', wouldnt', thats', aircrafts', …). build_dict also auto-generates a U+2019 variant of each via alt_chars, and is non-word too, so the typographic form fails identically.

Mid-word apostrophes are fine (woudn'twouldn't writes correctly) because the trailing \b there follows a word character.

Fix

lines[i] = re.sub(rf"(?<!\w){re.escape(word)}(?!\w)", fixword, lines[i])

Lookarounds express the intended boundary without depending on the previous character's class. Verified equivalent on the boundary-sensitive cases:

word line before after
dont' dont' go there dont' go there don't go there
dont’ dont’ go dont’ go don’t go
teh teh cat teh the cat the the cat the
teh theteh x no match no match ✅
teh _teh x no match no match ✅
teh teh's the's the's

The re.escape isn't fixing a live bug — no dictionary key currently contains a regex metacharacter (I checked) — but the interpolation was only safe by accident, and escaping is free here.

Testing

Added test_write_changes_trailing_apostrophe, next to the existing test_apostrophe. The existing test only asserts detection (cs.main(fname) == 1) and uses woudn't — a mid-word apostrophe — so it never exercised the broken path; none of the -w tests use an apostrophe-terminated word either.

Verified it fails first (assert "dont' go\n" == "don't go\n") and passes after.

Full codespell_lib/tests/ — 124 passed, 16 skipped. The 3 failures (test_command, test_stdin, test_args_from_file) are identical before and after my change; they're FileNotFoundError from my sandbox not having the CLI on PATH, unrelated to this. ruff check and ruff format --check clean.


This change was AI-assisted (Claude). I reproduced the phantom fix and the non-idempotence myself with the real CLI before writing anything, confirmed the regex mechanism directly in a REPL, counted the affected dictionary keys, and ran the tests and baseline comparison above myself.

The word regex admits a trailing apostrophe, so word can end in one. A
\b after that can never hold -- it needs a word/non-word transition and
the apostrophe is already non-word -- so re.sub matched nothing while
codespell still reported FIXED and exited 0.

Use lookarounds instead, and escape the interpolated word.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DimitriPapadopoulos

Copy link
Copy Markdown
Collaborator

I always hesitate to modify regexes because I fear it might have an unexpected impact on performance. Unfortunately we don't have tests for that 😟

@chuenchen309

Copy link
Copy Markdown
Author

Totally fair concern — I measured it so we're not guessing.

The key point is where this regex runs: it's on the options.write_changes and fix path only, i.e. once per misspelling actually being written back with -w. Detection (the hot path that scans every line) uses the unchanged word regex, so nothing in a normal codespell run without fixes is touched.

Timing the two patterns directly (re.compile(...).sub(), CPython 3.12):

line \b{word}\b (?<!\w){word}(?!\w) delta
realistic single hit 2.30 µs 2.81 µs +0.5 µs/call
pathological, 100 hits/line 86 µs 96 µs +10 µs (1.11x)

So it's a fixed ~0.5 µs adder from swapping one \b for two zero-width lookarounds, paid once per applied fix. On a real run the fix path fires a handful of times, dwarfed by detection — I couldn't measure a difference in end-to-end codespell -w wall time above noise.

No backtracking risk either: the middle is now a re.escape'd literal (fixed string, no quantifier), bracketed by two O(1) zero-width assertions.

If you'd rather touch even less, the minimal variant rf"\b{re.escape(word)}(?!\w)" keeps the leading \b (every dictionary key starts with a word char, so it's equivalent there) and only replaces the trailing boundary — that's the one that can't hold after an apostrophe. Slightly faster, but asymmetric; I went symmetric to be safe. Happy to switch.

And since you mentioned there's no perf coverage — I'm glad to add a small timeit-based regression guard for the fix path in this PR if you'd find it useful.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants