vt_patch_bridge/
vt_patch_bridge.rs1use 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
27fn 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
38fn 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
54fn 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
65fn 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
76fn logical_line_count(source: &[u8]) -> usize {
78 source.iter().filter(|&&byte| byte == b'\n').count() + 1
79}
80
81fn 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
113fn 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
229fn 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
264fn 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}