worktrunk 0.37.1

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
//! Gutter formatting for quoted content
//!
//! Provides functions for formatting commands and configuration with visual gutters.

#[cfg(feature = "syntax-highlighting")]
use super::highlighting::bash_token_style;
#[cfg(feature = "syntax-highlighting")]
use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};

// Import canonical implementations from parent module
use super::{terminal_width, visual_width};

/// Width overhead added by format_with_gutter()
///
/// The gutter formatting adds:
/// - 1 column: colored space (gutter)
/// - 1 column: regular space for padding
///
/// Total: 2 columns
///
/// This aligns with message symbols (1 char) + space (1 char) = 2 columns,
/// so gutter content starts at the same column as message text.
///
/// When passing widths to tools like git --stat-width, subtract this overhead
/// so the final output (content + gutter) fits within the terminal width.
pub const GUTTER_OVERHEAD: usize = 2;

/// Wraps text at word boundaries to fit within the specified width
///
/// # Arguments
/// * `text` - The text to wrap (may contain ANSI codes)
/// * `max_width` - Maximum visual width for each line
///
/// # Returns
/// A vector of wrapped lines
///
/// # Note
/// Width calculation ignores ANSI escape codes to handle colored output correctly.
pub(super) fn wrap_text_at_width(text: &str, max_width: usize) -> Vec<String> {
    if max_width == 0 {
        return vec![text.to_string()];
    }

    // Use visual width (ignoring ANSI codes) for proper wrapping of colored text
    let text_width = visual_width(text);

    // If the line fits, return it as-is
    if text_width <= max_width {
        return vec![text.to_string()];
    }

    let mut lines = Vec::new();
    let mut current_line = String::new();
    let mut current_width = 0;

    for word in text.split_whitespace() {
        let word_width = visual_width(word);

        // If this is the first word in the line
        if current_line.is_empty() {
            // If a single word is longer than max_width, we have to include it anyway
            current_line = word.to_string();
            current_width = word_width;
        } else {
            // Calculate width with space before the word
            let new_width = current_width + 1 + word_width;

            if new_width <= max_width {
                // Word fits on current line
                current_line.push(' ');
                current_line.push_str(word);
                current_width = new_width;
            } else {
                // Word doesn't fit, start a new line
                lines.push(current_line);
                current_line = word.to_string();
                current_width = word_width;
            }
        }
    }

    // Add the last line if there's content
    if !current_line.is_empty() {
        lines.push(current_line);
    }

    // Handle empty input
    if lines.is_empty() {
        lines.push(String::new());
    }

    lines
}

/// Formats text with a gutter (single-space with background color) on each line
///
/// This creates a subtle visual separator for quoted content like commands or configuration.
/// Text is automatically word-wrapped at terminal width to prevent overflow.
///
/// # Arguments
/// * `content` - The text to format (preserves internal structure for multi-line)
/// * `max_width` - Optional maximum width (for testing). If None, auto-detects terminal width.
///
/// The gutter appears at column 0, followed by 1 space, then the content starts at column 2.
/// This aligns with message symbols (1 column) + space (1 column) = content at column 2.
///
/// # Example
/// ```
/// use worktrunk::styling::format_with_gutter;
///
/// print!("{}", format_with_gutter("hello world", Some(80)));
/// ```
pub fn format_with_gutter(content: &str, max_width: Option<usize>) -> String {
    let gutter = super::GUTTER;

    // Use provided width or detect terminal width (respects COLUMNS env var)
    let term_width = max_width.unwrap_or_else(terminal_width);

    // Account for gutter (1) + space (1)
    let available_width = term_width.saturating_sub(2);

    // Build lines without trailing newline - caller is responsible for element separation
    let lines: Vec<String> = content
        .lines()
        .flat_map(|line| {
            wrap_text_at_width(line, available_width)
                .into_iter()
                .map(|wrapped_line| format!("{gutter} {gutter:#} {wrapped_line}"))
        })
        .collect();

    lines.join("\n")
}

