Add inline summaries in --write-changes mode (#3836)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py
index 94ab65d..341795b 100644
--- a/codespell_lib/_codespell.py
+++ b/codespell_lib/_codespell.py
@@ -883,6 +883,32 @@
     return check_matches
 
 
+def _format_colored_output(
+    filename: str,
+    colors: TermColors,
+    line_num: int,
+    wrong: str,
+    right: str,
+) -> tuple[str, str, str, str]:
+    """Format colored strings for output.
+
+    Args:
+        filename: The filename being processed.
+        colors: TermColors instance for color formatting.
+        line_num: Line number (1-based) where the misspelling was found.
+        wrong: The misspelled word.
+        right: The correct word.
+
+    Returns:
+        Tuple of (filename, line_num, wrong_word, right_word) with color codes.
+    """
+    cfilename = f"{colors.FILE}{filename}{colors.DISABLE}"
+    cline = f"{colors.FILE}{line_num}{colors.DISABLE}"
+    cwrongword = f"{colors.WWORD}{wrong}{colors.DISABLE}"
+    crightword = f"{colors.FWORD}{right}{colors.DISABLE}"
+    return cfilename, cline, cwrongword, crightword
+
+
 def parse_lines(
     fragment: tuple[bool, int, list[str]],
     filename: str,
@@ -897,9 +923,10 @@
     uri_ignore_words: set[str],
     context: Optional[tuple[int, int]],
     options: argparse.Namespace,
-) -> tuple[int, bool]:
+) -> tuple[int, bool, list[tuple[int, str, str]]]:
     bad_count = 0
     changed = False
+    changes_made: list[tuple[int, str, str]] = []
 
     _, fragment_line_number, lines = fragment
 
@@ -985,6 +1012,7 @@
                     changed = True
                     lines[i] = re.sub(rf"\b{word}\b", fixword, lines[i])
                     fixed_words.add(word)
+                    changes_made.append((line_number + 1, word, fixword))
                     continue
 
                 # otherwise warning was explicitly set by interactive mode
@@ -995,10 +1023,9 @@
                 ):
                     continue
 
-                cfilename = f"{colors.FILE}{filename}{colors.DISABLE}"
-                cline = f"{colors.FILE}{line_number + 1}{colors.DISABLE}"
-                cwrongword = f"{colors.WWORD}{word}{colors.DISABLE}"
-                crightword = f"{colors.FWORD}{fixword}{colors.DISABLE}"
+                cfilename, cline, cwrongword, crightword = _format_colored_output(
+                    filename, colors, line_number + 1, word, fixword
+                )
 
                 reason = misspellings[lword].reason
                 if reason:
@@ -1028,7 +1055,7 @@
                         f"==> {crightword}{creason}"
                     )
 
-    return bad_count, changed
+    return bad_count, changed, changes_made
 
 
 def parse_file(
@@ -1068,9 +1095,9 @@
                 if summary and fix:
                     summary.update(lword)
 
-                cfilename = f"{colors.FILE}{filename}{colors.DISABLE}"
-                cwrongword = f"{colors.WWORD}{word}{colors.DISABLE}"
-                crightword = f"{colors.FWORD}{fixword}{colors.DISABLE}"
+                cfilename, _, cwrongword, crightword = _format_colored_output(
+                    filename, colors, 0, word, fixword
+                )
 
                 reason = misspellings[lword].reason
                 if reason:
@@ -1109,12 +1136,13 @@
 
     # Parse lines.
     changed = False
+    changes_made: list[tuple[int, str, str]] = []
     for fragment in fragments:
         ignore, _, _ = fragment
         if ignore:
             continue
 
-        bad_count_update, changed_update = parse_lines(
+        bad_count_update, changed_update, changes_made_update = parse_lines(
             fragment,
             filename,
             colors,
@@ -1131,6 +1159,7 @@
         )
         bad_count += bad_count_update
         changed = changed or changed_update
+        changes_made.extend(changes_made_update)
 
     # Write out lines, if changed.
     if changed:
@@ -1145,6 +1174,14 @@
                     f"{colors.FWORD}FIXED:{colors.DISABLE} {filename}",
                     file=sys.stderr,
                 )
+                for line_num, wrong, right in changes_made:
+                    cfilename, cline, cwrongword, crightword = _format_colored_output(
+                        filename, colors, line_num, wrong, right
+                    )
+                    print(
+                        f"  {cfilename}:{cline}: {cwrongword} ==> {crightword}",
+                        file=sys.stderr,
+                    )
             with open(filename, "w", encoding=encoding, newline="") as f:
                 for _, _, lines in fragments:
                     f.writelines(lines)
diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py
index d8f97ea..44b17ca 100644
--- a/codespell_lib/tests/test_basic.py
+++ b/codespell_lib/tests/test_basic.py
@@ -169,6 +169,31 @@
     assert cs.main(tmp_path) == 0
 
 
+def test_write_changes_lists_changes(
+    tmp_path: Path,
+    capsys: pytest.CaptureFixture[str],
+) -> None:
+    """Test that -w flag shows list of changes made to file."""
+
+    fname = tmp_path / "misspelled.txt"
+    fname.write_text("This is abandonned\nAnd this is occured\nAlso teh typo\n")
+
+    result = cs.main("-w", fname, std=True)
+    assert isinstance(result, tuple)
+    code, _, stderr = result
+    assert code == 0
+
+    assert "FIXED:" in stderr
+
+    # Check that changes are listed with format: filename:line: wrong ==> right
+    assert "misspelled.txt:1: abandonned ==> abandoned" in stderr
+    assert "misspelled.txt:2: occured ==> occurred" in stderr
+    assert "misspelled.txt:3: teh ==> the" in stderr
+
+    corrected = fname.read_text()
+    assert corrected == "This is abandoned\nAnd this is occurred\nAlso the typo\n"
+
+
 def test_default_word_parsing(
     tmp_path: Path,
     capsys: pytest.CaptureFixture[str],