Skip to main content

lattice_magit/
hunk.rs

1//! MG.18b: locating the hunk at a cursor, and turning it into a patch.
2//!
3//! Pure functions over diff text. No buffer, no store, no `Editor` —
4//! `magit.md` §7.2's substrate-helper pattern, and what lets MG.22
5//! relocate this into `magit-hunk-mode` rather than rewrite it.
6//!
7//! # Why lines arrive through an accessor
8//!
9//! [`hunk_at_with`] pulls lines one at a time by index rather than
10//! taking a slice. Its production caller reads a buffer snapshot, and
11//! a `*magit:diff*` buffer can hold tens of thousands of lines —
12//! materialising all of them to stage one hunk would put an
13//! O(document) copy on a keystroke path (paramount #1). Through the
14//! accessor the read is O(distance to the file header + hunk body),
15//! which is the work the answer actually costs. The slice form below
16//! is the same parser, adapted for tests.
17//!
18//! # Why the buffer text is the source of truth
19//!
20//! `magit.md` §7.5: hunk boundaries are *already* derived from buffer
21//! text. `]c` / `[c` walk `hunk_lines` in `magit_core_mode.rs` — a raw
22//! scan for `@@` / `diff --git`, identical in every magit buffer,
23//! consulting no cache. Staging reads the same text, so navigation and
24//! staging cannot disagree about where a hunk begins. A parsed-hunk
25//! cache (§7.2 records one as deliberately absent) would give `]c` one
26//! boundary set and `s` another with nothing forcing agreement.
27//!
28//! # Why headers are copied, never reconstructed
29//!
30//! [`HunkPatch::header`] holds the file's header lines **verbatim** —
31//! `diff --git`, `index`, mode changes, `---`, `+++`. Rebuilding them
32//! from a parsed path would have to re-derive git's own quoting for
33//! paths with spaces or non-ASCII bytes, and would silently drop
34//! rename and mode metadata. Copying cannot get any of that wrong.
35
36use std::fmt::Write as _;
37use std::path::PathBuf;
38
39use crate::highlight::{DiffLineClass, classify_diff_line};
40
41/// One hunk, plus the file header needed to apply it on its own.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct HunkPatch {
44    /// Verbatim file-header lines: `diff --git` through `+++`.
45    pub header: Vec<String>,
46    /// The `@@` line and its body, verbatim.
47    pub hunk: Vec<String>,
48    /// Buffer line of the `@@` header, for cursor restoration.
49    pub header_line: usize,
50    /// Buffer line one past the hunk's last body line.
51    pub end_line: usize,
52}
53
54impl HunkPatch {
55    /// `path:line` naming this hunk's position **in the file** — the
56    /// `@@` header's new-side start, not a buffer row. Prompts and
57    /// echoes use it, so `Discard hunk at src/main.rs:42?` points at
58    /// something the user can find after the buffer is rebuilt.
59    ///
60    /// Falls back to the path alone, then to `"hunk"`, so a header
61    /// this cannot parse still yields a sentence.
62    pub fn display_location(&self) -> String {
63        let path = self.display_path();
64        let start = self
65            .hunk
66            .first()
67            .and_then(|h| parse_hunk_starts(h.trim_end()));
68        match (path, start) {
69            (Some(p), Some(HunkStarts { new, .. })) => format!("{p}:{new}"),
70            (Some(p), None) => p.to_string(),
71            (None, _) => "hunk".to_string(),
72        }
73    }
74
75    /// The file this hunk belongs to, for finding it again after the
76    /// buffer is rebuilt (MG.18d).
77    ///
78    /// Prefers the `+++ b/` side and falls back to `--- a/`, so a
79    /// deletion — whose `+++` is `/dev/null` — still names its file.
80    /// [`Self::display_path`] deliberately does not: a prompt saying
81    /// "discard hunk at /dev/null" would be worse than one that omits
82    /// the path, while a cursor restore that skipped deletions would
83    /// silently lose the user's place on exactly the rows that are
84    /// hardest to find again.
85    pub fn file_path(&self) -> Option<&str> {
86        self.display_path().or_else(|| {
87            let minus = self.header.iter().find(|l| l.starts_with("--- "))?;
88            let rest = minus[4..].trim_end();
89            (rest != "/dev/null").then(|| rest.strip_prefix("a/").unwrap_or(rest))
90        })
91    }
92
93    /// The path from the `+++ b/…` line, for prompts and messages.
94    /// `None` for a deletion (`+++ /dev/null`) or a malformed header.
95    ///
96    /// Display only — [`Self::to_patch`] never consults it, so a path
97    /// this cannot parse still stages correctly.
98    pub fn display_path(&self) -> Option<&str> {
99        let plus = self.header.iter().find(|l| l.starts_with("+++ "))?;
100        let rest = plus[4..].trim_end();
101        if rest == "/dev/null" {
102            return None;
103        }
104        Some(rest.strip_prefix("b/").unwrap_or(rest))
105    }
106
107    /// A standalone patch: the file header, then this hunk alone.
108    ///
109    /// Always ends in a newline — `git apply` rejects a patch whose
110    /// final line is unterminated.
111    pub fn to_patch(&self) -> String {
112        let mut out = String::new();
113        for line in self.header.iter().chain(self.hunk.iter()) {
114            let _ = writeln!(out, "{line}");
115        }
116        out
117    }
118}
119
120/// Which way a patch will be handed to `git apply`, and therefore
121/// which side of it the target already matches.
122///
123/// MG.18e: this is the only thing that distinguishes staging a region
124/// from unstaging one. The two are mirror images — one function with a
125/// flag, not two that can drift.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum ApplyDirection {
128    /// `s` — the target holds the hunk's **old** side.
129    Forward,
130    /// `u` / `x` — the target holds the hunk's **new** side.
131    Reverse,
132}
133
134impl HunkPatch {
135    /// MG.18e: rewrite this hunk to carry only the changes on the buffer
136    /// rows in `selected`.
137    ///
138    /// Region staging is not "a smaller hunk" — the body has to be
139    /// *rewritten*, because a patch must still describe a complete
140    /// transformation of the region it covers:
141    ///
142    /// | Line | Selected | Unselected (`Forward`) | Unselected (`Reverse`) |
143    /// |---|---|---|---|
144    /// | `+added` | stays `+` | **dropped** | becomes context |
145    /// | `-removed` | stays `-` | becomes context | **dropped** |
146    /// | context | context | context | context |
147    ///
148    /// The asymmetry is not a convention, it is what the target
149    /// contains. Applying forward, the target holds the old side: an
150    /// unselected `+` is not there and must not appear at all, while an
151    /// unselected `-` *is* there and survives — i.e. context. Reversed,
152    /// the target holds the new side and the roles swap exactly.
153    ///
154    /// Both counts are recounted from the rewritten body; `git apply`
155    /// validates them against it and rejects the patch outright if they
156    /// disagree ("corrupt patch"). The two **start** lines are kept
157    /// verbatim: whichever side the target matches is preserved
158    /// line-for-line by the rules above, so its start is still correct,
159    /// and the other side's start is not something git checks.
160    ///
161    /// `None` when the selection contains no `+`/`-` line at all — a
162    /// body of pure context is a patch that does nothing, and telling
163    /// the user "nothing to stage there" beats handing git a no-op.
164    pub fn restrict_to_rows(
165        &self,
166        selected: std::ops::RangeInclusive<usize>,
167        direction: ApplyDirection,
168    ) -> Option<HunkPatch> {
169        let mut body: Vec<String> = Vec::new();
170        let mut selected_changes = 0usize;
171        // `\ No newline at end of file` annotates the line above it, so
172        // it rides along only if that line survived. Dropping the line
173        // and keeping its marker would attach it to whatever came
174        // before, silently changing THAT line's trailing newline.
175        let mut kept_previous = false;
176
177        for (k, line) in self.hunk.iter().enumerate().skip(1) {
178            // The parser consumed the body contiguously, markers
179            // included, so `hunk[k]` is buffer row `header_line + k`.
180            let row = self.header_line + k;
181            if line.starts_with('\\') {
182                if kept_previous {
183                    body.push(line.clone());
184                }
185                continue;
186            }
187            let marker = line.chars().next();
188            let is_change = matches!(marker, Some('+') | Some('-'));
189            if !is_change {
190                body.push(line.clone());
191                kept_previous = true;
192                continue;
193            }
194            let is_add = marker == Some('+');
195            if selected.contains(&row) {
196                body.push(line.clone());
197                kept_previous = true;
198                selected_changes += 1;
199                continue;
200            }
201            let dropped = match direction {
202                ApplyDirection::Forward => is_add,
203                ApplyDirection::Reverse => !is_add,
204            };
205            if dropped {
206                kept_previous = false;
207            } else {
208                // Contextualise: same content, no marker. `+`/`-` are
209                // one byte, so the slice is char-boundary safe.
210                body.push(format!(" {}", &line[1..]));
211                kept_previous = true;
212            }
213        }
214
215        if selected_changes == 0 {
216            return None;
217        }
218
219        let old = body
220            .iter()
221            .filter(|l| !l.starts_with('\\') && !l.starts_with('+'))
222            .count();
223        let new = body
224            .iter()
225            .filter(|l| !l.starts_with('\\') && !l.starts_with('-'))
226            .count();
227        let header = rewrite_hunk_header(self.hunk.first()?, old, new)?;
228
229        let mut hunk = Vec::with_capacity(body.len() + 1);
230        hunk.push(header);
231        hunk.extend(body);
232        Some(HunkPatch {
233            header: self.header.clone(),
234            hunk,
235            header_line: self.header_line,
236            end_line: self.end_line,
237        })
238    }
239}
240
241/// Rebuild an `@@` header with new counts, keeping both starts and any
242/// trailing function-context suffix git emitted.
243fn rewrite_hunk_header(original: &str, old: usize, new: usize) -> Option<String> {
244    let trimmed = original.trim_end();
245    let starts = parse_hunk_starts(trimmed)?;
246    // Everything after the closing `@@` — git's function-context hint.
247    // Carried through rather than recomputed: it is a display aid, and
248    // deriving it would mean guessing at the language's idea of an
249    // enclosing definition.
250    let suffix = trimmed
251        .strip_prefix("@@ ")
252        .and_then(|rest| rest.split_once(" @@"))
253        .map(|(_, after)| after)
254        .unwrap_or("");
255    Some(format!(
256        "@@ -{},{} +{},{} @@{}",
257        starts.old, old, starts.new, new, suffix
258    ))
259}
260
261/// The `-old,count +new,count` line counts declared by an `@@` header.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263struct HunkCounts {
264    old: usize,
265    new: usize,
266}
267
268/// Parse `@@ -12,7 +12,8 @@ trailing context` into its two counts.
269///
270/// A missing count means 1 (`@@ -12 +12 @@` is a one-line hunk) — an
271/// omission git emits routinely and a naive `split(',')` drops.
272fn parse_hunk_counts(header: &str) -> Option<HunkCounts> {
273    let inner = header.strip_prefix("@@ ")?;
274    let inner = inner.split(" @@").next()?;
275    let mut parts = inner.split_whitespace();
276    let old = parse_range_count(parts.next()?.strip_prefix('-')?)?;
277    let new = parse_range_count(parts.next()?.strip_prefix('+')?)?;
278    Some(HunkCounts { old, new })
279}
280
281/// `"12,7"` → 7; `"12"` → 1.
282fn parse_range_count(range: &str) -> Option<usize> {
283    match range.split_once(',') {
284        Some((_, count)) => count.parse().ok(),
285        None => Some(1),
286    }
287}
288
289/// The `-old +new` **start lines** declared by an `@@` header — the
290/// file positions, used only to name the hunk in prompts.
291struct HunkStarts {
292    old: usize,
293    new: usize,
294}
295
296fn parse_hunk_starts(header: &str) -> Option<HunkStarts> {
297    let inner = header.strip_prefix("@@ ")?;
298    let inner = inner.split(" @@").next()?;
299    let mut parts = inner.split_whitespace();
300    let old = parse_range_start(parts.next()?.strip_prefix('-')?)?;
301    let new = parse_range_start(parts.next()?.strip_prefix('+')?)?;
302    Some(HunkStarts { old, new })
303}
304
305/// `"12,7"` → 12; `"12"` → 12.
306fn parse_range_start(range: &str) -> Option<usize> {
307    range
308        .split_once(',')
309        .map(|(start, _)| start)
310        .unwrap_or(range)
311        .parse()
312        .ok()
313}
314
315/// Locate the hunk containing `cursor` in `lines`.
316///
317/// The slice form of [`hunk_at_with`], for tests: production reads a
318/// buffer, which has no slice to hand.
319#[cfg(test)]
320pub(crate) fn hunk_at(lines: &[&str], cursor: usize) -> Option<HunkPatch> {
321    hunk_at_with(|i| lines.get(i).map(|l| (*l).to_string()), cursor)
322}
323
324/// Locate the hunk containing `cursor`, reading lines through `read`.
325///
326/// `read(i)` returns line `i` without its trailing newline, or `None`
327/// past the end. Lines are used **verbatim** — trailing whitespace is
328/// part of the diff's content, and trimming it produces a patch whose
329/// context no longer matches the file.
330///
331/// Returns `None` when the cursor is not inside a hunk body — on a
332/// section header, a file entry, a commit message, a diff's own
333/// `---`/`+++` header lines, or anywhere *after* the last body line of
334/// the hunk above. Callers fall back to file-level staging, which is
335/// what keeps every pre-MG.18 behaviour intact.
336/// MG.22: the file a diff line belongs to — the one diff-path parser.
337///
338/// Three modes had a copy of this (magit-diff, magit-commit,
339/// magit-revision), each scanning upward for `diff --git a/<path>`,
340/// and magit-revision additionally checking the cursor line for a
341/// `git show --stat` summary row (`" src/main.rs | 12 +++++-----"`).
342///
343/// **The order of those two checks is load-bearing, and the copy that
344/// had both got it wrong.** magit-revision tried the stat row *first*,
345/// and `parse_stat_line` splits on `" | "` — so `<CR>` on any diff
346/// body line containing that sequence (` let x = a | b;`, a markdown
347/// table, a doc comment) resolved to the text left of the pipe and
348/// opened a buffer named after it.
349///
350/// Scanning for the `diff --git` header first removes the ambiguity
351/// structurally rather than by tightening the stat pattern: a diff
352/// body line **always** has a header above it, and a stat row never
353/// does, because `git show --stat -p` prints the summary before the
354/// first diff. So reaching the stat check at all means the cursor is
355/// above every diff, which is exactly where stat rows live.
356///
357/// Reads through an accessor rather than a materialised buffer, for
358/// the reason [`hunk_at_with`] does: a large `git show` is tens of
359/// thousands of lines and resolving one path must not copy them.
360pub fn path_at_cursor(read: impl Fn(usize) -> Option<String>, cursor: usize) -> Option<PathBuf> {
361    for l in (0..=cursor).rev() {
362        let text = read(l)?;
363        if let Some(rest) = text.strip_prefix("diff --git a/") {
364            // `a/<path> b/<path>` — take the first. They differ only
365            // for renames, which file-level resolution does not
366            // special-case.
367            return rest.split(" b/").next().map(PathBuf::from);
368        }
369    }
370    // Above every diff header: a `--stat` summary row, if this buffer
371    // has one.
372    parse_stat_line(&read(cursor)?)
373}
374
375/// `git show --stat`'s summary row: `" <path> | <N> <bar>"`.
376///
377/// Only ever reached from above the first `diff --git` header — see
378/// [`path_at_cursor`] for why that ordering is what makes splitting on
379/// `" | "` safe.
380fn parse_stat_line(line: &str) -> Option<PathBuf> {
381    let trimmed = line.trim_start();
382    let (path, _rest) = trimmed.split_once(" | ")?;
383    let path = path.trim();
384    (!path.is_empty()).then(|| PathBuf::from(path))
385}
386
387pub fn hunk_at_with(read: impl Fn(usize) -> Option<String>, cursor: usize) -> Option<HunkPatch> {
388    let header_line = enclosing_hunk_header(&read, cursor)?;
389    let header_text = read(header_line)?;
390    let counts = parse_hunk_counts(header_text.trim_end())?;
391
392    // Consume the body using the header's declared counts rather than
393    // stopping at "a line that doesn't look like diff content".
394    //
395    // This is load-bearing in magit-status: the file entry that follows
396    // an inline diff is `"  modified src/foo.rs"`, which begins with a
397    // space and is therefore indistinguishable from a context line by
398    // prefix alone. The counts delimit the hunk exactly, so the parser
399    // never runs past its end into the surrounding buffer.
400    let mut old_seen = 0usize;
401    let mut new_seen = 0usize;
402    let mut hunk = vec![header_text];
403    let mut idx = header_line + 1;
404    while old_seen < counts.old || new_seen < counts.new {
405        let Some(line) = read(idx) else { break };
406        // "\ No newline at end of file" annotates the line above and
407        // counts toward neither side, but must ride along: dropping it
408        // changes whether the applied result ends in a newline.
409        if line.starts_with('\\') {
410            hunk.push(line);
411            idx += 1;
412            continue;
413        }
414        match line.chars().next() {
415            Some('+') => new_seen += 1,
416            Some('-') => old_seen += 1,
417            // A context line counts toward both sides. An empty line is
418            // a context line whose single space git trimmed in transit;
419            // treat it as context rather than aborting the hunk.
420            Some(' ') | None => {
421                old_seen += 1;
422                new_seen += 1;
423            }
424            // Anything else means the diff was truncated (a buffer that
425            // ends mid-hunk). Stop rather than absorb foreign lines.
426            _ => break,
427        }
428        hunk.push(line);
429        idx += 1;
430    }
431
432    // A hunk whose declared counts were not satisfied is truncated;
433    // applying it would corrupt. Refuse instead.
434    if old_seen < counts.old || new_seen < counts.new {
435        return None;
436    }
437
438    // A `\ No newline at end of file` following the LAST body line is
439    // reached after the counts are satisfied, so the loop above never
440    // sees it. Dropping it would tell git to append a newline the file
441    // does not have — a one-byte corruption of every no-final-newline
442    // file staged this way.
443    while let Some(line) = read(idx) {
444        if !line.starts_with('\\') {
445            break;
446        }
447        hunk.push(line);
448        idx += 1;
449    }
450
451    // The cursor must be INSIDE the hunk it resolved, not merely below
452    // one. In magit-status the line after an expanded diff is the next
453    // file entry, and `s` there must stage that file — the pre-MG.18c
454    // behaviour — rather than restage the hunk above it. Without this
455    // the backward scan would happily claim every row down to the next
456    // `@@`, whatever it contained.
457    if cursor >= idx {
458        return None;
459    }
460
461    let header = file_header_above(&read, header_line)?;
462    Some(HunkPatch {
463        header,
464        hunk,
465        header_line,
466        end_line: idx,
467    })
468}
469
470/// MG.18d: the 0-based index of the hunk whose header sits at
471/// `header_row`, among the hunks of the file it belongs to.
472///
473/// The ordinal is what survives a rebuild: staging hunk *k* removes it,
474/// so ordinal *k* then names the hunk that took its place. Counted
475/// backwards from the hunk to its `diff --git` line, so it is the same
476/// walk [`hunk_at_with`] already does and cannot disagree with it about
477/// where the file starts.
478pub fn hunk_ordinal_at(read: impl Fn(usize) -> Option<String>, header_row: usize) -> usize {
479    let mut ordinal = 0usize;
480    for row in (0..header_row).rev() {
481        let Some(line) = read(row) else { break };
482        match classify_diff_line(&line) {
483            DiffLineClass::Hunk => ordinal += 1,
484            DiffLineClass::FileCommand => break,
485            _ => {}
486        }
487    }
488    ordinal
489}
490
491/// The `@@` line at or above `cursor`, without crossing into a
492/// different file's diff or out of the diff entirely.
493fn enclosing_hunk_header(read: &impl Fn(usize) -> Option<String>, cursor: usize) -> Option<usize> {
494    // Past the end of the buffer there is nothing to resolve.
495    read(cursor)?;
496    for idx in (0..=cursor).rev() {
497        let line = read(idx)?;
498        match classify_diff_line(&line) {
499            DiffLineClass::Hunk => return Some(idx),
500            // Reaching the file command or its path headers means the
501            // cursor sat above the first `@@` — inside the header, not
502            // inside a hunk.
503            DiffLineClass::FileCommand => return None,
504            _ => {}
505        }
506    }
507    None
508}
509
510/// The verbatim file-header block above `header_line`: from the
511/// `diff --git` line down to just before the first `@@`.
512///
513/// `None` when there is none above the cursor — a diff fragment with no
514/// file header cannot be applied, so refusing here is what stops a
515/// patch that would target the wrong file.
516fn file_header_above(
517    read: &impl Fn(usize) -> Option<String>,
518    header_line: usize,
519) -> Option<Vec<String>> {
520    let start = (0..header_line).rev().find(|&i| {
521        read(i)
522            .map(|l| classify_diff_line(&l) == DiffLineClass::FileCommand)
523            .unwrap_or(false)
524    })?;
525    let mut header = Vec::new();
526    for i in start..header_line {
527        let line = read(i)?;
528        if classify_diff_line(&line) == DiffLineClass::Hunk {
529            break;
530        }
531        header.push(line);
532    }
533    Some(header)
534}
535
536/// MG.22: the one diff-path parser, and the misfire it retires.
537#[cfg(test)]
538mod path_at_cursor_tests {
539    use super::*;
540
541    fn reader(lines: &'static [&'static str]) -> impl Fn(usize) -> Option<String> {
542        move |i: usize| lines.get(i).map(|s| s.to_string())
543    }
544
545    /// `git show --stat -p`: header, stat summary, then the diff.
546    const SHOW: &[&str] = &[
547        "commit a1b2c3d4",
548        "Author: Jane Doe <jane@example.com>",
549        "",
550        "    do the thing",
551        "",
552        " src/main.rs | 12 +++++-----",
553        " 1 file changed",
554        "",
555        "diff --git a/src/main.rs b/src/main.rs",
556        "index 111..222 100644",
557        "--- a/src/main.rs",
558        "+++ b/src/main.rs",
559        "@@ -1,3 +1,3 @@",
560        " fn main() {",
561        "-    let x = a | b;",
562        "+    let x = a & b;",
563        " }",
564    ];
565
566    /// The bug this ordering removes: `parse_stat_line` splits on
567    /// `\" | \"`, so a diff body line containing that sequence used to
568    /// resolve to the text left of the pipe. magit-revision checked the
569    /// stat row FIRST, so `<CR>` on this line opened a buffer named
570    /// `"    let x = a"`.
571    #[test]
572    fn a_diff_line_containing_a_pipe_resolves_to_its_file_not_to_itself() {
573        let got = path_at_cursor(reader(SHOW), 14);
574        assert_eq!(
575            got,
576            Some(PathBuf::from("src/main.rs")),
577            "a body line with ` | ` in it must resolve through the \
578             `diff --git` header above it"
579        );
580    }
581
582    /// And the stat row still works — it is reached precisely because
583    /// there is no diff header above it.
584    #[test]
585    fn a_stat_summary_row_resolves_to_the_file_it_names() {
586        assert_eq!(
587            path_at_cursor(reader(SHOW), 5),
588            Some(PathBuf::from("src/main.rs"))
589        );
590    }
591
592    /// The common case, in a buffer with no stat section at all.
593    #[test]
594    fn a_plain_diff_resolves_from_the_header_above_the_cursor() {
595        const DIFF: &[&str] = &[
596            "diff --git a/src/lib.rs b/src/lib.rs",
597            "--- a/src/lib.rs",
598            "+++ b/src/lib.rs",
599            "@@ -1 +1 @@",
600            "-old",
601            "+new",
602        ];
603        assert_eq!(
604            path_at_cursor(reader(DIFF), 5),
605            Some(PathBuf::from("src/lib.rs"))
606        );
607    }
608
609    /// The second file's lines must resolve to the second file — an
610    /// upward scan that stopped at the first header ever seen would
611    /// name the wrong one.
612    #[test]
613    fn a_multi_file_diff_resolves_to_the_nearest_header_above() {
614        const TWO: &[&str] = &[
615            "diff --git a/a.txt b/a.txt",
616            "@@ -1 +1 @@",
617            "-a",
618            "diff --git a/b.txt b/b.txt",
619            "@@ -1 +1 @@",
620            "-b",
621        ];
622        assert_eq!(path_at_cursor(reader(TWO), 2), Some(PathBuf::from("a.txt")));
623        assert_eq!(path_at_cursor(reader(TWO), 5), Some(PathBuf::from("b.txt")));
624    }
625
626    /// Nothing above and nothing stat-shaped on the line: no answer,
627    /// rather than a guess.
628    #[test]
629    fn prose_with_no_diff_above_it_resolves_to_nothing() {
630        const HEADER_ONLY: &[&str] = &["commit a1b2c3d4", "Author: Jane", "", "    subject"];
631        assert_eq!(path_at_cursor(reader(HEADER_ONLY), 3), None);
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    const TWO_HUNKS: &str = "\
640diff --git a/src/main.rs b/src/main.rs
641index 1234567..89abcde 100644
642--- a/src/main.rs
643+++ b/src/main.rs
644@@ -1,3 +1,3 @@
645 fn main() {
646-    println!(\"old\");
647+    println!(\"new\");
648 }
649@@ -20,2 +20,3 @@ fn other() {
650     let x = 1;
651+    let y = 2;
652     drop(x);
653";
654
655    fn lines(s: &str) -> Vec<&str> {
656        s.lines().collect()
657    }
658
659    #[test]
660    fn a_cursor_inside_the_first_hunk_finds_exactly_that_hunk() {
661        let l = lines(TWO_HUNKS);
662        // Line 6 is `-    println!("old");`
663        let h = hunk_at(&l, 6).expect("cursor is inside hunk 1");
664        assert_eq!(h.header_line, 4);
665        assert!(h.hunk[0].starts_with("@@ -1,3 +1,3 @@"));
666        assert!(
667            h.hunk.iter().any(|s| s.contains("println!(\"new\")")),
668            "hunk 1's body is present: {:?}",
669            h.hunk
670        );
671        assert!(
672            !h.hunk.iter().any(|s| s.contains("let y = 2")),
673            "hunk 2 must NOT bleed in: {:?}",
674            h.hunk
675        );
676    }
677
678    #[test]
679    fn a_cursor_in_the_second_hunk_finds_the_second() {
680        let l = lines(TWO_HUNKS);
681        let h = hunk_at(&l, 11).expect("cursor is inside hunk 2");
682        assert!(h.hunk[0].starts_with("@@ -20,2 +20,3 @@"));
683        assert!(h.hunk.iter().any(|s| s.contains("let y = 2")));
684        assert!(!h.hunk.iter().any(|s| s.contains("println!")));
685    }
686
687    #[test]
688    fn the_hunk_header_line_itself_resolves_to_its_own_hunk() {
689        let l = lines(TWO_HUNKS);
690        let h = hunk_at(&l, 4).expect("cursor on the @@ line");
691        assert_eq!(h.header_line, 4);
692    }
693
694    #[test]
695    fn a_cursor_in_the_file_header_is_not_in_a_hunk() {
696        let l = lines(TWO_HUNKS);
697        // `--- a/src/main.rs` — above the first @@.
698        assert!(
699            hunk_at(&l, 2).is_none(),
700            "header lines fall back to file-level staging"
701        );
702        assert!(hunk_at(&l, 0).is_none(), "the diff --git line likewise");
703    }
704
705    #[test]
706    fn the_counts_stop_the_body_before_a_following_status_entry() {
707        // THE magit-status hazard: the entry line after an inline diff
708        // starts with a space, exactly like a context line. Only the
709        // `@@` counts distinguish them.
710        let text = "\
711diff --git a/a.txt b/a.txt
712--- a/a.txt
713+++ b/a.txt
714@@ -1,2 +1,2 @@
715 keep
716-old
717+new
718  modified src/other.rs
719  modified src/third.rs
720";
721        let l = lines(text);
722        let h = hunk_at(&l, 5).expect("inside the hunk");
723        assert_eq!(
724            h.hunk,
725            vec!["@@ -1,2 +1,2 @@", " keep", "-old", "+new"],
726            "the body stops at the declared counts, not at the next entry"
727        );
728        assert!(
729            !h.to_patch().contains("modified src/other.rs"),
730            "a status entry must never reach the patch:\n{}",
731            h.to_patch()
732        );
733    }
734
735    /// MG.18c — the fallback that keeps file-level staging alive.
736    /// `s` on the entry line *below* an expanded diff must stage that
737    /// file, not restage the hunk above it. The backward scan finds
738    /// that hunk's `@@` either way; only the containment check
739    /// distinguishes "inside it" from "somewhere after it".
740    #[test]
741    fn a_cursor_below_a_hunk_resolves_to_no_hunk() {
742        let text = "\
743diff --git a/a.txt b/a.txt
744--- a/a.txt
745+++ b/a.txt
746@@ -1,2 +1,2 @@
747 keep
748-old
749+new
750  modified src/other.rs
751";
752        let l = lines(text);
753        assert!(
754            hunk_at(&l, 6).is_some(),
755            "the last body line is still inside the hunk"
756        );
757        assert!(
758            hunk_at(&l, 7).is_none(),
759            "the following status entry is not in the hunk — `s` there stages the file"
760        );
761    }
762
763    /// Trailing whitespace is content. `git apply` matches context
764    /// byte-for-byte, so trimming a context line's trailing spaces
765    /// produces a patch git refuses — every diff touching a
766    /// whitespace-dirty region would fail to stage.
767    #[test]
768    fn trailing_whitespace_survives_into_the_patch() {
769        // Written with escapes: a literal with trailing spaces is
770        // invisible in review and the first formatter to touch this
771        // file would silently delete the thing under test.
772        let text = concat!(
773            "diff --git a/a.txt b/a.txt\n",
774            "--- a/a.txt\n",
775            "+++ b/a.txt\n",
776            "@@ -1,2 +1,2 @@\n",
777            " keep   \n",
778            "-old\t\n",
779            "+new\n",
780        );
781        let l = lines(text);
782        let h = hunk_at(&l, 5).expect("inside the hunk");
783        assert_eq!(h.hunk[1], " keep   ", "context kept verbatim");
784        assert_eq!(h.hunk[2], "-old\t", "removed line kept verbatim");
785    }
786
787    /// The marker after the FINAL body line is reached only once the
788    /// counts are satisfied, so the body loop never sees it. Dropping
789    /// it tells git to append a newline the file does not have.
790    #[test]
791    fn a_trailing_no_newline_marker_after_the_last_body_line_rides_along() {
792        let text = "\
793diff --git a/a.txt b/a.txt
794--- a/a.txt
795+++ b/a.txt
796@@ -1 +1 @@
797-old
798+new
799\\ No newline at end of file
800";
801        let l = lines(text);
802        let h = hunk_at(&l, 4).expect("inside the hunk");
803        assert_eq!(
804            h.hunk.last().map(String::as_str),
805            Some("\\ No newline at end of file"),
806            "the trailing marker must reach the patch: {:?}",
807            h.hunk
808        );
809        assert_eq!(h.end_line, 7, "and be counted as part of the hunk");
810    }
811
812    /// MG.18d: the ordinal is what survives a rebuild, so it must be
813    /// counted within the file — not from the top of the buffer.
814    #[test]
815    fn a_hunks_ordinal_counts_within_its_own_file() {
816        let l = lines(TWO_HUNKS);
817        let read = |i: usize| l.get(i).map(|s| (*s).to_string());
818        assert_eq!(hunk_ordinal_at(read, 4), 0, "the first `@@`");
819        assert_eq!(hunk_ordinal_at(read, 9), 1, "the second");
820    }
821
822    #[test]
823    fn the_ordinal_restarts_at_each_files_header() {
824        let text = "\
825diff --git a/a.txt b/a.txt
826@@ -1,1 +1,1 @@
827-a
828diff --git a/b.txt b/b.txt
829@@ -1,1 +1,1 @@
830-b
831@@ -9,1 +9,1 @@
832-c
833";
834        let l = lines(text);
835        let read = |i: usize| l.get(i).map(|s| (*s).to_string());
836        assert_eq!(
837            hunk_ordinal_at(read, 4),
838            0,
839            "b.txt's first hunk is ordinal 0, not 1 — the count stops at its own `diff --git`"
840        );
841        assert_eq!(hunk_ordinal_at(read, 6), 1);
842    }
843
844    /// A deletion's `+++` is `/dev/null`, so the display path is `None`
845    /// — but the cursor restore still has to find the file again.
846    #[test]
847    fn file_path_falls_back_to_the_minus_side_for_a_deletion() {
848        let text = "\
849diff --git a/gone.txt b/gone.txt
850--- a/gone.txt
851+++ /dev/null
852@@ -1 +0,0 @@
853-was here
854";
855        let l = lines(text);
856        let h = hunk_at(&l, 4).expect("inside the hunk");
857        assert_eq!(h.display_path(), None, "prompts omit /dev/null");
858        assert_eq!(
859            h.file_path(),
860            Some("gone.txt"),
861            "but the restore must still name the file"
862        );
863    }
864
865    #[test]
866    fn display_location_names_the_file_line_not_the_buffer_row() {
867        let l = lines(TWO_HUNKS);
868        // Hunk 2 sits at buffer row 9 but describes file line 20.
869        let h = hunk_at(&l, 11).unwrap();
870        assert_eq!(h.display_location(), "src/main.rs:20");
871    }
872
873    #[test]
874    fn display_location_falls_back_when_the_header_is_unparseable() {
875        let text = "\
876diff --git a/gone.txt b/gone.txt
877--- a/gone.txt
878+++ /dev/null
879@@ -1 +0,0 @@
880-was here
881";
882        let l = lines(text);
883        let h = hunk_at(&l, 4).unwrap();
884        assert_eq!(h.display_location(), "hunk", "a deletion has no b/ path");
885    }
886
887    #[test]
888    fn a_hunk_header_without_counts_means_one_line() {
889        let text = "\
890diff --git a/a.txt b/a.txt
891--- a/a.txt
892+++ b/a.txt
893@@ -5 +5 @@
894-old
895+new
896";
897        let l = lines(text);
898        let h = hunk_at(&l, 4).expect("inside the hunk");
899        assert_eq!(h.hunk, vec!["@@ -5 +5 @@", "-old", "+new"]);
900    }
901
902    #[test]
903    fn a_no_newline_marker_rides_along_with_the_body() {
904        let text = "\
905diff --git a/a.txt b/a.txt
906--- a/a.txt
907+++ b/a.txt
908@@ -1 +1 @@
909-old
910\\ No newline at end of file
911+new
912";
913        let l = lines(text);
914        let h = hunk_at(&l, 4).expect("inside the hunk");
915        assert!(
916            h.hunk.iter().any(|s| s.starts_with('\\')),
917            "dropping the marker would change the applied result's trailing newline: {:?}",
918            h.hunk
919        );
920    }
921
922    #[test]
923    fn a_truncated_hunk_is_refused_rather_than_applied() {
924        // A buffer that ends mid-hunk (the diff was cut off). Applying
925        // the fragment would corrupt the file.
926        let text = "\
927diff --git a/a.txt b/a.txt
928--- a/a.txt
929+++ b/a.txt
930@@ -1,5 +1,5 @@
931 one
932-two
933";
934        let l = lines(text);
935        assert!(
936            hunk_at(&l, 5).is_none(),
937            "an unsatisfied count means truncated — refuse"
938        );
939    }
940
941    #[test]
942    fn a_hunk_with_no_file_header_above_it_is_refused() {
943        // Without a header there is no way to know which file this
944        // targets; a patch built from it could hit the wrong one.
945        let text = "\
946@@ -1,2 +1,2 @@
947 keep
948-old
949+new
950";
951        let l = lines(text);
952        assert!(hunk_at(&l, 2).is_none());
953    }
954
955    #[test]
956    fn the_patch_carries_the_header_verbatim_and_ends_in_a_newline() {
957        let l = lines(TWO_HUNKS);
958        let h = hunk_at(&l, 6).unwrap();
959        let patch = h.to_patch();
960        assert!(patch.starts_with("diff --git a/src/main.rs b/src/main.rs\n"));
961        assert!(
962            patch.contains("index 1234567..89abcde 100644"),
963            "index/mode metadata is preserved, not reconstructed:\n{patch}"
964        );
965        assert!(
966            patch.ends_with('\n'),
967            "git apply rejects an unterminated patch"
968        );
969    }
970
971    #[test]
972    fn display_path_reads_the_plus_header() {
973        let l = lines(TWO_HUNKS);
974        let h = hunk_at(&l, 6).unwrap();
975        assert_eq!(h.display_path(), Some("src/main.rs"));
976    }
977
978    #[test]
979    fn display_path_is_none_for_a_deletion() {
980        let text = "\
981diff --git a/gone.txt b/gone.txt
982--- a/gone.txt
983+++ /dev/null
984@@ -1 +0,0 @@
985-was here
986";
987        let l = lines(text);
988        let h = hunk_at(&l, 4).expect("inside the hunk");
989        assert_eq!(h.display_path(), None);
990        assert!(
991            h.to_patch().contains("+++ /dev/null"),
992            "the patch still carries the real header"
993        );
994    }
995
996    #[test]
997    fn every_body_line_of_a_multi_hunk_diff_resolves_to_its_own_hunk() {
998        // Sweep: no cursor position inside a hunk may resolve to the
999        // wrong one, and none may panic.
1000        let l = lines(TWO_HUNKS);
1001        let first: Vec<usize> = (5..=8).collect();
1002        let second: Vec<usize> = (10..=12).collect();
1003        for c in first {
1004            let h = hunk_at(&l, c).unwrap_or_else(|| panic!("line {c} is in hunk 1"));
1005            assert_eq!(h.header_line, 4, "line {c} belongs to hunk 1");
1006        }
1007        for c in second {
1008            let h = hunk_at(&l, c).unwrap_or_else(|| panic!("line {c} is in hunk 2"));
1009            assert_eq!(h.header_line, 9, "line {c} belongs to hunk 2");
1010        }
1011    }
1012}
1013
1014/// MG.18e: the region rewrite, as a table.
1015///
1016/// Rows 5–8 of `REGION` are the body: ` keep`, `-old-a`, `-old-b`,
1017/// `+new-a`, `+new-b` — enough to select adds only, removes only, an
1018/// interleaved slice, the first change, and the last.
1019#[cfg(test)]
1020mod region {
1021    use super::*;
1022
1023    const REGION: &str = "\
1024diff --git a/a.txt b/a.txt
1025index 111..222 100644
1026--- a/a.txt
1027+++ b/a.txt
1028@@ -1,3 +1,3 @@
1029 keep
1030-old-a
1031-old-b
1032+new-a
1033+new-b
1034";
1035    /// Body rows, by name, so the tests read as intent not arithmetic.
1036    const KEEP: usize = 5;
1037    const OLD_A: usize = 6;
1038    const OLD_B: usize = 7;
1039    const NEW_A: usize = 8;
1040    const NEW_B: usize = 9;
1041
1042    fn whole() -> HunkPatch {
1043        let lines: Vec<&str> = REGION.lines().collect();
1044        hunk_at(&lines, OLD_A).expect("the fixture parses")
1045    }
1046
1047    fn body(p: &HunkPatch) -> Vec<&str> {
1048        p.hunk.iter().map(String::as_str).collect()
1049    }
1050
1051    /// The boundary case that keeps the two paths honest: selecting
1052    /// every line must produce exactly what whole-hunk staging does.
1053    #[test]
1054    fn selecting_the_whole_body_reproduces_the_whole_hunk_patch() {
1055        let whole = whole();
1056        let restricted = whole
1057            .restrict_to_rows(KEEP..=NEW_B, ApplyDirection::Forward)
1058            .expect("changes are selected");
1059        assert_eq!(
1060            restricted.to_patch(),
1061            whole.to_patch(),
1062            "an all-selected region is not a special case, it IS the hunk"
1063        );
1064    }
1065
1066    /// Selecting nothing changeable is not an empty patch — it is a
1067    /// refusal, so the caller can say "nothing to stage there".
1068    #[test]
1069    fn a_selection_with_no_change_in_it_is_refused() {
1070        assert!(
1071            whole()
1072                .restrict_to_rows(KEEP..=KEEP, ApplyDirection::Forward)
1073                .is_none(),
1074            "a context-only selection would be a patch that does nothing"
1075        );
1076    }
1077
1078    /// Staging one added line: the other addition is DROPPED (it is not
1079    /// in the index yet, so it cannot appear at all), and both removals
1080    /// become context (they are still in the index).
1081    #[test]
1082    fn staging_one_addition_drops_the_other_and_contextualises_removals() {
1083        let p = whole()
1084            .restrict_to_rows(NEW_A..=NEW_A, ApplyDirection::Forward)
1085            .expect("one addition selected");
1086        assert_eq!(
1087            body(&p),
1088            vec!["@@ -1,3 +1,4 @@", " keep", " old-a", " old-b", "+new-a"],
1089            "old side unchanged (3 lines), new side gains exactly the one addition"
1090        );
1091    }
1092
1093    /// Staging one removal: it stays `-`, the other removal becomes
1094    /// context, and BOTH additions vanish.
1095    #[test]
1096    fn staging_one_removal_keeps_it_and_drops_every_addition() {
1097        let p = whole()
1098            .restrict_to_rows(OLD_B..=OLD_B, ApplyDirection::Forward)
1099            .expect("one removal selected");
1100        assert_eq!(
1101            body(&p),
1102            vec!["@@ -1,3 +1,2 @@", " keep", " old-a", "-old-b"],
1103        );
1104    }
1105
1106    /// The mirror image. Unstaging reverses the roles: an unselected
1107    /// addition is in the index and survives as context; an unselected
1108    /// removal is not, and goes.
1109    #[test]
1110    fn unstaging_mirrors_the_rules_exactly() {
1111        let p = whole()
1112            .restrict_to_rows(OLD_A..=OLD_A, ApplyDirection::Reverse)
1113            .expect("one removal selected");
1114        assert_eq!(
1115            body(&p),
1116            vec!["@@ -1,4 +1,3 @@", " keep", "-old-a", " new-a", " new-b"],
1117            "new side unchanged at 3 — it is what a reverse apply matches. The old \
1118             side is 4 because un-removing `old-a` puts it back ALONGSIDE the \
1119             additions that stay staged."
1120        );
1121    }
1122
1123    /// An interleaved selection exercises both rules in one body.
1124    #[test]
1125    fn an_interleaved_selection_applies_both_rules() {
1126        let p = whole()
1127            .restrict_to_rows(OLD_B..=NEW_A, ApplyDirection::Forward)
1128            .expect("one removal and one addition selected");
1129        assert_eq!(
1130            body(&p),
1131            vec!["@@ -1,3 +1,3 @@", " keep", " old-a", "-old-b", "+new-a"],
1132        );
1133    }
1134
1135    /// A selection reaching past the hunk in either direction clamps to
1136    /// the body — the cursor's hunk is the unit, and rows outside it
1137    /// belong to other entries.
1138    #[test]
1139    fn a_selection_wider_than_the_hunk_clamps_to_its_body() {
1140        let p = whole()
1141            .restrict_to_rows(0..=999, ApplyDirection::Forward)
1142            .expect("everything selected");
1143        assert_eq!(p.to_patch(), whole().to_patch());
1144    }
1145
1146    /// The header's function-context suffix is a display hint git
1147    /// emitted; recomputing it would mean guessing at the language's
1148    /// idea of an enclosing definition, so it rides through.
1149    #[test]
1150    fn the_headers_function_context_suffix_survives_the_rewrite() {
1151        let text = "\
1152diff --git a/a.rs b/a.rs
1153--- a/a.rs
1154+++ b/a.rs
1155@@ -10,1 +10,1 @@ fn main() {
1156-old
1157+new
1158";
1159        let lines: Vec<&str> = text.lines().collect();
1160        let p = hunk_at(&lines, 4)
1161            .expect("parses")
1162            .restrict_to_rows(4..=4, ApplyDirection::Forward)
1163            .expect("the removal is selected");
1164        assert_eq!(
1165            p.hunk[0], "@@ -10,1 +10,0 @@ fn main() {",
1166            "starts and suffix kept, counts recomputed"
1167        );
1168    }
1169
1170    /// A `\ No newline` marker annotates the line above it. If that line
1171    /// is dropped the marker must go too, or it re-attaches to whatever
1172    /// came before and silently changes THAT line's trailing newline.
1173    #[test]
1174    fn a_marker_whose_line_was_dropped_is_dropped_with_it() {
1175        let text = "\
1176diff --git a/a.txt b/a.txt
1177--- a/a.txt
1178+++ b/a.txt
1179@@ -1,2 +1,2 @@
1180 keep
1181-old
1182+new
1183\\ No newline at end of file
1184";
1185        let lines: Vec<&str> = text.lines().collect();
1186        let whole = hunk_at(&lines, 5).expect("parses");
1187        // Select the removal only: the addition (row 6) is dropped, and
1188        // its marker (row 7) must not survive it.
1189        let p = whole
1190            .restrict_to_rows(5..=5, ApplyDirection::Forward)
1191            .expect("the removal is selected");
1192        assert!(
1193            !p.hunk.iter().any(|l| l.starts_with('\\')),
1194            "the marker belonged to the dropped line: {:?}",
1195            p.hunk
1196        );
1197        // Selecting the addition keeps both.
1198        let p = whole
1199            .restrict_to_rows(6..=6, ApplyDirection::Forward)
1200            .expect("the addition is selected");
1201        assert!(
1202            p.hunk.last().is_some_and(|l| l.starts_with('\\')),
1203            "kept with the line it annotates: {:?}",
1204            p.hunk
1205        );
1206    }
1207}
1208
1209/// MG.18b round-trip: the parser's output must be a patch **git
1210/// accepts**. Unit tests above prove the parse is self-consistent;
1211/// only git can prove it is correct.
1212#[cfg(test)]
1213mod git_round_trip {
1214    use super::*;
1215    use std::process::Command;
1216
1217    fn git(dir: &std::path::Path, args: &[&str]) -> String {
1218        let out = Command::new("git")
1219            .args(args)
1220            .current_dir(dir)
1221            .output()
1222            .expect("git");
1223        String::from_utf8_lossy(&out.stdout).into_owned()
1224    }
1225
1226    fn git_ok(dir: &std::path::Path, args: &[&str]) {
1227        let st = Command::new("git")
1228            .args(args)
1229            .current_dir(dir)
1230            .status()
1231            .expect("git");
1232        assert!(st.success(), "git {args:?} failed");
1233    }
1234
1235    /// A repo whose working tree differs from HEAD in two places far
1236    /// enough apart that git reports two hunks.
1237    fn two_hunk_repo() -> tempfile::TempDir {
1238        let dir = tempfile::tempdir().expect("tempdir");
1239        let p = dir.path();
1240        git_ok(p, &["init"]);
1241        git_ok(p, &["config", "user.email", "t@lattice.dev"]);
1242        git_ok(p, &["config", "user.name", "lattice-test"]);
1243        let base: String = (1..=20).map(|i| format!("line {i}\n")).collect();
1244        std::fs::write(p.join("a.txt"), &base).unwrap();
1245        git_ok(p, &["add", "a.txt"]);
1246        git_ok(p, &["commit", "-m", "base"]);
1247        let modified: String = (1..=20)
1248            .map(|i| match i {
1249                2 => "line 2 CHANGED\n".to_string(),
1250                19 => "line 19 CHANGED\n".to_string(),
1251                _ => format!("line {i}\n"),
1252            })
1253            .collect();
1254        std::fs::write(p.join("a.txt"), &modified).unwrap();
1255        dir
1256    }
1257
1258    /// Byte index of the nth line starting with `@@ `.
1259    fn nth_hunk_line(text: &str, n: usize) -> usize {
1260        text.lines()
1261            .enumerate()
1262            .filter(|(_, l)| l.starts_with("@@ "))
1263            .map(|(i, _)| i)
1264            .nth(n)
1265            .expect("hunk header")
1266    }
1267
1268    #[test]
1269    fn a_parsed_hunk_applies_to_the_index_without_taking_its_neighbour() {
1270        let dir = two_hunk_repo();
1271        let p = dir.path();
1272        let diff = git(p, &["diff", "--", "a.txt"]);
1273        let lines: Vec<&str> = diff.lines().collect();
1274
1275        // Cursor one line into the FIRST hunk's body.
1276        let cursor = nth_hunk_line(&diff, 0) + 1;
1277        let h = hunk_at(&lines, cursor).expect("cursor is inside hunk 1");
1278
1279        let repo = lattice_vcs::Repository::discover(p).expect("discover");
1280        lattice_vcs::Index::apply_patch(&repo, &h.to_patch(), true, false)
1281            .expect("the synthesized patch must be one git accepts");
1282
1283        let staged = git(p, &["diff", "--cached", "--", "a.txt"]);
1284        assert!(
1285            staged.contains("line 2 CHANGED"),
1286            "the selected hunk reached the index:\n{staged}"
1287        );
1288        assert!(
1289            !staged.contains("line 19 CHANGED"),
1290            "the neighbouring hunk must stay unstaged:\n{staged}"
1291        );
1292    }
1293
1294    #[test]
1295    fn the_second_hunk_applies_just_as_cleanly() {
1296        // Guards an off-by-one that would only bite the non-first hunk:
1297        // a header block collected from the wrong `diff --git`, or a
1298        // body that started one line late.
1299        let dir = two_hunk_repo();
1300        let p = dir.path();
1301        let diff = git(p, &["diff", "--", "a.txt"]);
1302        let lines: Vec<&str> = diff.lines().collect();
1303
1304        let cursor = nth_hunk_line(&diff, 1) + 1;
1305        let h = hunk_at(&lines, cursor).expect("cursor is inside hunk 2");
1306
1307        let repo = lattice_vcs::Repository::discover(p).expect("discover");
1308        lattice_vcs::Index::apply_patch(&repo, &h.to_patch(), true, false)
1309            .expect("hunk 2's patch applies");
1310
1311        let staged = git(p, &["diff", "--cached", "--", "a.txt"]);
1312        assert!(staged.contains("line 19 CHANGED"), "{staged}");
1313        assert!(!staged.contains("line 2 CHANGED"), "{staged}");
1314    }
1315
1316    #[test]
1317    fn a_parsed_hunk_reverses_out_of_the_index() {
1318        // `u` unstages by applying the staged hunk in reverse.
1319        let dir = two_hunk_repo();
1320        let p = dir.path();
1321        git_ok(p, &["add", "a.txt"]);
1322        let staged_diff = git(p, &["diff", "--cached", "--", "a.txt"]);
1323        let lines: Vec<&str> = staged_diff.lines().collect();
1324
1325        let cursor = nth_hunk_line(&staged_diff, 0) + 1;
1326        let h = hunk_at(&lines, cursor).expect("cursor inside staged hunk 1");
1327
1328        let repo = lattice_vcs::Repository::discover(p).expect("discover");
1329        lattice_vcs::Index::apply_patch(&repo, &h.to_patch(), true, true)
1330            .expect("reverse-apply unstages");
1331
1332        let still = git(p, &["diff", "--cached", "--", "a.txt"]);
1333        assert!(!still.contains("line 2 CHANGED"), "{still}");
1334        assert!(still.contains("line 19 CHANGED"), "{still}");
1335    }
1336
1337    /// MG.23g: `a` puts one hunk of a commit into the **working
1338    /// tree** and leaves the index alone, and `-` takes it back out.
1339    ///
1340    /// Against a real repository, because what is being asserted is
1341    /// git's behaviour under `(cached = false)`: a `cached` slip would
1342    /// stage a commit's hunk invisibly, which no argv-shaped test
1343    /// would notice.
1344    #[test]
1345    fn a_committed_hunk_applies_to_the_worktree_and_reverses_back_out() {
1346        let dir = tempfile::tempdir().expect("tempdir");
1347        let p = dir.path();
1348        git_ok(p, &["init"]);
1349        git_ok(p, &["config", "user.email", "t@lattice.dev"]);
1350        git_ok(p, &["config", "user.name", "lattice-test"]);
1351        // The byte-exact assertion below must not depend on the machine's
1352        // git config: with `autocrlf=true` (the Windows runner default)
1353        // `reset --hard` writes the file back out with CRLF.
1354        git_ok(p, &["config", "core.autocrlf", "false"]);
1355        let base: String = (1..=20).map(|i| format!("line {i}\n")).collect();
1356        std::fs::write(p.join("a.txt"), &base).unwrap();
1357        git_ok(p, &["add", "a.txt"]);
1358        git_ok(p, &["commit", "-m", "base"]);
1359
1360        // A commit that changes two distant lines — two hunks, so
1361        // applying one must not drag the other along.
1362        let edited: String = (1..=20)
1363            .map(|i| match i {
1364                2 => "line 2 FROM COMMIT\n".to_string(),
1365                19 => "line 19 FROM COMMIT\n".to_string(),
1366                _ => format!("line {i}\n"),
1367            })
1368            .collect();
1369        std::fs::write(p.join("a.txt"), &edited).unwrap();
1370        git_ok(p, &["add", "a.txt"]);
1371        git_ok(p, &["commit", "-m", "the change"]);
1372        // ...then rewind the working tree and the index to before it,
1373        // which is the situation `a` exists for: the change is in
1374        // history but not here.
1375        git_ok(p, &["reset", "--hard", "HEAD~1"]);
1376
1377        let show = git(p, &["show", "HEAD@{1}", "--", "a.txt"]);
1378        let lines: Vec<&str> = show.lines().collect();
1379        let cursor = nth_hunk_line(&show, 0) + 1;
1380        let h = hunk_at(&lines, cursor).expect("cursor inside the commit's first hunk");
1381
1382        let repo = lattice_vcs::Repository::discover(p).expect("discover");
1383        lattice_vcs::Index::apply_patch(&repo, &h.to_patch(), false, false)
1384            .expect("`a` applies a committed hunk to the working tree");
1385
1386        let on_disk = std::fs::read_to_string(p.join("a.txt")).unwrap();
1387        assert!(
1388            on_disk.contains("line 2 FROM COMMIT"),
1389            "the hunk must land in the file:\n{on_disk}"
1390        );
1391        assert!(
1392            !on_disk.contains("line 19 FROM COMMIT"),
1393            "and only that hunk — the commit's other change stays out:\n{on_disk}"
1394        );
1395        assert_eq!(
1396            git(p, &["diff", "--cached", "--", "a.txt"]).trim(),
1397            "",
1398            "`a` writes the working tree, never the index — a staged \
1399             hunk here would be a `cached` slip nobody would see"
1400        );
1401
1402        // `-` is the exact inverse, which is what makes neither of
1403        // them need a confirm.
1404        lattice_vcs::Index::apply_patch(&repo, &h.to_patch(), false, true)
1405            .expect("`-` reverses it back out");
1406        assert_eq!(
1407            std::fs::read_to_string(p.join("a.txt")).unwrap(),
1408            base,
1409            "reversing must restore the file exactly"
1410        );
1411    }
1412
1413    /// MG.18e: a repo whose edit produces ONE hunk containing two
1414    /// removals and two additions — the shape region staging exists
1415    /// for.
1416    fn one_hunk_two_changes_repo() -> tempfile::TempDir {
1417        let dir = tempfile::tempdir().expect("tempdir");
1418        let p = dir.path();
1419        git_ok(p, &["init"]);
1420        git_ok(p, &["config", "user.email", "t@lattice.dev"]);
1421        git_ok(p, &["config", "user.name", "lattice-test"]);
1422        let base: String = (1..=10).map(|i| format!("line {i}\n")).collect();
1423        std::fs::write(p.join("a.txt"), &base).unwrap();
1424        git_ok(p, &["add", "a.txt"]);
1425        git_ok(p, &["commit", "-m", "base"]);
1426        let edited: String = (1..=10)
1427            .map(|i| match i {
1428                4 => "line 4 EDITED\n".to_string(),
1429                5 => "line 5 EDITED\n".to_string(),
1430                _ => format!("line {i}\n"),
1431            })
1432            .collect();
1433        std::fs::write(p.join("a.txt"), &edited).unwrap();
1434        dir
1435    }
1436
1437    /// The row of the body line whose text contains `needle`.
1438    fn row_containing(text: &str, needle: &str) -> usize {
1439        text.lines()
1440            .position(|l| l.contains(needle))
1441            .unwrap_or_else(|| panic!("no line containing {needle:?} in:\n{text}"))
1442    }
1443
1444    /// The counts are the part `git apply` validates — a rewritten body
1445    /// with a stale header is rejected as "corrupt patch". Only git can
1446    /// prove the arithmetic.
1447    #[test]
1448    fn a_region_patch_stages_only_the_selected_line() {
1449        let dir = one_hunk_two_changes_repo();
1450        let p = dir.path();
1451        let diff = git(p, &["diff", "--", "a.txt"]);
1452        let lines: Vec<&str> = diff.lines().collect();
1453
1454        let removal = row_containing(&diff, "-line 4");
1455        let whole = hunk_at(&lines, removal).expect("cursor inside the hunk");
1456        let region = whole
1457            .restrict_to_rows(removal..=removal, ApplyDirection::Forward)
1458            .expect("one removal selected");
1459
1460        let repo = lattice_vcs::Repository::discover(p).expect("discover");
1461        lattice_vcs::Index::apply_patch(&repo, &region.to_patch(), true, false)
1462            .expect("git must accept the rewritten hunk");
1463
1464        let staged = git(p, &["diff", "--cached", "--", "a.txt"]);
1465        assert!(
1466            staged.contains("-line 4") && !staged.contains("line 4 EDITED"),
1467            "only the removal of line 4 reached the index:\n{staged}"
1468        );
1469        // ` line 5` appears as CONTEXT in the index diff, which is the
1470        // point — it is unchanged there. What must be absent is either
1471        // changed form of it.
1472        assert!(
1473            !staged.contains("-line 5") && !staged.contains("line 5 EDITED"),
1474            "line 5's change stayed out of the index entirely:\n{staged}"
1475        );
1476        assert!(
1477            std::fs::read_to_string(p.join("a.txt"))
1478                .unwrap()
1479                .contains("line 4 EDITED"),
1480            "the worktree is untouched — staging is an index operation"
1481        );
1482    }
1483
1484    /// The reverse direction, against real git: stage everything, then
1485    /// unstage one line of it. Proves the mirrored rules produce a
1486    /// patch git accepts REVERSED, which is a different validation path
1487    /// (it matches the new side, not the old).
1488    #[test]
1489    fn a_region_patch_unstages_only_the_selected_line() {
1490        let dir = one_hunk_two_changes_repo();
1491        let p = dir.path();
1492        git_ok(p, &["add", "a.txt"]);
1493        let staged_diff = git(p, &["diff", "--cached", "--", "a.txt"]);
1494        let lines: Vec<&str> = staged_diff.lines().collect();
1495
1496        let addition = row_containing(&staged_diff, "+line 4 EDITED");
1497        let whole = hunk_at(&lines, addition).expect("cursor inside the staged hunk");
1498        let region = whole
1499            .restrict_to_rows(addition..=addition, ApplyDirection::Reverse)
1500            .expect("one addition selected");
1501
1502        let repo = lattice_vcs::Repository::discover(p).expect("discover");
1503        lattice_vcs::Index::apply_patch(&repo, &region.to_patch(), true, true)
1504            .expect("git must accept the rewritten hunk reversed");
1505
1506        let still = git(p, &["diff", "--cached", "--", "a.txt"]);
1507        assert!(
1508            !still.contains("line 4 EDITED"),
1509            "line 4's change left the index:\n{still}"
1510        );
1511        assert!(
1512            still.contains("line 5 EDITED"),
1513            "line 5's change is still staged:\n{still}"
1514        );
1515    }
1516
1517    #[test]
1518    fn every_cursor_position_in_a_hunk_yields_the_same_patch() {
1519        // The user's cursor can be anywhere in the hunk when they press
1520        // `s`. All of those must produce byte-identical patches, or
1521        // staging would depend on where you happened to be standing.
1522        let dir = two_hunk_repo();
1523        let p = dir.path();
1524        let diff = git(p, &["diff", "--", "a.txt"]);
1525        let lines: Vec<&str> = diff.lines().collect();
1526
1527        let start = nth_hunk_line(&diff, 0);
1528        let end = nth_hunk_line(&diff, 1);
1529        let expected = hunk_at(&lines, start).expect("at the header").to_patch();
1530        for cursor in start..end {
1531            let got = hunk_at(&lines, cursor)
1532                .unwrap_or_else(|| panic!("line {cursor} is inside hunk 1"))
1533                .to_patch();
1534            assert_eq!(
1535                got, expected,
1536                "cursor at line {cursor} produced a different patch"
1537            );
1538        }
1539    }
1540}
1541
1542/// A position in the SOURCE FILE, resolved from a cursor sitting on a
1543/// diff row. 0-based row plus a 0-based BYTE offset within it, matching
1544/// `lattice_protocol::position::Position`'s own `{ line, byte }` shape.
1545///
1546/// A plain value type rather than `Position` itself so this module keeps
1547/// the "pure functions over diff text, no buffer / store / `Editor`"
1548/// contract its header claims.
1549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1550pub struct SourcePos {
1551    pub line: u32,
1552    pub byte: u32,
1553}
1554
1555/// MG.50: the SOURCE position the cursor is looking at, inside a diff.
1556///
1557/// `<CR>` in emacs magit opens the file *at the code under the cursor*,
1558/// not at the top. The diff already carries the answer: a hunk's `@@`
1559/// header names where its body starts in the new file, so the target is
1560/// that start plus however many new-side rows precede the cursor within
1561/// the hunk.
1562///
1563/// **Which rows count.** Only those that exist on the NEW side — context
1564/// (` `) and additions (`+`). A deletion (`-`) is not in the file being
1565/// opened, so it advances nothing; a cursor sitting on one resolves to
1566/// the position where the deleted text *was*, which is where a reader
1567/// looking at it wants to land. `\ No newline at end of file` belongs to
1568/// neither side.
1569///
1570/// **The byte offset.** Every hunk body row carries the one-character
1571/// diff marker (` ` / `+` / `-`) at byte 0, so source byte = cursor byte
1572/// − 1. Subtracting a BYTE rather than a column is what makes this
1573/// correct on rows containing multibyte text: the marker is always
1574/// exactly one byte, whatever follows it. `saturating_sub` handles a
1575/// cursor parked ON the marker, which resolves to the start of the
1576/// source line rather than wrapping. On the `@@` row there is no code
1577/// under the cursor to align to, so the offset is 0.
1578///
1579/// Line and offset are answered **together, by one walk**, rather than
1580/// by a `source_line_at` plus a separate offset helper. The bug this
1581/// replaced was precisely a caller that had the line and defaulted the
1582/// offset to 0; a shape where you cannot obtain one without the other
1583/// cannot regress that way again.
1584///
1585/// `None` when the cursor is not inside a hunk (a file entry, a section
1586/// header, a `diff --git` line) — the caller then opens at the top,
1587/// which is what emacs does for a file entry too.
1588pub fn source_position_at(
1589    read: impl Fn(usize) -> Option<String>,
1590    cursor: usize,
1591    cursor_byte: u32,
1592) -> Option<SourcePos> {
1593    // The marker occupies byte 0 of every body row; strip it. A cursor
1594    // on the marker itself lands at the start of the source line.
1595    let byte = cursor_byte.saturating_sub(1);
1596    let at = |line: u32| Some(SourcePos { line, byte });
1597
1598    let header_line = enclosing_hunk_header(&read, cursor)?;
1599    let header_text = read(header_line)?;
1600    let header = header_text.trim_end();
1601    let start = parse_hunk_starts(header)?.new;
1602    let counts = parse_hunk_counts(header)?;
1603
1604    // On the `@@` row itself: the hunk's first new-side line. The
1605    // header's own columns describe the hunk, not the code, so there is
1606    // nothing to align to — open at the start of the line.
1607    if cursor == header_line {
1608        return u32::try_from(start.saturating_sub(1))
1609            .ok()
1610            .map(|line| SourcePos { line, byte: 0 });
1611    }
1612
1613    // Walk the body under the header's DECLARED counts rather than
1614    // until something stops looking like diff content — the same reason
1615    // `hunk_at_with` does, and the same trap the fold source hit: in
1616    // magit-status the row after a hunk is `"  modified src/foo.rs"`,
1617    // which begins with a space and is indistinguishable from a context
1618    // line by prefix alone. The counts end the hunk exactly.
1619    let (mut old_left, mut new_left) = (counts.old, counts.new);
1620    let mut advanced = 0usize;
1621    let mut row = header_line + 1;
1622    while old_left > 0 || new_left > 0 {
1623        // Tested INSIDE the loop, so it can only match while the hunk
1624        // still has body left. Testing it in the loop condition would
1625        // also match the row one PAST the last body line — which in
1626        // magit-status is the next file's entry row, and would hand back
1627        // a line number inside a file the cursor is not in.
1628        if row == cursor {
1629            return u32::try_from(start.saturating_sub(1) + advanced)
1630                .ok()
1631                .and_then(at);
1632        }
1633        let text = read(row)?;
1634        match text.chars().next() {
1635            // Context: present on both sides.
1636            None | Some(' ') => {
1637                old_left = old_left.checked_sub(1)?;
1638                new_left = new_left.checked_sub(1)?;
1639                advanced += 1;
1640            }
1641            // An addition exists only in the file being opened.
1642            Some('+') => {
1643                new_left = new_left.checked_sub(1)?;
1644                advanced += 1;
1645            }
1646            // A deletion is not in that file, so it advances nothing —
1647            // a cursor on one resolves to the position it occupied.
1648            Some('-') => old_left = old_left.checked_sub(1)?,
1649            // `\ No newline at end of file` belongs to neither side.
1650            Some('\\') => {}
1651            _ => return None,
1652        }
1653        row += 1;
1654    }
1655    // Counts exhausted without reaching the cursor: it sits past this
1656    // hunk's last body line, so there is no line in this file to name.
1657    None
1658}
1659
1660#[cfg(test)]
1661mod source_position_tests {
1662    use super::{SourcePos, source_position_at};
1663
1664    /// The cases below are about the LINE. Wrapping keeps each one
1665    /// asserting its own subject; the byte offset has its own tests at
1666    /// the end of the module.
1667    fn line_at(read: impl Fn(usize) -> Option<String>, cursor: usize) -> Option<u32> {
1668        source_position_at(read, cursor, 0).map(|p| p.line)
1669    }
1670
1671    const DIFF: &[&str] = &[
1672        "diff --git a/src/main.rs b/src/main.rs", // 0
1673        "index 111..222 100644",                  // 1
1674        "--- a/src/main.rs",                      // 2
1675        "+++ b/src/main.rs",                      // 3
1676        "@@ -10,3 +20,3 @@ fn main() {",          // 4  -> new starts at 20
1677        " context one",                           // 5  -> line 20
1678        "-deleted",                               // 6  -> not in the new file
1679        "+added",                                 // 7  -> line 21
1680        " context two",                           // 8  -> line 22
1681    ];
1682
1683    fn read(i: usize) -> Option<String> {
1684        DIFF.get(i).map(|s| s.to_string())
1685    }
1686
1687    /// The first body row is the header's own new-side start.
1688    #[test]
1689    fn the_first_body_row_is_the_hunks_start() {
1690        // `@@ +20` is 1-based; row 5 is buffer line 19.
1691        assert_eq!(line_at(read, 5), Some(19));
1692    }
1693
1694    /// A deletion advances nothing — it is not in the file being opened.
1695    #[test]
1696    fn a_deletion_does_not_advance_the_source_line() {
1697        // Row 6 is the `-` itself. The deleted text is not in the file
1698        // being opened, so it resolves to the position it occupied —
1699        // just after `context one`, which is new line 21 (0-based 20).
1700        // That is where a reader looking at the deletion wants to land.
1701        assert_eq!(line_at(read, 6), Some(20));
1702        // Row 7 (`+added`) follows one context row and one deletion, so
1703        // only the context advanced: line 21 (0-based 20).
1704        assert_eq!(line_at(read, 7), Some(20));
1705    }
1706
1707    /// Context after an addition keeps counting.
1708    #[test]
1709    fn context_after_an_addition_keeps_counting() {
1710        assert_eq!(line_at(read, 8), Some(21));
1711    }
1712
1713    /// On the `@@` row, the hunk's start.
1714    #[test]
1715    fn the_header_row_resolves_to_the_hunk_start() {
1716        assert_eq!(line_at(read, 4), Some(19));
1717    }
1718
1719    /// Outside a hunk there is no line to name — the caller opens at the
1720    /// top, which is what emacs does for a file entry.
1721    #[test]
1722    fn a_row_outside_any_hunk_has_no_source_line() {
1723        assert_eq!(line_at(read, 0), None);
1724        assert_eq!(line_at(read, 3), None);
1725    }
1726
1727    /// The magit-status shape: a hunk with an ENTRY ROW under it.
1728    ///
1729    /// `"  modified src/other.rs"` starts with a space, so by prefix
1730    /// alone it is a context line. Walking until something stops looking
1731    /// like diff content would count it and hand back a line number
1732    /// inside a file the cursor is not in — the same trap that let a
1733    /// fold swallow the rest of the status buffer. The declared counts
1734    /// end the hunk exactly.
1735    #[test]
1736    fn an_entry_row_below_the_hunk_is_not_counted_as_context() {
1737        const STATUS: &[&str] = &[
1738            "diff --git a/a.rs b/a.rs", // 0
1739            "--- a/a.rs",               // 1
1740            "+++ b/a.rs",               // 2
1741            "@@ -1,1 +5,2 @@",          // 3
1742            " ctx",                     // 4  -> line 5
1743            "+added",                   // 5  -> line 6
1744            "  modified src/other.rs",  // 6  <- an entry row, not context
1745            "  modified src/third.rs",  // 7
1746        ];
1747        let r = |i: usize| STATUS.get(i).map(|s| s.to_string());
1748        assert_eq!(line_at(r, 4), Some(4), "` ctx` is new line 5");
1749        assert_eq!(line_at(r, 5), Some(5), "`+added` is new line 6");
1750        // Past the hunk's declared end: not inside it, so no line.
1751        assert_eq!(
1752            line_at(r, 6),
1753            None,
1754            "an entry row below the hunk must not resolve to a line \
1755             inside the hunk's file",
1756        );
1757        assert_eq!(line_at(r, 7), None);
1758    }
1759
1760    /// A header with no comma (`@@ -1 +7 @@`) is a one-line range and
1761    /// still parses.
1762    #[test]
1763    fn a_single_line_range_parses() {
1764        const ONE: &[&str] = &["@@ -1 +7 @@", " ctx"];
1765        let r = |i: usize| ONE.get(i).map(|s| s.to_string());
1766        assert_eq!(line_at(r, 1), Some(6));
1767    }
1768
1769    // ── the byte offset ───────────────────────────────────────────
1770    //
1771    // `<CR>` used to land at the start of the line no matter where the
1772    // cursor sat, because the caller had the line and passed 0 for the
1773    // offset. These pin the other axis.
1774
1775    /// The marker occupies byte 0, so the cursor's offset shifts left
1776    /// by exactly one to land on the same character in the file.
1777    #[test]
1778    fn the_diff_marker_is_stripped_from_the_offset() {
1779        // Row 5 is `" context one"`. Byte 4 is the `n` of `context`
1780        // (` c o n` → 0 1 2 3, so byte 3 is `n`… count on the source:
1781        // stripping the marker, `context one` starts at byte 0, so a
1782        // cursor at buffer byte 4 is source byte 3).
1783        let pos = source_position_at(read, 5, 4).expect("inside the hunk");
1784        assert_eq!(pos, SourcePos { line: 19, byte: 3 });
1785    }
1786
1787    /// A cursor parked ON the marker has no character in the source to
1788    /// point at, so it resolves to the start of the line rather than
1789    /// wrapping to `u32::MAX`.
1790    #[test]
1791    fn a_cursor_on_the_marker_lands_at_line_start() {
1792        let pos = source_position_at(read, 5, 0).expect("inside the hunk");
1793        assert_eq!(pos, SourcePos { line: 19, byte: 0 });
1794    }
1795
1796    /// Additions and deletions carry a marker exactly like context, so
1797    /// the same shift applies — including on a `-` row, whose line
1798    /// resolves to the position the deleted text occupied.
1799    #[test]
1800    fn additions_and_deletions_shift_the_same_way() {
1801        // Row 7 is `"+added"`; byte 3 is the `d` in `added`'s source
1802        // form (`added` at byte 0 → `a d d` → byte 2 is the second `d`).
1803        assert_eq!(
1804            source_position_at(read, 7, 3),
1805            Some(SourcePos { line: 20, byte: 2 })
1806        );
1807        // Row 6 is `"-deleted"`.
1808        assert_eq!(
1809            source_position_at(read, 6, 3),
1810            Some(SourcePos { line: 20, byte: 2 })
1811        );
1812    }
1813
1814    /// The `@@` row's own columns describe the hunk, not code, so there
1815    /// is nothing to align to — offset 0 regardless of where the cursor
1816    /// sits along it.
1817    #[test]
1818    fn the_header_row_ignores_the_cursor_offset() {
1819        assert_eq!(
1820            source_position_at(read, 4, 17),
1821            Some(SourcePos { line: 19, byte: 0 })
1822        );
1823    }
1824
1825    /// Subtracting a BYTE rather than a column is what keeps this
1826    /// correct on multibyte content: the marker is one byte whatever
1827    /// follows it, so the arithmetic never lands mid-character.
1828    #[test]
1829    fn a_multibyte_row_shifts_by_one_byte_not_one_char() {
1830        const MB: &[&str] = &["@@ -1,1 +1,1 @@", " héllo wörld"];
1831        let r = |i: usize| MB.get(i).map(|s| s.to_string());
1832        // `é` is two bytes, so in the buffer row `" héllo"` the `l`
1833        // after it sits at byte 4; in the source `"héllo"` it is byte 3.
1834        let pos = source_position_at(r, 1, 4).expect("inside the hunk");
1835        assert_eq!(pos, SourcePos { line: 0, byte: 3 });
1836        assert!(
1837            "héllo wörld".is_char_boundary(pos.byte as usize),
1838            "the resolved offset must be a char boundary"
1839        );
1840    }
1841
1842    /// Outside a hunk there is no position at all, offset or otherwise —
1843    /// the caller opens the file at the top.
1844    #[test]
1845    fn a_row_outside_any_hunk_has_no_position() {
1846        assert_eq!(source_position_at(read, 0, 7), None);
1847    }
1848}