/// Wrap ANSI-styled text at word boundaries, preserving styles across line breaks
///
/// Uses `wrap-ansi` crate which handles ANSI escape sequences, Unicode width,
/// and OSC 8 hyperlinks automatically.
///
/// Note: wrap_ansi injects color reset codes ([39m for foreground, [49m for background)
/// at line ends to make each line "self-contained". We strip these because:
/// 1. We never emit [39m/[49m ourselves - all our resets use [0m (full reset)
/// 2. These injected codes create visual discontinuity when styled text wraps
///
/// Additionally, wrap_ansi may split between styled content and its reset code,
/// leaving [0m at the start of continuation lines. We move these to line ends.
///
/// IMPORTANT: wrap_ansi only restores foreground colors on continuation lines,
/// not text attributes like dim. We detect this and prepend dim (\x1b[2m) to
/// continuation lines that start with a color code, ensuring consistent dimming.
///
/// Leading indentation is preserved: if the input starts with spaces, continuation
/// lines will have the same indentation (wrapping happens within the remaining width).
pub fn wrap_styled_text(styled: &str, max_width: usize) -> Vec<String> {
    if max_width == 0 {
        return vec![styled.to_string()];
    }

    // Detect leading indentation (spaces before any content or ANSI codes)
    let leading_spaces = styled.chars().take_while(|c| *c == ' ').count();
    let indent = " ".repeat(leading_spaces);
    let content = &styled[leading_spaces..];

    // Handle whitespace-only or empty content
    if content.is_empty() {
        return vec![styled.to_string()];
    }

    // Calculate width for content (excluding indent)
    let content_width = max_width.saturating_sub(leading_spaces);
    if content_width < 10 {
        // Width too narrow for meaningful wrapping
        return vec![styled.to_string()];
    }

    // wrap_ansi returns a string with '\n' at wrap points, preserving ANSI styles
    // Preserve leading whitespace (wrap_ansi's default trims it)
    let options = wrap_ansi::WrapOptions::builder()
        .trim_whitespace(false)
        .build();
    let wrapped = wrap_ansi::wrap_ansi(content, content_width, Some(options));

    if wrapped.is_empty() {
        return vec![String::new()];
    }

    // Strip color reset codes injected by wrap_ansi - we never emit these ourselves,
    // so any occurrence is an artifact that creates visual discontinuity
    let cleaned = wrapped
        .replace("\x1b[39m", "") // reset foreground to default
        .replace("\x1b[49m", ""); // reset background to default

    // Fix reset codes that got separated from their content by wrapping.
    // When wrap happens between styled text and its [0m reset, the reset
    // ends up at the start of the next line. Strip leading resets.
    //
    // Also fix missing dim on continuation lines: wrap_ansi restores colors
    // but not text attributes like dim. If a line starts with a color code
    // (e.g., \x1b[32m) but no dim (\x1b[2m), prepend dim to maintain consistency.
    let lines: Vec<_> = cleaned.lines().collect();
    let mut result = Vec::with_capacity(lines.len());

    for (i, line) in lines.iter().enumerate() {
        let trimmed = line.strip_prefix("\x1b[0m").unwrap_or(line);

        // For continuation lines (not first), check if we need to restore dim.
        // wrap_ansi restores foreground colors (\x1b[3Xm where X is 0-7 or 8;...)
        // but drops text attributes like dim (\x1b[2m).
        //
        // We restore dim for lines that start with a color code. This is safe
        // because format_bash_with_gutter_impl always starts lines with dim,
        // so any wrapped continuation should also be dimmed.
        let with_dim = if i > 0 && trimmed.starts_with("\x1b[3") {
            format!("\x1b[2m{trimmed}")
        } else {
            trimmed.to_owned()
        };

        // Add the original indentation to all lines
        result.push(format!("{indent}{with_dim}"));
    }

    result
}

