TestFuzzyReplace_EndingNormalization pins the line-ending rule. Rule: every spliced line gets the file's dominant ending, except when the caller signaled intent by making search and replace disagree on internal endings (both non-empty, different). Intent requires pass 1 to byte-match the file's end
(t *testing.T)
| 2082 | // ending, so a match covering the file's last line does not |
| 2083 | // materialize a newline the file never had. |
| 2084 | func TestFuzzyReplace_EndingNormalization(t *testing.T) { |
| 2085 | t.Parallel() |
| 2086 | |
| 2087 | tmpdir := os.TempDir() |
| 2088 | logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) |
| 2089 | |
| 2090 | type edit struct { |
| 2091 | search, replace string |
| 2092 | replaceAll bool |
| 2093 | } |
| 2094 | tests := []struct { |
| 2095 | name string |
| 2096 | content string |
| 2097 | edits []edit |
| 2098 | expected string |
| 2099 | }{ |
| 2100 | // CRLF file, LF search, LF replace with expansion. |
| 2101 | // Internal endings agree (both LF), rule fires, every |
| 2102 | // spliced line becomes CRLF. |
| 2103 | { |
| 2104 | name: "CRLFFile_LFSearchReplace_Expansion", |
| 2105 | content: "line1\r\nline2\r\nline3\r\n", |
| 2106 | edits: []edit{{search: "line1\nline2\n", replace: "line1\nINSERTED\nline2\n"}}, |
| 2107 | expected: "line1\r\nINSERTED\r\nline2\r\nline3\r\n", |
| 2108 | }, |
| 2109 | // CRLF file with no trailing newline, LF search/replace |
| 2110 | // with expansion that covers the file's last line. Interior |
| 2111 | // spliced lines become CRLF; final spliced line preserves |
| 2112 | // the file's no-EOL property. |
| 2113 | { |
| 2114 | name: "CRLFFileNoEOL_LFSearchReplace_ExpansionAtEOF", |
| 2115 | content: "alpha\r\nbeta\r\ngamma", |
| 2116 | edits: []edit{{search: "gamma", replace: "gamma\ndelta\nepsilon"}}, |
| 2117 | expected: "alpha\r\nbeta\r\ngamma\r\ndelta\r\nepsilon", |
| 2118 | }, |
| 2119 | // CRLF Go file with no final newline; LLM sends LF |
| 2120 | // search/replace that expands the function body. This is |
| 2121 | // the motivating real-world case for the rule. |
| 2122 | { |
| 2123 | name: "CRLFFileNoEOL_LFCallerExpandsFunctionBody", |
| 2124 | content: "package main\r\n\r\nfunc main() {\r\n\tprintln(\"hi\")\r\n}", |
| 2125 | edits: []edit{{search: "\tprintln(\"hi\")\n}", replace: "\tprintln(\"hi\")\n\tprintln(\"bye\")\n\treturn\n}"}}, |
| 2126 | expected: "package main\r\n\r\nfunc main() {\r\n\tprintln(\"hi\")\r\n\tprintln(\"bye\")\r\n\treturn\r\n}", |
| 2127 | }, |
| 2128 | // LF file, CRLF search/replace (caller sent CRLF, file is |
| 2129 | // LF). Internal endings agree (both CRLF). Rule fires, the |
| 2130 | // file's LF wins. |
| 2131 | { |
| 2132 | name: "LFFile_CRLFSearchReplace_FileLFWins", |
| 2133 | content: "one\ntwo\nthree\n", |
| 2134 | edits: []edit{{search: "one\r\ntwo\r\n", replace: "ONE\r\nTWO\r\n"}}, |
| 2135 | expected: "ONE\nTWO\nthree\n", |
| 2136 | }, |
| 2137 | // Caller got endings right: CRLF in search, replace, and file. |
| 2138 | // Pins that normalization doesn't regress this happy path. |
| 2139 | { |
| 2140 | name: "CRLFFile_CRLFSearchReplace_SanityPreserved", |
| 2141 | content: "a\r\nb\r\nc\r\n", |