1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
//! Byte-preserving line edits (#2227).
//!
//! `str::lines()` is lossy for a read-modify-write: it drops the terminator and
//! a trailing `\r`. A tool that rebuilds a file from `lines()` and `join("\n")`
//! can only put back the ending it was told to, so a CRLF file silently came
//! out LF and every file lost its final newline. Nothing in the tool result said
//! so, because the diff only lists changed lines.
//!
//! These helpers splice the edited byte range out of the original instead, so
//! every byte nobody asked about is preserved exactly. `fuzzy_replace_once` has
//! worked this way since #2212; the line operations of `edit_file` did not, and
//! `hashline_edit` still rebuilt the whole document.
/// Byte span of every line, terminator included.
///
/// `spans.len()` always equals `content.lines().count()`, so callers can keep
/// validating bounds against the line count they already compute.
pub(crate) fn line_spans(content: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut start = 0;
for (i, byte) in content.bytes().enumerate() {
if byte == b'\n' {
spans.push((start, i + 1));
start = i + 1;
}
}
if start < content.len() {
spans.push((start, content.len()));
}
spans
}
/// Borrow `content[start..end]`.
///
/// Every index these helpers pass comes from [`line_spans`], which only splits
/// at `b'\n'` or at `content.len()`. A `0x0A` byte is never a UTF-8
/// continuation byte, so both indices are char boundaries by construction and
/// the slice cannot land mid-character. `get` rather than `[..]` means a future
/// caller that breaks that invariant gets a named panic instead of a bare
/// slice panic with no explanation (#2227).
fn slice(content: &str, start: usize, end: usize) -> &str {
content
.get(start..end)
.expect("line_ops indices come from line_spans, which yields char boundaries")
}
/// The line ending used by line `index`.
///
/// The line's own ending wins, which keeps a mixed-ending file mixed. An
/// unterminated last line carries no ending of its own, so the line before it
/// describes the file just as well.
pub(crate) fn eol_at(content: &str, spans: &[(usize, usize)], index: usize) -> &'static str {
if index < spans.len() {
let line = slice(content, spans[index].0, spans[index].1);
if line.contains("\r\n") {
return "\r\n";
}
}
if index > 0
&& let Some(&(start, end)) = spans.get(index - 1)
&& slice(content, start, end).contains("\r\n")
{
return "\r\n";
}
"\n"
}
/// The ending a whole-document rebuild should use.
///
/// For callers that cannot splice because they replace the document wholesale
/// (`hashline_edit` applies edits bottom-up over a line vector). A file with
/// equal counts of both endings is treated as LF, the commoner case.
pub(crate) fn dominant_eol(content: &str) -> &'static str {
let crlf = content.matches("\r\n").count();
let lf = content.matches('\n').count().saturating_sub(crlf);
if crlf > lf { "\r\n" } else { "\n" }
}
/// Rewrite caller-supplied text with `eol` as its line separator.
///
/// `read_file` shows the model CR-stripped text, so a multi-line replacement
/// arrives with bare LF even when the file is CRLF. The inserted lines have to
/// adopt the file's ending, or the untouched bytes are preserved and the new
/// ones still land as LF.
///
/// One trailing newline is treated as the caller terminating the block rather
/// than as a request for an extra blank line; the caller supplies the block's
/// terminator separately. This matches how `fuzzy_replace_once` reads its
/// replacement text.
pub(crate) fn with_eol(text: &str, eol: &str) -> String {
let lf = text.replace("\r\n", "\n");
let block = lf.strip_suffix('\n').unwrap_or(&lf);
if eol == "\n" {
block.to_string()
} else {
block.replace('\n', eol)
}
}
fn splice(content: &str, start: usize, end: usize, body: &str) -> String {
let mut out = String::with_capacity(content.len() - (end - start) + body.len());
out.push_str(slice(content, 0, start));
out.push_str(body);
out.push_str(slice(content, end, content.len()));
out
}
/// Replace lines `start..=end` (0-indexed) with `new_text`.
///
/// The region's own terminator decides whether the replacement ends with one,
/// so a file keeps its final newline (or keeps lacking one).
pub(crate) fn replace_lines(content: &str, start: usize, end: usize, new_text: &str) -> String {
let spans = line_spans(content);
let eol = eol_at(content, &spans, start);
let region_start = spans[start].0;
let region_end = spans[end].1;
let region_terminated = slice(content, region_start, region_end).ends_with('\n');
let mut body = with_eol(new_text, eol);
if region_terminated {
body.push_str(eol);
}
splice(content, region_start, region_end, &body)
}
/// Insert `text` before line `line` (0-indexed), or at EOF when `line` equals
/// the line count.
pub(crate) fn insert_line(content: &str, line: usize, text: &str) -> String {
let spans = line_spans(content);
let offset = if line < spans.len() {
spans[line].0
} else {
content.len()
};
let eol = eol_at(content, &spans, line.min(spans.len().saturating_sub(1)));
let at_eof = offset == content.len();
let mut body = String::new();
if offset > 0 && !slice(content, 0, offset).ends_with('\n') {
// Appending to a file whose last line had no terminator: that line
// needs one now, or the two would merge into one.
body.push_str(eol);
}
body.push_str(&with_eol(text, eol));
if !at_eof || content.ends_with('\n') {
body.push_str(eol);
}
splice(content, offset, offset, &body)
}
/// Delete lines `start..=end` (0-indexed).
pub(crate) fn delete_lines(content: &str, start: usize, end: usize) -> String {
let spans = line_spans(content);
splice(content, spans[start].0, spans[end].1, "")
}