#[cfg(feature = "syntax-highlighting")]
fn format_bash_with_gutter_impl(content: &str, width_override: Option<usize>) -> String {
    // Normalize line endings: CRLF to LF, and trim trailing newlines.
    // Trailing newlines would create spurious blank gutter lines because
    // style restoration after newlines produces `\n[DIM]` which becomes
    // its own line when split.
    let content = content.replace("\r\n", "\n");
    let content = content.trim_end_matches('\n');

    // Replace Jinja template delimiters with identifier placeholders before parsing.
    // Tree-sitter can't parse `{{` and `}}` (especially when split across lines),
    // so we swap them out and restore after highlighting. Placeholders must NOT
    // contain quote characters — they break quote boundaries when adjacent to
    // existing quotes (e.g., `"{{ var }}"` → `""WTO" var "WTC""` with double quotes,
    // or `'text {{ var }}'` → `'text 'WTO'...'WTC''` with single quotes).
    //
    // TPL_CLOSE gets a trailing space so tree-sitter doesn't merge it with adjacent
    // path characters (e.g., `}}/path` → `WTC/path` would be one "function" token,
    // giving the path the wrong color). The space is stripped during restoration.
    const TPL_OPEN: &str = "WTO";
    const TPL_CLOSE: &str = "WTC";
    let normalized = content
        .replace("{{", TPL_OPEN)
        .replace("}}", &format!("{TPL_CLOSE} "));
    let content = normalized.as_str();

    let gutter = super::GUTTER;
    let reset = anstyle::Reset;
    let dim = anstyle::Style::new().dimmed();
    let string_style = bash_token_style("string").unwrap_or(dim);

    // Calculate available width for content
    let term_width = width_override.unwrap_or_else(terminal_width);
    let available_width = term_width.saturating_sub(2);

    // Set up tree-sitter bash highlighting
    let highlight_names = vec![
        "function", // Commands like npm, git, cargo
        "keyword",  // Keywords like for, if, while
        "string",   // Quoted strings
        "operator", // Operators like &&, ||, |, $, -
        "comment",  // Comments
        "number",   // Numbers
        "variable", // Variables
        "constant", // Constants/flags
    ];

    let bash_language = tree_sitter_bash::LANGUAGE.into();
    let bash_highlights = tree_sitter_bash::HIGHLIGHT_QUERY;

    let mut config = HighlightConfiguration::new(
        bash_language,
        "bash",
        bash_highlights,
        "", // injections query
        "", // locals query
    )
    .expect("tree-sitter-bash HIGHLIGHT_QUERY should be valid");
    config.configure(&highlight_names);

    let mut highlighter = Highlighter::new();
    let highlights = highlighter
        .highlight(&config, content.as_bytes(), None, |_| None)
        .expect("highlighting valid UTF-8 should not fail");

    let content_bytes = content.as_bytes();

    // Phase 1: Build styled content with ANSI codes, restoring style after newlines.
    // Template placeholders are restored here (not in a separate phase) because we
    // need the active style context to correctly style `{{ }}` as green (string).
    // A post-hoc replace can't know whether the delimiter was inside a string
    // (inherits green) or bare (needs green injected).
    let mut styled = format!("{dim}");
    let mut pending_highlight: Option<usize> = None;
    let mut active_style: Option<anstyle::Style> = None;
    let mut ate_tpl_boundary = false;
    let close_with_space = format!("{TPL_CLOSE} ");

    for event in highlights {
        match event.unwrap() {
            HighlightEvent::Source { start, end } => {
                if let Ok(text) = std::str::from_utf8(&content_bytes[start..end]) {
                    // Strip boundary space inserted after TPL_CLOSE for token separation.
                    // The space may land in a separate Source event from TPL_CLOSE itself.
                    let text = if ate_tpl_boundary {
                        ate_tpl_boundary = false;
                        text.strip_prefix(' ').unwrap_or(text)
                    } else {
                        text
                    };

                    // Apply pending highlight style. Skip for pure placeholder tokens —
                    // tree-sitter sees e.g. `WTC` as a "function" but that's meaningless;
                    // placeholder restoration handles the styling.
                    let is_placeholder = text == TPL_CLOSE || text == TPL_OPEN;
                    if let Some(idx) = pending_highlight.take()
                        && let Some(name) = highlight_names.get(idx)
                        && let Some(style) = bash_token_style(name)
                        && !is_placeholder
                    {
                        styled.push_str(&format!("{reset}{style}"));
                        active_style = Some(style);
                    }

                    // Restore template placeholders with string styling for `{{ }}`.
                    // When already inside a string (green context), just replace the
                    // text — no ANSI injection needed since `{{ }}` inherits the style.
                    // Otherwise, inject string styling and restore the active context.
                    //
                    // Replace "WTC " before "WTC" to consume the boundary space when
                    // both land in the same Source event (e.g., inside a string token).
                    let has_placeholder = text.contains(TPL_OPEN) || text.contains(TPL_CLOSE);
                    let ends_with_close = text.ends_with(TPL_CLOSE);
                    let text = if !has_placeholder {
                        text.to_string()
                    } else if active_style == Some(string_style) {
                        text.replace(TPL_OPEN, "{{")
                            .replace(&close_with_space, "}}")
                            .replace(TPL_CLOSE, "}}")
                    } else {
                        let restore = format!("{reset}{}", active_style.unwrap_or(dim));
                        let close_repl = format!("{reset}{string_style}}}}}{restore}");
                        text.replace(TPL_OPEN, &format!("{reset}{string_style}{{{{{restore}"))
                            .replace(&close_with_space, &close_repl)
                            .replace(TPL_CLOSE, &close_repl)
                    };

                    if ends_with_close {
                        ate_tpl_boundary = true;
                    }

                    // Insert style restore after each newline so lines are self-contained
                    let style_restore = match active_style {
                        Some(style) => format!("{dim}{reset}{style}"),
                        None => format!("{dim}"),
                    };
                    styled.push_str(&text.replace('\n', &format!("\n{style_restore}")));
                }
            }
            HighlightEvent::HighlightStart(idx) => {
                pending_highlight = Some(idx.0);
            }
            HighlightEvent::HighlightEnd => {
                pending_highlight = None;
                active_style = None;
                styled.push_str(&format!("{reset}{dim}"));
            }
        }
    }

    // Phase 3: Split into lines, wrap each, add gutters
    styled
        .lines()
        .flat_map(|line| wrap_styled_text(line, available_width))
        .map(|wrapped| format!("{gutter} {gutter:#} {wrapped}{reset}"))
        .collect::<Vec<_>>()
        .join("\n")
}

