Skip to content

Commit 429b2fe

Browse files
committed
mason: track apply_patch terminator sentinel instead of inferring it
Files ending in two or more newlines lost one terminal newline on every update: after popping the split terminator, the real trailing empty line was indistinguishable from the sentinel, so the join's emptiness check skipped appending it. Track had_trailing_newline at split time and append the sentinel from that flag at join time; unterminated files keep the existing newline-normalizing policy.
1 parent 9785314 commit 429b2fe

1 file changed

Lines changed: 88 additions & 3 deletions

File tree

crates/aft/src/patch/apply.rs

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ pub fn apply_update_chunks(
142142
chunks: &[UpdateFileChunk],
143143
) -> Result<String, String> {
144144
let line_ending = dominant_line_ending(original_content);
145+
// Remember at split time whether the file ended with a newline. The join below
146+
// must restore the terminator sentinel from this flag: once the lines are in a
147+
// buffer, a real trailing empty line (a file ending in two newlines) is
148+
// indistinguishable from the split terminator, so re-inferring it there loses
149+
// one terminal newline on every update to such files.
150+
let had_trailing_newline = original_content.ends_with('\n');
145151
// Remove CRLF's carriage return before matching, then restore the chosen convention on output.
146152
let mut original_lines: Vec<String> = original_content
147153
.split('\n')
@@ -252,7 +258,12 @@ pub fn apply_update_chunks(
252258
result.splice(start_idx..start_idx + old_len, new_segment);
253259
}
254260

255-
if result.last().is_none_or(|line| !line.is_empty()) {
261+
// Append the terminator sentinel from the tracked flag instead of inferring it
262+
// from the last line's emptiness: a real trailing empty line left behind by a
263+
// file ending in two newlines looks exactly like the sentinel, and declining to
264+
// push in that case would eat one terminal newline. Files that lacked a trailing
265+
// newline keep the historical policy of being normalized to newline-terminated.
266+
if had_trailing_newline || result.last().is_none_or(|line| !line.is_empty()) {
256267
result.push(String::new());
257268
}
258269

@@ -560,12 +571,18 @@ mod tests {
560571
}
561572

562573
#[test]
563-
fn trailing_empty_line_retry_matches_patch_parser_source_514_519() {
574+
fn trailing_empty_line_hunk_keeps_real_blank_line() {
564575
let chunks = [chunk(&["alpha", ""], &["beta", ""])];
565576

577+
// The full old pattern ["alpha", ""] never matches the one-line file at the
578+
// strict tiers; the reflow tier matches just "alpha", so new_lines' trailing
579+
// "" survives into the buffer. The join previously absorbed that empty line
580+
// as the terminator sentinel (yielding "beta\n"); since the terminator is
581+
// now tracked from the original content instead of inferred from the last
582+
// line, the empty line is real content and the output gains a blank line.
566583
assert_eq!(
567584
apply_update_chunks("alpha\n", "src/trailing.ts", &chunks).unwrap(),
568-
"beta\n"
585+
"beta\n\n"
569586
);
570587
}
571588

@@ -629,4 +646,72 @@ mod tests {
629646
assert_eq!(result.as_bytes(), expected);
630647
}
631648
}
649+
650+
#[test]
651+
fn update_preserves_two_terminal_newlines() {
652+
// Regression repro: "alpha\n\n" splits to ["alpha", "", ""]; popping the split
653+
// terminator leaves a REAL trailing empty line, which the join previously
654+
// mistook for the sentinel and emitted "beta\n" (one newline lost).
655+
let result = apply_update_chunks(
656+
"alpha\n\n",
657+
"src/double.txt",
658+
&[chunk(&["alpha"], &["beta"])],
659+
)
660+
.unwrap();
661+
assert_eq!(result.as_bytes(), b"beta\n\n");
662+
}
663+
664+
#[test]
665+
fn update_keeps_single_terminal_newline() {
666+
let result =
667+
apply_update_chunks("alpha\n", "src/single.txt", &[chunk(&["alpha"], &["beta"])])
668+
.unwrap();
669+
assert_eq!(result.as_bytes(), b"beta\n");
670+
}
671+
672+
#[test]
673+
fn update_normalizes_unterminated_file_to_newline_terminated() {
674+
// Policy preserved from before the terminal-newline fix: apply_patch already
675+
// normalized files without a trailing newline to newline-terminated output
676+
// (see mixed_and_unterminated_files_follow_dominant_newline_policy), so the
677+
// missing newline is deliberately added rather than preserved.
678+
let result =
679+
apply_update_chunks("alpha", "src/bare.txt", &[chunk(&["alpha"], &["beta"])]).unwrap();
680+
assert_eq!(result.as_bytes(), b"beta\n");
681+
}
682+
683+
#[test]
684+
fn hunk_that_explicitly_deletes_blank_line_still_deletes_it() {
685+
let interior = apply_update_chunks(
686+
"alpha\n\nbeta\n",
687+
"src/delete-blank.txt",
688+
&[chunk(&["alpha", ""], &["beta"])],
689+
)
690+
.unwrap();
691+
assert_eq!(interior.as_bytes(), b"beta\nbeta\n");
692+
693+
// Deleting the trailing blank line of a two-newline file leaves exactly one
694+
// terminal newline: the hunk consumed the real empty line, the sentinel is
695+
// still restored.
696+
let trailing = apply_update_chunks(
697+
"alpha\n\n",
698+
"src/delete-trailing.txt",
699+
&[chunk(&["alpha", ""], &["beta"])],
700+
)
701+
.unwrap();
702+
assert_eq!(trailing.as_bytes(), b"beta\n");
703+
}
704+
705+
#[test]
706+
fn crlf_with_two_terminal_newlines_preserves_majority_convention() {
707+
// The CRLF normalization (dominant_line_ending) must compose with the
708+
// tracked terminator: both terminal newlines survive, both as CRLF.
709+
let result = apply_update_chunks(
710+
"alpha\r\n\r\n",
711+
"src/crlf-double.txt",
712+
&[chunk(&["alpha"], &["beta"])],
713+
)
714+
.unwrap();
715+
assert_eq!(result.as_bytes(), b"beta\r\n\r\n");
716+
}
632717
}

0 commit comments

Comments
 (0)