Skip to main content

grit_lib/
mergetool_vimdiff.rs

1//! Vimdiff merge tool layout generation compatible with Git's `mergetools/vimdiff` driver.
2//!
3//! This mirrors the shell logic in `git/mergetools/vimdiff` so `grit mergetool` and tests can
4//! share one implementation. Layout strings use only ASCII (`LOCAL`, `BASE`, `REMOTE`, `MERGED`,
5//! separators `+`, `/`, `,`, and parentheses).
6
7/// Result of [`vimdiff_gen_cmd`]: the `-c "..."` vim argument body and the save target pane.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct VimdiffGenCmd {
10    /// Full `-c "set hidden diffopt-=hiddenoff | ... | tabfirst"` string (as passed to `vim -f`).
11    pub final_cmd: String,
12    /// Which file receives edits when the tool exits successfully (`LOCAL`, `BASE`, `REMOTE`, or `MERGED`).
13    pub final_target: &'static str,
14}
15
16/// Computes `FINAL_CMD` and `FINAL_TARGET` from a layout string, matching `gen_cmd` in Git's vimdiff driver.
17///
18/// # Arguments
19///
20/// * `layout` — Layout definition (see `git help mergetool`, vimdiff backend).
21#[must_use]
22pub fn vimdiff_gen_cmd(layout: &str) -> VimdiffGenCmd {
23    let final_target = if layout.contains("@LOCAL") {
24        "LOCAL"
25    } else if layout.contains("@BASE") {
26        "BASE"
27    } else if layout.contains("@REMOTE") {
28        "REMOTE"
29    } else {
30        "MERGED"
31    };
32
33    let mut cmd = String::new();
34    for (tab_idx, tab) in layout.split('+').enumerate() {
35        if tab_idx == 0 {
36            cmd.push_str("echo");
37        } else {
38            cmd.push_str(" | tabnew");
39        }
40
41        if !tab.contains(',') && !tab.contains('/') {
42            cmd.push_str(" | silent execute 'bufdo diffthis'");
43        }
44
45        cmd = gen_cmd_aux(tab, cmd);
46    }
47
48    cmd.push_str(" | execute 'tabdo windo diffthis'");
49    let final_cmd = format!("-c \"set hidden diffopt-=hiddenoff | {cmd} | tabfirst\"");
50
51    VimdiffGenCmd {
52        final_cmd,
53        final_target,
54    }
55}
56
57/// Resolves the layout string for a merge tool name, matching `merge_cmd` in Git's vimdiff script.
58///
59/// * `tool` — e.g. `vimdiff`, `gvimdiff2`, `nvimdiff1`.
60/// * `mergetool_layout` — value of `mergetool.<tool>.layout` when set.
61/// * `vimdiff_layout_fallback` — value of `mergetool.vimdiff.layout` when variant-specific layout is unset.
62#[must_use]
63pub fn vimdiff_resolve_layout<'a>(
64    tool: &str,
65    mergetool_layout: Option<&'a str>,
66    vimdiff_layout_fallback: Option<&'a str>,
67) -> &'a str {
68    if let Some(l) = mergetool_layout.filter(|s| !s.is_empty()) {
69        return l;
70    }
71    if let Some(l) = vimdiff_layout_fallback.filter(|s| !s.is_empty()) {
72        return l;
73    }
74
75    if tool.ends_with("vimdiff1") {
76        return "@LOCAL,REMOTE";
77    }
78    if tool.ends_with("vimdiff2") {
79        return "LOCAL,MERGED,REMOTE";
80    }
81    if tool.ends_with("vimdiff3") {
82        return "MERGED";
83    }
84
85    if tool.contains("vimdiff") {
86        return "(LOCAL,BASE,REMOTE)/MERGED";
87    }
88
89    "(LOCAL,BASE,REMOTE)/MERGED"
90}
91
92/// Executable name for a vimdiff-family merge tool (`vim`, `gvim`, `nvim`).
93#[must_use]
94pub fn vimdiff_executable_for_tool(tool: &str) -> Option<&'static str> {
95    if tool.starts_with("nvimdiff") {
96        return Some("nvim");
97    }
98    if tool.starts_with("gvimdiff") {
99        return Some("gvim");
100    }
101    if tool.starts_with("vimdiff") {
102        return Some("vim");
103    }
104    None
105}
106
107/// When no base version exists, Git rewrites buffer indices in the vim command (`2b` → `quit`, etc.).
108#[must_use]
109pub fn vimdiff_cmd_without_base(final_cmd: &str) -> String {
110    final_cmd
111        .replace("2b", "quit")
112        .replace("3b", "2b")
113        .replace("4b", "3b")
114}
115
116fn substring_bytes(s: &str, start: usize, len: usize) -> &str {
117    let b = s.as_bytes();
118    if start >= b.len() || len == 0 {
119        return "";
120    }
121    let end = (start + len).min(b.len());
122    // Layout strings are ASCII-only in Git; slice must be char boundaries.
123    s.get(start..end).unwrap_or("")
124}
125
126fn gen_cmd_aux(layout: &str, mut cmd: String) -> String {
127    let b = layout.as_bytes();
128    let mut start = 0usize;
129    let mut end = b.len();
130
131    let mut nested = 0i32;
132    let mut nested_min = 100i32;
133    for &ch in b {
134        let c = ch as char;
135        if c == ' ' {
136            continue;
137        }
138        if c == '(' {
139            nested += 1;
140            continue;
141        }
142        if c == ')' {
143            nested -= 1;
144            continue;
145        }
146        nested_min = nested_min.min(nested);
147    }
148
149    let mut nested_min = nested_min;
150    while nested_min > 0 {
151        start += 1;
152        end -= 1;
153        let mut start_minus_one = start.wrapping_sub(1);
154        while start > 0 && substring_bytes(layout, start_minus_one, 1) != "(" {
155            start += 1;
156            start_minus_one = start.wrapping_sub(1);
157        }
158        while end > 0 && substring_bytes(layout, end, 1) != ")" {
159            end -= 1;
160        }
161        nested_min -= 1;
162    }
163
164    let mut index_horizontal: Option<usize> = None;
165    let mut index_vertical: Option<usize> = None;
166    let mut nested = 0i32;
167    let slice = substring_bytes(layout, start, end.saturating_sub(start));
168    for (offset, &ch) in slice.as_bytes().iter().enumerate() {
169        let c = ch as char;
170        if c == ' ' {
171            continue;
172        }
173        if c == '(' {
174            nested += 1;
175            continue;
176        }
177        if c == ')' {
178            nested -= 1;
179            continue;
180        }
181        if nested == 0 {
182            let idx = start + offset;
183            if c == '/' && index_horizontal.is_none() {
184                index_horizontal = Some(idx);
185            } else if c == ',' && index_vertical.is_none() {
186                index_vertical = Some(idx);
187            }
188        }
189    }
190
191    if let Some(index) = index_horizontal {
192        let (before, after) = ("leftabove split", "wincmd j");
193        cmd.push_str(" | ");
194        cmd.push_str(before);
195        cmd = gen_cmd_aux(
196            substring_bytes(layout, start, index.saturating_sub(start)),
197            cmd,
198        );
199        cmd.push_str(" | ");
200        cmd.push_str(after);
201        cmd = gen_cmd_aux(
202            substring_bytes(layout, index + 1, b.len().saturating_sub(index)),
203            cmd,
204        );
205        return cmd;
206    }
207
208    if let Some(index) = index_vertical {
209        let (before, after) = ("leftabove vertical split", "wincmd l");
210        cmd.push_str(" | ");
211        cmd.push_str(before);
212        cmd = gen_cmd_aux(
213            substring_bytes(layout, start, index.saturating_sub(start)),
214            cmd,
215        );
216        cmd.push_str(" | ");
217        cmd.push_str(after);
218        cmd = gen_cmd_aux(
219            substring_bytes(layout, index + 1, b.len().saturating_sub(index)),
220            cmd,
221        );
222        return cmd;
223    }
224
225    let leaf = substring_bytes(layout, start, end.saturating_sub(start));
226    let target: String = leaf
227        .chars()
228        .filter(|c| !matches!(c, ' ' | '@' | '(' | ')' | ';' | '|' | '-'))
229        .collect();
230
231    cmd.push_str(" | ");
232    cmd.push_str(match target.as_str() {
233        "LOCAL" => "1b",
234        "BASE" => "2b",
235        "REMOTE" => "3b",
236        "MERGED" => "4b",
237        _ => {
238            return format!("{cmd} | ERROR: >{target}<");
239        }
240    });
241
242    cmd
243}
244
245/// Inner script passed to `vim -f -c '<script>'` (without the outer `-c` wrapper).
246#[must_use]
247pub fn vimdiff_final_cmd_script(final_cmd: &str) -> String {
248    final_cmd
249        .strip_prefix("-c \"")
250        .and_then(|s| s.strip_suffix('"'))
251        .unwrap_or(final_cmd)
252        .to_string()
253}
254
255/// Builds argv for `vim -f -c '...' LOCAL BASE REMOTE MERGED` (base present), matching Git's `merge_cmd` eval.
256#[must_use]
257pub fn vimdiff_merge_argv_with_base(
258    final_cmd: &str,
259    local: &str,
260    base: &str,
261    remote: &str,
262    merged: &str,
263) -> Vec<String> {
264    vec![
265        "-f".to_string(),
266        "-c".to_string(),
267        vimdiff_final_cmd_script(final_cmd),
268        local.to_string(),
269        base.to_string(),
270        remote.to_string(),
271        merged.to_string(),
272    ]
273}
274
275/// Builds argv when the common ancestor is missing: `LOCAL REMOTE MERGED` only, after [`vimdiff_cmd_without_base`].
276#[must_use]
277pub fn vimdiff_merge_argv_no_base(
278    final_cmd: &str,
279    local: &str,
280    remote: &str,
281    merged: &str,
282) -> Vec<String> {
283    vec![
284        "-f".to_string(),
285        "-c".to_string(),
286        vimdiff_final_cmd_script(final_cmd),
287        local.to_string(),
288        remote.to_string(),
289        merged.to_string(),
290    ]
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn t7609_vimdiff_gen_cmd_cases() {
299        const CASES: &[&str] = &[
300            "(LOCAL,BASE,REMOTE)/MERGED",
301            "@LOCAL,REMOTE",
302            "LOCAL,MERGED,REMOTE",
303            "MERGED",
304            "LOCAL/MERGED/REMOTE",
305            "(LOCAL/REMOTE),MERGED",
306            "MERGED,(LOCAL/REMOTE)",
307            "(LOCAL,REMOTE)/MERGED",
308            "MERGED/(LOCAL,REMOTE)",
309            "(LOCAL/BASE/REMOTE),MERGED",
310            "(LOCAL,BASE,REMOTE)/MERGED+BASE,LOCAL+BASE,REMOTE+(LOCAL/BASE/REMOTE),MERGED",
311            "((LOCAL,REMOTE)/BASE),MERGED",
312            "((LOCAL,REMOTE)/BASE),((LOCAL/REMOTE),MERGED)",
313            "BASE,REMOTE+BASE,LOCAL",
314            "  ((  (LOCAL , BASE , REMOTE) / MERGED))   +(BASE)   , LOCAL+ BASE , REMOTE+ (((LOCAL / BASE / REMOTE)) ,    MERGED   )  ",
315            "LOCAL,BASE,REMOTE / MERGED + BASE,LOCAL + BASE,REMOTE + (LOCAL / BASE / REMOTE),MERGED",
316            "(LOCAL,@BASE,REMOTE)/MERGED",
317            "LOCAL,@REMOTE",
318            "@REMOTE",
319        ];
320
321        const EXPECTED_CMD: &[&str] = &[
322            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove split | leftabove vertical split | 1b | wincmd l | leftabove vertical split | 2b | wincmd l | 3b | wincmd j | 4b | execute 'tabdo windo diffthis' | tabfirst\"",
323            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove vertical split | 1b | wincmd l | 3b | execute 'tabdo windo diffthis' | tabfirst\"",
324            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove vertical split | 1b | wincmd l | leftabove vertical split | 4b | wincmd l | 3b | execute 'tabdo windo diffthis' | tabfirst\"",
325            "-c \"set hidden diffopt-=hiddenoff | echo | silent execute 'bufdo diffthis' | 4b | execute 'tabdo windo diffthis' | tabfirst\"",
326            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove split | 1b | wincmd j | leftabove split | 4b | wincmd j | 3b | execute 'tabdo windo diffthis' | tabfirst\"",
327            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove vertical split | leftabove split | 1b | wincmd j | 3b | wincmd l | 4b | execute 'tabdo windo diffthis' | tabfirst\"",
328            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove vertical split | 4b | wincmd l | leftabove split | 1b | wincmd j | 3b | execute 'tabdo windo diffthis' | tabfirst\"",
329            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove split | leftabove vertical split | 1b | wincmd l | 3b | wincmd j | 4b | execute 'tabdo windo diffthis' | tabfirst\"",
330            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove split | 4b | wincmd j | leftabove vertical split | 1b | wincmd l | 3b | execute 'tabdo windo diffthis' | tabfirst\"",
331            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove vertical split | leftabove split | 1b | wincmd j | leftabove split | 2b | wincmd j | 3b | wincmd l | 4b | execute 'tabdo windo diffthis' | tabfirst\"",
332            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove split | leftabove vertical split | 1b | wincmd l | leftabove vertical split | 2b | wincmd l | 3b | wincmd j | 4b | tabnew | leftabove vertical split | 2b | wincmd l | 1b | tabnew | leftabove vertical split | 2b | wincmd l | 3b | tabnew | leftabove vertical split | leftabove split | 1b | wincmd j | leftabove split | 2b | wincmd j | 3b | wincmd l | 4b | execute 'tabdo windo diffthis' | tabfirst\"",
333            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove vertical split | leftabove split | leftabove vertical split | 1b | wincmd l | 3b | wincmd j | 2b | wincmd l | 4b | execute 'tabdo windo diffthis' | tabfirst\"",
334            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove vertical split | leftabove split | leftabove vertical split | 1b | wincmd l | 3b | wincmd j | 2b | wincmd l | leftabove vertical split | leftabove split | 1b | wincmd j | 3b | wincmd l | 4b | execute 'tabdo windo diffthis' | tabfirst\"",
335            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove vertical split | 2b | wincmd l | 3b | tabnew | leftabove vertical split | 2b | wincmd l | 1b | execute 'tabdo windo diffthis' | tabfirst\"",
336            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove split | leftabove vertical split | 1b | wincmd l | leftabove vertical split | 2b | wincmd l | 3b | wincmd j | 4b | tabnew | leftabove vertical split | 2b | wincmd l | 1b | tabnew | leftabove vertical split | 2b | wincmd l | 3b | tabnew | leftabove vertical split | leftabove split | 1b | wincmd j | leftabove split | 2b | wincmd j | 3b | wincmd l | 4b | execute 'tabdo windo diffthis' | tabfirst\"",
337            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove split | leftabove vertical split | 1b | wincmd l | leftabove vertical split | 2b | wincmd l | 3b | wincmd j | 4b | tabnew | leftabove vertical split | 2b | wincmd l | 1b | tabnew | leftabove vertical split | 2b | wincmd l | 3b | tabnew | leftabove vertical split | leftabove split | 1b | wincmd j | leftabove split | 2b | wincmd j | 3b | wincmd l | 4b | execute 'tabdo windo diffthis' | tabfirst\"",
338            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove split | leftabove vertical split | 1b | wincmd l | leftabove vertical split | 2b | wincmd l | 3b | wincmd j | 4b | execute 'tabdo windo diffthis' | tabfirst\"",
339            "-c \"set hidden diffopt-=hiddenoff | echo | leftabove vertical split | 1b | wincmd l | 3b | execute 'tabdo windo diffthis' | tabfirst\"",
340            "-c \"set hidden diffopt-=hiddenoff | echo | silent execute 'bufdo diffthis' | 3b | execute 'tabdo windo diffthis' | tabfirst\"",
341        ];
342
343        const EXPECTED_TARGET: &[&str] = &[
344            "MERGED", "LOCAL", "MERGED", "MERGED", "MERGED", "MERGED", "MERGED", "MERGED",
345            "MERGED", "MERGED", "MERGED", "MERGED", "MERGED", "MERGED", "MERGED", "MERGED", "BASE",
346            "REMOTE", "REMOTE",
347        ];
348
349        assert_eq!(CASES.len(), EXPECTED_CMD.len());
350        assert_eq!(CASES.len(), EXPECTED_TARGET.len());
351
352        for (i, layout) in CASES.iter().enumerate() {
353            let g = vimdiff_gen_cmd(layout);
354            assert_eq!(
355                g.final_cmd,
356                EXPECTED_CMD[i],
357                "case {} layout {:?}",
358                i + 1,
359                layout
360            );
361            assert_eq!(g.final_target, EXPECTED_TARGET[i], "target case {}", i + 1);
362        }
363    }
364
365    #[test]
366    fn t7609_merge_argv_paths_with_spaces() {
367        let g = vimdiff_gen_cmd("(LOCAL,BASE,REMOTE)/MERGED");
368        let adjusted = vimdiff_cmd_without_base(&g.final_cmd);
369        let argv = vimdiff_merge_argv_no_base(&adjusted, "lo cal", "' '", "mer ged");
370        assert_eq!(
371            argv,
372            vec![
373                "-f".to_string(),
374                "-c".to_string(),
375                "set hidden diffopt-=hiddenoff | echo | leftabove split | leftabove vertical split | 1b | wincmd l | leftabove vertical split | quit | wincmd l | 2b | wincmd j | 3b | execute 'tabdo windo diffthis' | tabfirst".to_string(),
376                "lo cal".to_string(),
377                "' '".to_string(),
378                "mer ged".to_string(),
379            ],
380            "merge_cmd without base: three path args, single -c string"
381        );
382    }
383}