/// Formats bash/shell commands with syntax highlighting and gutter
///
/// Uses unified highlighting (entire command at once) to preserve context across
/// line breaks. Multi-line strings are correctly highlighted.
///
/// Template syntax (`{{ }}`) is detected to avoid misinterpreting Jinja variables
/// as shell commands.
///
/// # Example
/// ```
/// use worktrunk::styling::format_bash_with_gutter;
///
/// print!("{}", format_bash_with_gutter("npm install --frozen-lockfile"));
/// ```
#[cfg(feature = "syntax-highlighting")]
pub fn format_bash_with_gutter(content: &str) -> String {
    format_bash_with_gutter_impl(content, None)
}

/// Test-only helper to force a specific terminal width for deterministic output.
///
/// This avoids env var mutation which is unsafe in parallel tests.
#[cfg(all(test, feature = "syntax-highlighting"))]
pub(crate) fn format_bash_with_gutter_at_width(content: &str, width: usize) -> String {
    format_bash_with_gutter_impl(content, Some(width))
}

/// Format bash commands with gutter (fallback without syntax highlighting)
///
/// This version is used when the `syntax-highlighting` feature is disabled.
/// It provides the same gutter formatting without tree-sitter dependencies.
#[cfg(not(feature = "syntax-highlighting"))]
pub fn format_bash_with_gutter(content: &str) -> String {
    format_with_gutter(content, None)
}

#[cfg(test)]
mod tests {
    use insta::assert_snapshot;

    use super::*;

