Skip to main content

vt_patch_bridge/
vt_patch_bridge.rs

1use std::env;
2use std::error::Error;
3use std::fs;
4use std::io::{self, Write};
5
6use highlight_spans::{Grammar, SpanHighlighter};
7use render_ansi::{
8    highlight_to_ansi_with_highlighter_and_mode_and_background, ColorMode, IncrementalRenderer,
9    RenderError, StreamLineRenderer,
10};
11use theme_engine::load_theme;
12
13#[derive(Debug, Clone)]
14struct Options {
15    source_path: String,
16    theme_name: String,
17    grammar_name: String,
18    width: usize,
19    height: usize,
20    origin_row: Option<usize>,
21    origin_col: usize,
22    color_mode: ColorMode,
23    preserve_terminal_background: bool,
24    previous_source_path: Option<String>,
25}
26
27/// Parses a grammar argument and returns a human-friendly error on failure.
28fn parse_grammar(input: &str) -> Result<Grammar, String> {
29    Grammar::from_name(input).ok_or_else(|| {
30        format!(
31            "unknown grammar '{}'; use one of: {}",
32            input,
33            Grammar::supported_names().join(", ")
34        )
35    })
36}
37
38/// Parses a viewport dimension from CLI value, env var, or fallback.
39///
40/// The resolved value is always at least `1`.
41fn parse_dimension(value: Option<&str>, env_key: &str, fallback: usize) -> usize {
42    if let Some(v) = value {
43        if let Ok(parsed) = v.parse::<usize>() {
44            return parsed.max(1);
45        }
46    }
47    env::var(env_key)
48        .ok()
49        .and_then(|s| s.parse::<usize>().ok())
50        .map(|v| v.max(1))
51        .unwrap_or(fallback)
52}
53
54/// Parses a color mode argument and returns a human-friendly error on failure.
55fn parse_color_mode(input: &str) -> Result<ColorMode, String> {
56    ColorMode::from_name(input).ok_or_else(|| {
57        format!(
58            "unknown color mode '{}'; use one of: {}",
59            input,
60            ColorMode::supported_names().join(", ")
61        )
62    })
63}
64
65/// Maps stream-line renderer errors into user-facing CLI errors.
66fn streamline_mode_error(err: RenderError) -> Box<dyn Error> {
67    match err {
68        RenderError::MultiLineInput => {
69            "stream-line mode requires single-line input; pass --origin-row to use IncrementalRenderer"
70                .into()
71        }
72        other => Box::new(other),
73    }
74}
75
76/// Returns the number of logical lines in a snapshot (`\n` + 1), minimum `1`.
77fn logical_line_count(source: &[u8]) -> usize {
78    source.iter().filter(|&&byte| byte == b'\n').count() + 1
79}
80
81/// Builds a full-rerender patch that clears the previously painted logical block.
82///
83/// The patch uses relative movement only:
84/// - return to column 1
85/// - move up to the start of the old block
86/// - clear each old line (`CSI 2K`)
87/// - return to block start
88/// - append the full rendered frame
89fn build_full_rerender_patch(rendered: String, previous_source: Option<&[u8]>) -> String {
90    let clear_lines = previous_source.map_or(1usize, logical_line_count).max(1);
91    let mut patch = String::new();
92    patch.push('\r');
93
94    if clear_lines > 1 {
95        patch.push_str(&format!("\x1b[{}A", clear_lines - 1));
96    }
97
98    for line_idx in 0..clear_lines {
99        patch.push_str("\x1b[2K");
100        if line_idx + 1 < clear_lines {
101            patch.push_str("\x1b[1B\r");
102        }
103    }
104
105    if clear_lines > 1 {
106        patch.push_str(&format!("\x1b[{}A", clear_lines - 1));
107    }
108    patch.push('\r');
109    patch.push_str(&rendered);
110    patch
111}
112
113/// Parses CLI options for the `vt_patch_bridge` example.
114///
115/// Returns a user-facing error string suitable for usage output.
116fn parse_args(args: &[String]) -> Result<Options, String> {
117    if args.len() < 2 {
118        return Err("missing source file".to_string());
119    }
120
121    let source_path = args[1].clone();
122    let theme_name = args
123        .get(2)
124        .cloned()
125        .unwrap_or_else(|| "tokyonight-dark".to_string());
126    let grammar_name = args
127        .get(3)
128        .cloned()
129        .unwrap_or_else(|| "objectscript".to_string());
130
131    let mut width_arg: Option<String> = None;
132    let mut height_arg: Option<String> = None;
133    let mut origin_row_arg: Option<String> = None;
134    let mut origin_col_arg: Option<String> = None;
135    let mut color_mode = ColorMode::TrueColor;
136    let mut preserve_terminal_background = true;
137    let mut previous_source_path: Option<String> = None;
138    let mut i = 4usize;
139    while i < args.len() {
140        match args[i].as_str() {
141            "--width" => {
142                i += 1;
143                let Some(value) = args.get(i) else {
144                    return Err("expected value after --width".to_string());
145                };
146                width_arg = Some(value.clone());
147            }
148            "--height" => {
149                i += 1;
150                let Some(value) = args.get(i) else {
151                    return Err("expected value after --height".to_string());
152                };
153                height_arg = Some(value.clone());
154            }
155            "--origin-row" => {
156                i += 1;
157                let Some(value) = args.get(i) else {
158                    return Err("expected value after --origin-row".to_string());
159                };
160                origin_row_arg = Some(value.clone());
161            }
162            "--origin-col" => {
163                i += 1;
164                let Some(value) = args.get(i) else {
165                    return Err("expected value after --origin-col".to_string());
166                };
167                origin_col_arg = Some(value.clone());
168            }
169            "--prev" => {
170                i += 1;
171                let Some(value) = args.get(i) else {
172                    return Err("expected value after --prev".to_string());
173                };
174                previous_source_path = Some(value.clone());
175            }
176            "--color-mode" => {
177                i += 1;
178                let Some(value) = args.get(i) else {
179                    return Err("expected value after --color-mode".to_string());
180                };
181                color_mode = parse_color_mode(value)?;
182            }
183            "--theme-bg" => {
184                preserve_terminal_background = false;
185            }
186            "--terminal-bg" => {
187                preserve_terminal_background = true;
188            }
189            flag => return Err(format!("unknown option '{flag}'")),
190        }
191        i += 1;
192    }
193
194    if origin_row_arg.is_none() && origin_col_arg.is_some() {
195        return Err("--origin-col requires --origin-row".to_string());
196    }
197
198    let origin_row = match origin_row_arg.as_deref() {
199        Some(value) => {
200            let parsed = value
201                .parse::<usize>()
202                .map_err(|_| format!("invalid value '{value}' for --origin-row"))?;
203            Some(parsed.max(1))
204        }
205        None => None,
206    };
207    let origin_col = match origin_col_arg.as_deref() {
208        Some(value) => value
209            .parse::<usize>()
210            .map(|v| v.max(1))
211            .map_err(|_| format!("invalid value '{value}' for --origin-col"))?,
212        None => 1,
213    };
214
215    Ok(Options {
216        source_path,
217        theme_name,
218        grammar_name,
219        width: parse_dimension(width_arg.as_deref(), "COLUMNS", 240),
220        height: parse_dimension(height_arg.as_deref(), "LINES", 80),
221        origin_row,
222        origin_col,
223        color_mode,
224        preserve_terminal_background,
225        previous_source_path,
226    })
227}
228
229/// Prints CLI usage for the `vt_patch_bridge` example.
230fn print_usage() {
231    eprintln!("Usage:");
232    eprintln!(
233        "  cargo run -p render-ansi --example vt_patch_bridge -- <source-file> [theme] [grammar] [--color-mode truecolor|ansi256|ansi16] [--theme-bg] [--prev <old-source-file>] [--origin-row N --origin-col N --width N --height N]"
234    );
235    eprintln!();
236    eprintln!("Mode selection:");
237    eprintln!(
238        "  - no --origin-row: uses StreamLineRenderer (single-line relative mode), falls back to full render for multiline input"
239    );
240    eprintln!("  - with --origin-row: uses IncrementalRenderer (multiline viewport mode)");
241    eprintln!();
242    eprintln!("Examples:");
243    eprintln!("  cargo run -p render-ansi --example vt_patch_bridge -- sample.cls");
244    eprintln!(
245        "  cargo run -p render-ansi --example vt_patch_bridge -- sample.mac solarized-dark objectscript --width 200 --height 60"
246    );
247    eprintln!(
248        "  cargo run -p render-ansi --example vt_patch_bridge -- sample.mac tokyonight-dark objectscript --origin-row 4 --origin-col 7"
249    );
250    eprintln!(
251        "  cargo run -p render-ansi --example vt_patch_bridge -- new.mac tokyonight-dark objectscript --prev old.mac"
252    );
253    eprintln!(
254        "  cargo run -p render-ansi --example vt_patch_bridge -- sample.sql tokyonight-dark sql --color-mode ansi256"
255    );
256    eprintln!(
257        "  cargo run -p render-ansi --example vt_patch_bridge -- sample.sql tokyonight-dark sql --color-mode ansi16"
258    );
259    eprintln!(
260        "  cargo run -p render-ansi --example vt_patch_bridge -- sample.sql tokyonight-dark sql --theme-bg"
261    );
262}
263
264/// Emits a VT patch for a highlighted source file.
265///
266/// When `--prev` is provided, the previous file seeds renderer state so output
267/// contains only the delta from previous to current content.
268///
269/// Mode selection:
270/// - no `--origin-row`: `StreamLineRenderer` (falls back to full render for multiline snapshots)
271/// - with `--origin-row`: `IncrementalRenderer`
272///
273/// # Errors
274///
275/// Returns an error when file IO, theme loading, or highlighting fails.
276fn main() -> Result<(), Box<dyn Error>> {
277    let args: Vec<String> = env::args().collect();
278    let options = match parse_args(&args) {
279        Ok(options) => options,
280        Err(err) => {
281            eprintln!("invalid arguments: {err}");
282            eprintln!();
283            print_usage();
284            return Ok(());
285        }
286    };
287
288    let grammar =
289        parse_grammar(&options.grammar_name).map_err(|msg| format!("invalid grammar: {msg}"))?;
290    let source = fs::read(&options.source_path)?;
291    let previous_source = if let Some(previous_source_path) = &options.previous_source_path {
292        Some(fs::read(previous_source_path)?)
293    } else {
294        None
295    };
296    let theme = load_theme(&options.theme_name)?;
297    let mut highlighter = SpanHighlighter::new()?;
298
299    let patch = if let Some(origin_row) = options.origin_row {
300        let mut renderer = IncrementalRenderer::new(options.width, options.height);
301        renderer.set_origin(origin_row, options.origin_col);
302        renderer.set_color_mode(options.color_mode);
303        renderer.set_preserve_terminal_background(options.preserve_terminal_background);
304
305        if let Some(previous_source) = previous_source.as_deref() {
306            let _ =
307                renderer.highlight_to_patch(&mut highlighter, previous_source, grammar, &theme)?;
308        }
309
310        renderer.highlight_to_patch(&mut highlighter, &source, grammar, &theme)?
311    } else {
312        let source_is_multiline = source.contains(&b'\n');
313        let previous_is_multiline = previous_source
314            .as_ref()
315            .is_some_and(|snapshot| snapshot.contains(&b'\n'));
316
317        if source_is_multiline || previous_is_multiline {
318            let rendered = highlight_to_ansi_with_highlighter_and_mode_and_background(
319                &mut highlighter,
320                &source,
321                grammar,
322                &theme,
323                options.color_mode,
324                options.preserve_terminal_background,
325            )?;
326            build_full_rerender_patch(rendered, previous_source.as_deref())
327        } else {
328            let mut renderer = StreamLineRenderer::new();
329            renderer.set_color_mode(options.color_mode);
330            renderer.set_preserve_terminal_background(options.preserve_terminal_background);
331
332            if let Some(previous_source) = previous_source.as_deref() {
333                let _ = renderer
334                    .highlight_line_to_patch(&mut highlighter, previous_source, grammar, &theme)
335                    .map_err(streamline_mode_error)?;
336            }
337
338            renderer
339                .highlight_line_to_patch(&mut highlighter, &source, grammar, &theme)
340                .map_err(streamline_mode_error)?
341        }
342    };
343
344    print!("{patch}");
345    io::stdout().flush()?;
346
347    Ok(())
348}