Module: Mistri::Edit
- Defined in:
- lib/mistri/edit.rb
Overview
Pure fuzzy text replacement: no files, no I/O. This is the string core that a workspace-backed edit tool calls, so it works the same against a database row as against a file on disk.
Each edit's old text must match one region and only one, so an edit can never silently change the wrong place. Matching relaxes in two steps: an exact substring first, then a whitespace-tolerant line match that forgives the indentation and trailing-space drift models introduce when they reproduce code they read. Unmatched regions keep their exact bytes, including the file's original line endings.
Defined Under Namespace
Class Method Summary collapse
-
.adapt_newlines(content, text) ⇒ Object
Match the document's dominant newline style so a replacement authored with bare LF does not mix endings into a CRLF document.
- .ambiguous_message(edit, line_numbers) ⇒ Object
-
.apply(content, edits) ⇒ Object
Apply edits (each new:, string or symbol keys) to content and return the new content.
-
.best_window(lines, wanted) ⇒ Object
Score windows by per-line bigram similarity, so a one-character typo in a one-line old_string still finds its region.
- .bigrams(text) ⇒ Object
- .exact_match(content, edit) ⇒ Object
-
.fuzzy_match(content, edit) ⇒ Object
Match the old text's lines against a window of content lines, comparing each line stripped of leading and trailing whitespace.
- .line_number_at(content, offset) ⇒ Object
-
.line_spans(content) ⇒ Object
Each line with its character span in the original and its stripped form.
-
.locate(content, edit) ⇒ Object
Exact match first; on a miss, a whitespace-tolerant line match.
- .matching_windows(lines, wanted) ⇒ Object
-
.nearest_region(content, old_text) ⇒ Object
The window with the most stripped-equal lines, plus its first differing line pair.
- .normalize(edit, index) ⇒ Object
-
.not_found_message(content, edit) ⇒ Object
When nothing matched, show the model the closest region and exactly how it differs, so the retry is one shot instead of a guessing loop.
- .occurrence_offsets(content, needle) ⇒ Object
- .reject_overlaps(matches) ⇒ Object
-
.replace(content, old_string, new_string, replace_all: false) ⇒ Object
The model-facing single edit: replace old_string once (unique match required) or everywhere with replace_all.
- .replace_every(content, old, new) ⇒ Object
- .similarity(left, right) ⇒ Object
Class Method Details
.adapt_newlines(content, text) ⇒ Object
Match the document's dominant newline style so a replacement authored with bare LF does not mix endings into a CRLF document.
83 84 85 86 87 88 89 |
# File 'lib/mistri/edit.rb', line 83 def adapt_newlines(content, text) crlf = content.scan("\r\n").length = content.scan(/(?<!\r)\n/).length return text.gsub(/\r?\n/, "\r\n") if crlf > crlf.positive? || .positive? ? text.gsub("\r\n", "\n") : text end |
.ambiguous_message(edit, line_numbers) ⇒ Object
115 116 117 118 119 120 121 |
# File 'lib/mistri/edit.rb', line 115 def (edit, line_numbers) shown = line_numbers.first(4).join(", ") shown += ", ..." if line_numbers.length > 4 "edits[#{edit[:index]}] old text matched #{line_numbers.length} places " \ "(lines #{shown}). Add surrounding lines until it is unique, or set " \ "replace_all: true to change all #{line_numbers.length}." end |
.apply(content, edits) ⇒ Object
Apply edits (each new:, string or symbol keys) to content and return the new content. Raises EditError when an edit matches nothing, matches more than once, overlaps another, or changes nothing.
42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/mistri/edit.rb', line 42 def apply(content, edits) normalized = edits.each_with_index.map { |edit, i| normalize(edit, i) } matches = normalized.map { |edit| locate(content, edit) }.sort_by(&:start) reject_overlaps(matches) result = matches.reverse.reduce(content) do |text, match| text[0...match.start] + match.replacement + text[match.finish..] end raise EditError, "the edits changed nothing" if result == content result end |
.best_window(lines, wanted) ⇒ Object
Score windows by per-line bigram similarity, so a one-character typo in a one-line old_string still finds its region. Only a window at least half-similar overall is worth reporting.
183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/mistri/edit.rb', line 183 def best_window(lines, wanted) best = nil best_score = wanted.length / 2.0 (0..(lines.length - wanted.length)).each do |i| score = (0...wanted.length).sum { |j| similarity(lines[i + j][:stripped], wanted[j]) } if score > best_score best_score = score best = i end end best end |
.bigrams(text) ⇒ Object
207 |
# File 'lib/mistri/edit.rb', line 207 def bigrams(text) = (0...(text.length - 1)).map { |i| text[i, 2] }.uniq |
.exact_match(content, edit) ⇒ Object
91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/mistri/edit.rb', line 91 def exact_match(content, edit) offsets = occurrence_offsets(content, edit[:old]) return nil if offsets.empty? if offsets.length > 1 lines = offsets.map { |offset| line_number_at(content, offset) } raise EditError, (edit, lines) end first = offsets.first Match.new(first, first + edit[:old].length, edit[:new], edit[:index]) end |
.fuzzy_match(content, edit) ⇒ Object
Match the old text's lines against a window of content lines, comparing each line stripped of leading and trailing whitespace. The matched region is the exact original bytes those content lines span.
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/mistri/edit.rb', line 126 def fuzzy_match(content, edit) lines = line_spans(content) wanted = edit[:old].lines.map(&:strip) wanted.pop if wanted.last == "" # a trailing newline in old text is not a line to match return nil if wanted.empty? windows = matching_windows(lines, wanted) return nil if windows.empty? raise EditError, (edit, windows.map { |w| w + 1 }) if windows.length > 1 first = windows.first Match.new(lines[first][:start], lines[first + wanted.length - 1][:finish], edit[:new], edit[:index]) end |
.line_number_at(content, offset) ⇒ Object
113 |
# File 'lib/mistri/edit.rb', line 113 def line_number_at(content, offset) = content[0...offset].count("\n") + 1 |
.line_spans(content) ⇒ Object
Each line with its character span in the original and its stripped form. A leading BOM is invisible to matching, and the first span starts after it, so a replacement at the top of the document never swallows it.
218 219 220 221 222 223 224 225 226 227 |
# File 'lib/mistri/edit.rb', line 218 def line_spans(content) offset = 0 content.lines.map do |line| bom = offset.zero? && line.start_with?("\uFEFF") ? 1 : 0 span = { start: offset + bom, finish: offset + line.length, stripped: line.delete_prefix("\uFEFF").strip } offset += line.length span end end |
.locate(content, edit) ⇒ Object
Exact match first; on a miss, a whitespace-tolerant line match. Either level must resolve to exactly one region. A total miss reports the closest region and its precise difference, so the model's retry can be one-shot.
67 68 69 70 |
# File 'lib/mistri/edit.rb', line 67 def locate(content, edit) exact_match(content, edit) || fuzzy_match(content, edit) || raise(EditError, (content, edit)) end |
.matching_windows(lines, wanted) ⇒ Object
209 210 211 212 213 |
# File 'lib/mistri/edit.rb', line 209 def matching_windows(lines, wanted) (0..(lines.length - wanted.length)).select do |i| wanted.each_with_index.all? { |line, j| lines[i + j][:stripped] == line } end end |
.nearest_region(content, old_text) ⇒ Object
The window with the most stripped-equal lines, plus its first differing line pair.
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 |
# File 'lib/mistri/edit.rb', line 160 def nearest_region(content, old_text) lines = line_spans(content) wanted_raw = old_text.lines.map(&:chomp) wanted = wanted_raw.map(&:strip) wanted.pop && wanted_raw.pop if wanted.last == "" return nil if wanted.empty? || lines.length < wanted.length best = best_window(lines, wanted) return nil unless best diff_at = (0...wanted.length).find { |j| lines[best + j][:stripped] != wanted[j] } return nil unless diff_at yours = wanted_raw[diff_at] theirs = content.lines[best + diff_at].to_s.chomp hint = yours.strip == theirs.strip ? " (differs only in whitespace)" : "" { from: best + 1, to: best + wanted.length, line: best + diff_at + 1, yours: yours, theirs: theirs, hint: hint } end |
.normalize(edit, index) ⇒ Object
55 56 57 58 59 60 61 |
# File 'lib/mistri/edit.rb', line 55 def normalize(edit, index) edit = edit.transform_keys(&:to_sym) old = edit[:old].to_s raise EditError, "edits[#{index}] has empty old text" if old.empty? { old: old, new: edit[:new].to_s, index: index } end |
.not_found_message(content, edit) ⇒ Object
When nothing matched, show the model the closest region and exactly how it differs, so the retry is one shot instead of a guessing loop.
144 145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'lib/mistri/edit.rb', line 144 def (content, edit) base = "edits[#{edit[:index]}] old text was not found" near = nearest_region(content, edit[:old]) unless near return "#{base}. Copy old_string verbatim from read_file output, " \ "without line-number prefixes." end "#{base}. Closest region is lines #{near[:from]}-#{near[:to]}; it differs at " \ "line #{near[:line]}: your text #{near[:yours].inspect} vs the document's " \ "#{near[:theirs].inspect}#{near[:hint]}. Copy old_string verbatim from " \ "read_file output, then resend." end |
.occurrence_offsets(content, needle) ⇒ Object
103 104 105 106 107 108 109 110 111 |
# File 'lib/mistri/edit.rb', line 103 def occurrence_offsets(content, needle) offsets = [] offset = content.index(needle) while offset offsets << offset offset = content.index(needle, offset + 1) end offsets end |
.reject_overlaps(matches) ⇒ Object
229 230 231 232 233 234 235 236 |
# File 'lib/mistri/edit.rb', line 229 def reject_overlaps(matches) matches.each_cons(2) do |a, b| next if a.finish <= b.start raise EditError, "edits[#{a.edit_index}] and edits[#{b.edit_index}] overlap; " \ "merge them or target separate regions" end end |
.replace(content, old_string, new_string, replace_all: false) ⇒ Object
The model-facing single edit: replace old_string once (unique match required) or everywhere with replace_all. Returns a Result carrying the new content and how many places changed. The replacement adapts to the document's newline style, so an LF-authored new_string dropped into a CRLF document does not mix endings.
25 26 27 28 29 30 31 32 33 34 35 36 37 |
# File 'lib/mistri/edit.rb', line 25 def replace(content, old_string, new_string, replace_all: false) old = old_string.to_s raise EditError, "old_string is empty" if old.empty? new = adapt_newlines(content, new_string.to_s) return replace_every(content, old, new) if replace_all match = locate(content, { old: old, new: new, index: 0 }) changed = content[0...match.start] + match.replacement + content[match.finish..] raise EditError, "the edit changed nothing" if changed == content Result.new(content: changed, count: 1) end |
.replace_every(content, old, new) ⇒ Object
72 73 74 75 76 77 78 79 |
# File 'lib/mistri/edit.rb', line 72 def replace_every(content, old, new) count = content.enum_for(:scan, old).count raise EditError, (content, { old: old, index: 0 }) if count.zero? # Block form keeps both sides literal; a bare string replacement would # interpret backslash sequences. Result.new(content: content.gsub(old) { new }, count: count) end |
.similarity(left, right) ⇒ Object
196 197 198 199 200 201 202 203 204 205 |
# File 'lib/mistri/edit.rb', line 196 def similarity(left, right) return 1.0 if left == right return 0.0 if left.empty? || right.empty? pairs_left = bigrams(left) pairs_right = bigrams(right) return 0.0 if pairs_left.empty? || pairs_right.empty? (2.0 * (pairs_left & pairs_right).length) / (pairs_left.length + pairs_right.length) end |