    /// Wraps at word boundaries; never breaks a word; degrades gracefully on edge cases.
    #[test]
    fn test_wrap_text_at_width() {
        assert_eq!(wrap_text_at_width("short text", 20), vec!["short text"]);
        assert_eq!(
            wrap_text_at_width("hello world foo bar", 10),
            vec!["hello", "world foo", "bar"]
        );
        // Single long word is never broken
        assert_eq!(
            wrap_text_at_width("superlongword", 5),
            vec!["superlongword"]
        );
        // Multiple spaces preserved when no wrapping needed
        assert_eq!(
            wrap_text_at_width("hello    world", 20),
            vec!["hello    world"]
        );
        // Edge cases: zero width and empty input
        assert_eq!(wrap_text_at_width("hello world", 0), vec!["hello world"]);
        assert_eq!(wrap_text_at_width("", 20), vec![""]);
    }

    /// Same word-boundary contract as wrap_text_at_width, plus preserves leading indent.
    #[test]
    fn test_wrap_styled_text_edge_cases() {
        assert_eq!(wrap_styled_text("short text", 20), vec!["short text"]);
        assert_eq!(wrap_styled_text("hello world", 0), vec!["hello world"]);
        assert_eq!(wrap_styled_text("", 20), vec![""]);
        // Whitespace is preserved
        assert_eq!(
            wrap_styled_text("          Print help", 80),
            vec!["          Print help"]
        );
        assert_eq!(wrap_styled_text("          ", 80), vec!["          "]);
    }

    /// Continuation lines keep the same leading indent as the first line.
    #[test]
    fn test_wrap_styled_text_preserves_indent_on_wrap() {
        let result = wrap_styled_text(
            "          This is a longer text that should wrap across multiple lines",
            40,
        );
        assert!(result.len() > 1);
        for line in &result {
            assert!(
                line.starts_with("          "),
                "Line should start with 10 spaces: {:?}",
                line
            );
        }
    }

    /// Gutter formatting: single, multiline, empty, and wrapping cases.
    #[test]
    fn test_format_with_gutter() {
        assert_snapshot!(format_with_gutter("hello", Some(80)), @"  hello");
        assert_snapshot!(format_with_gutter("line1\nline2", Some(80)), @"
          line1
          line2
        ");
        assert_snapshot!(format_with_gutter("", Some(80)), @"");
        assert_snapshot!(format_with_gutter("word1 word2 word3 word4", Some(15)), @"
          word1 word2
          word3 word4
        ");
    }

    /// ANSI codes don't affect wrapping width and are preserved in output.
    #[test]
    fn test_wrap_styled_text_with_ansi() {
        let styled = "\u{1b}[1mbold text\u{1b}[0m here";
        let result = wrap_styled_text(styled, 100);
        assert_snapshot!(result[0], @"bold text here");
    }

    /// wrap_ansi's injected [39m]/[49m] resets are stripped (we use [0m] only).
    #[test]
    fn test_wrap_styled_text_strips_injected_resets() {
        let styled = "some colored text";
        let result = wrap_styled_text(styled, 50);
        assert!(!result[0].contains("\u{1b}[39m"));
        assert!(!result[0].contains("\u{1b}[49m"));
    }

    /// Continuation lines after wrap restore dim styling (wrap_ansi drops it).
    #[test]
    fn test_wrap_styled_text_restores_dim_on_continuation() {
        let dim = "\x1b[2m";
        let green = "\x1b[32m";
        let reset = "\x1b[0m";
        let styled = format!(
            "{dim}{green}This is a very long string that definitely needs to wrap across multiple lines{reset}"
        );

        let result = wrap_styled_text(&styled, 30);
        assert!(result.len() > 1);
        assert!(result[0].starts_with("\x1b[2m\x1b[32m"));
        for line in result.iter().skip(1) {
            assert!(line.starts_with("\x1b[2m\x1b[32m") || line.starts_with("\x1b[2m"));
        }
    }

    /// Bash syntax highlighting with gutter: single-line, multi-line, complex commands.
    #[test]
    #[cfg(feature = "syntax-highlighting")]
    fn test_format_bash_with_gutter() {
        assert_snapshot!(format_bash_with_gutter_at_width("echo hello", 80), @"  echo hello");
        assert_snapshot!(
            format_bash_with_gutter_at_width("echo line1\necho line2", 80),
            @"
          echo line1
          echo line2
        "
        );
        assert_snapshot!(
            format_bash_with_gutter_at_width("npm install && cargo build --release", 100),
            @"  npm install && cargo build --release"
        );
    }

    /// Regression: tree-sitter identifies `&&` as operators even at line ends.
    #[test]
    #[cfg(feature = "syntax-highlighting")]
    fn test_unified_multiline_highlighting() {
        use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};

        let cmd = "echo 'line1' &&\necho 'line2' &&\necho 'line3'";

        let highlight_names = vec![
            "function", "keyword", "string", "operator", "comment", "number", "variable",
            "constant",
        ];

        let bash_language = tree_sitter_bash::LANGUAGE.into();
        let bash_highlights = tree_sitter_bash::HIGHLIGHT_QUERY;

        let mut config =
            HighlightConfiguration::new(bash_language, "bash", bash_highlights, "", "").unwrap();
        config.configure(&highlight_names);

        let mut highlighter = Highlighter::new();

        let mut output = String::new();
        let highlights = highlighter
            .highlight(&config, cmd.as_bytes(), None, |_| None)
            .unwrap();
        for event in highlights {
            match event.unwrap() {
                HighlightEvent::Source { start, end } => {
                    output.push_str(&cmd[start..end]);
                }
                HighlightEvent::HighlightStart(idx) => {
                    output.push_str(&format!("[{}:", highlight_names[idx.0]));
                }
                HighlightEvent::HighlightEnd => {
                    output.push(']');
                }
            }
        }

        assert_snapshot!(output, @"
        [function:echo] [string:'line1'] [operator:&&]
        [function:echo] [string:'line2'] [operator:&&]
        [function:echo] [string:'line3']
        ");
    }

    /// Regression: `{{ }}` inside double quotes are restored without breaking highlighting.
    #[test]
    #[cfg(feature = "syntax-highlighting")]
    fn test_template_vars_inside_quotes_restored() {
        use ansi_str::AnsiStr;

        let cmd = r#"if [ "{{ target }}" = "main" ]; then git pull && git push; fi"#;
        let result = format_bash_with_gutter_at_width(cmd, 120);

        assert_snapshot!(result.ansi_strip(), @r#"  if [ "{{ target }}" = "main" ]; then git pull && git push; fi"#);
    }

    /// Regression: template syntax `{{ }}` doesn't prevent command identification.
    #[test]
    #[cfg(feature = "syntax-highlighting")]
    fn test_highlighting_with_template_syntax() {
        use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};

        let cmd = "echo {{ branch }} && mkdir {{ path }}";

        let highlight_names = vec!["function", "keyword", "string", "operator", "constant"];

        let bash_language = tree_sitter_bash::LANGUAGE.into();
        let bash_highlights = tree_sitter_bash::HIGHLIGHT_QUERY;

        let mut config =
            HighlightConfiguration::new(bash_language, "bash", bash_highlights, "", "").unwrap();
        config.configure(&highlight_names);

        let mut highlighter = Highlighter::new();

        let mut output = String::new();
        let highlights = highlighter
            .highlight(&config, cmd.as_bytes(), None, |_| None)
            .unwrap();
        for event in highlights {
            match event.unwrap() {
                HighlightEvent::Source { start, end } => {
                    output.push_str(&cmd[start..end]);
                }
                HighlightEvent::HighlightStart(idx) => {
                    output.push_str(&format!("[{}:", highlight_names[idx.0]));
                }
                HighlightEvent::HighlightEnd => {
                    output.push(']');
                }
            }
        }

        assert_snapshot!(output, @"[function:echo] {{ branch }} [operator:&&] [function:mkdir] {{ path }}");
    }
}