Skip to main content

hwatch_diffmode/
lib.rs

1// Copyright (c) 2025 Blacknon. All rights reserved.
2// Use of this source code is governed by an MIT license
3// that can be found in the LICENSE file.
4
5extern crate ratatui as tui;
6
7// local crate
8// extern crate hwatch_ansi as ansi;
9
10use ansi_term::Colour;
11use std::ffi::c_char;
12use std::fmt::Write;
13use std::sync::{Arc, Mutex};
14use std::{borrow::Cow, vec};
15
16use tui::{prelude::Line, style::Color};
17
18// const
19pub const COLOR_BATCH_LINE_NUMBER_DEFAULT: Colour = Colour::Fixed(240);
20pub const COLOR_BATCH_LINE_NUMBER_ADD: Colour = Colour::RGB(56, 119, 120);
21pub const COLOR_BATCH_LINE_NUMBER_REM: Colour = Colour::RGB(118, 0, 0);
22pub const COLOR_BATCH_LINE_ADD: Colour = Colour::Green;
23pub const COLOR_BATCH_LINE_REM: Colour = Colour::Red;
24pub const COLOR_BATCH_LINE_REVERSE_FG: Colour = Colour::White;
25pub const COLOR_WATCH_LINE_NUMBER_DEFAULT: Color = Color::DarkGray;
26pub const COLOR_WATCH_LINE_NUMBER_ADD: Color = Color::Rgb(56, 119, 120);
27pub const COLOR_WATCH_LINE_NUMBER_REM: Color = Color::Rgb(118, 0, 0);
28pub const COLOR_WATCH_LINE_ADD: Color = Color::Green;
29pub const COLOR_WATCH_LINE_REM: Color = Color::Red;
30pub const COLOR_WATCH_LINE_REVERSE_FG: Color = Color::White;
31pub const PLUGIN_ABI_VERSION: u32 = 2;
32pub const PLUGIN_ABI_VERSION_V1: u32 = 1;
33pub const PLUGIN_OUTPUT_BATCH: u32 = 0;
34pub const PLUGIN_OUTPUT_WATCH: u32 = 1;
35
36// type
37pub type DiffModeMutex = Arc<Mutex<Box<dyn DiffMode>>>;
38
39#[repr(C)]
40#[derive(Clone, Copy)]
41pub struct PluginSlice {
42    pub ptr: *const u8,
43    pub len: usize,
44}
45
46#[repr(C)]
47#[derive(Clone, Copy)]
48pub struct PluginOwnedBytes {
49    pub ptr: *mut u8,
50    pub len: usize,
51    pub cap: usize,
52}
53
54#[repr(C)]
55#[derive(Clone, Copy)]
56pub struct PluginDiffRequestV1 {
57    pub dest: PluginSlice,
58    pub src: PluginSlice,
59    pub output_kind: u32,
60    pub color: bool,
61    pub line_number: bool,
62    pub only_diffline: bool,
63}
64
65#[repr(C)]
66#[derive(Clone, Copy)]
67pub struct PluginDiffRequest {
68    pub dest: PluginSlice,
69    pub src: PluginSlice,
70    pub output_kind: u32,
71    pub color: bool,
72    pub line_number: bool,
73    pub only_diffline: bool,
74    pub ignore_spaceblock: bool,
75}
76
77#[repr(C)]
78#[derive(Clone, Copy)]
79pub struct PluginMetadata {
80    pub abi_version: u32,
81    pub supports_only_diffline: bool,
82    pub plugin_name: *const c_char,
83    pub header_text: *const c_char,
84}
85
86// OutputVecData is ...
87pub enum OutputVecData<'a> {
88    Lines(Vec<Line<'a>>),
89    Strings(Vec<String>),
90}
91
92// OutputVecElementData is ...
93pub enum OutputVecElementData<'a> {
94    Line(Line<'a>),
95    String(String),
96    None(),
97}
98
99// DifferenceType is ...
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub enum DifferenceType {
102    Same,
103    Add,
104    Rem,
105}
106
107pub struct DiffRow<'a> {
108    pub watch_line: Line<'a>,
109    pub batch_line: String,
110    pub line_number: Option<usize>,
111    pub diff_type: DifferenceType,
112}
113
114// TODO: headerで出力する文字列取得用のMethodを追加する
115// TODO: output onlyに対応しているかどうかを出力するMethodを追加する
116
117pub trait StringExt {
118    fn expand_tabs(&self, tab_size: u16) -> Cow<'_, str>;
119}
120
121impl<T> StringExt for T
122where
123    T: AsRef<str>,
124{
125    fn expand_tabs(&self, tab_size: u16) -> Cow<'_, str> {
126        let s = self.as_ref();
127        let tab = '\t';
128
129        if s.contains(tab) {
130            let mut res = String::new();
131            let mut last_pos = 0;
132
133            while let Some(pos) = &s[last_pos..].find(tab) {
134                res.push_str(&s[last_pos..*pos + last_pos]);
135
136                let spaces_to_add = if tab_size != 0 {
137                    tab_size - (*pos as u16 % tab_size)
138                } else {
139                    0
140                };
141
142                if spaces_to_add != 0 {
143                    let _ = write!(res, "{:width$}", "", width = spaces_to_add as usize);
144                }
145
146                last_pos += *pos + 1;
147            }
148
149            res.push_str(&s[last_pos..]);
150
151            Cow::from(res)
152        } else {
153            Cow::from(s)
154        }
155    }
156}
157
158/// DiffModeOptions
159#[derive(Debug, Clone, Copy)]
160pub struct DiffModeOptions {
161    //
162    color: bool,
163
164    //
165    line_number: bool,
166
167    //
168    only_diffline: bool,
169
170    //
171    ignore_spaceblock: bool,
172}
173
174impl DiffModeOptions {
175    pub fn new() -> Self {
176        Self {
177            color: false,
178            line_number: false,
179            only_diffline: false,
180            ignore_spaceblock: false,
181        }
182    }
183
184    pub fn get_color(&self) -> bool {
185        self.color
186    }
187
188    pub fn set_color(&mut self, color: bool) {
189        self.color = color;
190    }
191
192    pub fn get_line_number(&self) -> bool {
193        self.line_number
194    }
195
196    pub fn set_line_number(&mut self, line_number: bool) {
197        self.line_number = line_number;
198    }
199
200    pub fn get_only_diffline(&self) -> bool {
201        self.only_diffline
202    }
203
204    pub fn set_only_diffline(&mut self, only_diffline: bool) {
205        self.only_diffline = only_diffline;
206    }
207
208    pub fn get_ignore_spaceblock(&self) -> bool {
209        self.ignore_spaceblock
210    }
211
212    pub fn set_ignore_spaceblock(&mut self, ignore_spaceblock: bool) {
213        self.ignore_spaceblock = ignore_spaceblock;
214    }
215}
216
217impl Default for DiffModeOptions {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223/// DiffMode
224pub trait DiffMode: Send {
225    // generate and return diff watch window result.
226    fn generate_watch_diff(&mut self, dest: &str, src: &str) -> Vec<Line<'static>>;
227
228    // generate and return diff batch result.
229    fn generate_batch_diff(&mut self, dest: &str, src: &str) -> Vec<String>;
230
231    // get header's text
232    fn get_header_text(&self) -> String;
233
234    // get support only diffline
235    fn get_support_only_diffline(&self) -> bool;
236
237    // オプション指定用function
238    fn set_option(&mut self, options: DiffModeOptions);
239}
240
241/// get_option add DiffMode
242pub trait DiffModeExt: DiffMode {
243    fn get_option<T: 'static>(&self) -> DiffModeOptions;
244
245    fn get_header_width<T: 'static>(&self) -> usize;
246}
247
248pub fn expand_line_tab(data: &str, tab_size: u16) -> String {
249    let mut result_vec: Vec<String> = vec![];
250    for d in data.lines() {
251        let l = d.expand_tabs(tab_size).to_string();
252        result_vec.push(l);
253    }
254
255    result_vec.join("\n")
256}
257
258pub fn normalize_space_blocks(data: &str) -> String {
259    let mut normalized = String::with_capacity(data.len());
260    let mut in_spaceblock = false;
261
262    for ch in data.chars() {
263        if ch == '\n' {
264            normalized.push('\n');
265            in_spaceblock = false;
266            continue;
267        }
268
269        if ch.is_whitespace() {
270            if !in_spaceblock {
271                normalized.push(' ');
272                in_spaceblock = true;
273            }
274        } else {
275            normalized.push(ch);
276            in_spaceblock = false;
277        }
278    }
279
280    normalized
281}
282
283pub fn text_eq_ignoring_space_blocks(left: &str, right: &str, enabled: bool) -> bool {
284    if !enabled {
285        return left == right;
286    }
287
288    normalize_space_blocks(left) == normalize_space_blocks(right)
289}
290
291pub fn gen_counter_str(
292    is_color: bool,
293    counter: usize,
294    header_width: usize,
295    diff_type: DifferenceType,
296) -> String {
297    let mut counter_str = counter.to_string();
298    let mut seprator = " | ".to_string();
299    let mut prefix_width = 0;
300    let mut suffix_width = 0;
301
302    if is_color {
303        let style: ansi_term::Style = match diff_type {
304            DifferenceType::Same => ansi_term::Style::default().fg(COLOR_BATCH_LINE_NUMBER_DEFAULT),
305            DifferenceType::Add => ansi_term::Style::default().fg(COLOR_BATCH_LINE_NUMBER_ADD),
306            DifferenceType::Rem => ansi_term::Style::default().fg(COLOR_BATCH_LINE_NUMBER_REM),
307        };
308        counter_str = style.paint(counter_str).to_string();
309        seprator = style.paint(seprator).to_string();
310        prefix_width = style.prefix().to_string().len();
311        suffix_width = style.suffix().to_string().len();
312    }
313
314    let width = header_width + prefix_width + suffix_width;
315    format!("{counter_str:>width$}{seprator}")
316}
317
318pub fn expand_output_vec_element_data(
319    is_batch: bool,
320    data: Vec<OutputVecElementData>,
321) -> OutputVecData {
322    let mut lines = Vec::new();
323    let mut strings = Vec::new();
324
325    for element in data {
326        match element {
327            OutputVecElementData::Line(line) => {
328                lines.push(line);
329            }
330            OutputVecElementData::String(string) => {
331                strings.push(string);
332            }
333            _ => {}
334        }
335    }
336
337    if is_batch {
338        OutputVecData::Strings(strings)
339    } else {
340        OutputVecData::Lines(lines)
341    }
342}
343
344pub fn render_diff_rows_as_watch<'a>(
345    rows: Vec<DiffRow<'a>>,
346    is_line_number: bool,
347    header_width: usize,
348) -> Vec<Line<'a>> {
349    rows.into_iter()
350        .map(|mut row| {
351            if is_line_number {
352                let style = tui::style::Style::default().fg(match row.diff_type {
353                    DifferenceType::Same => COLOR_WATCH_LINE_NUMBER_DEFAULT,
354                    DifferenceType::Add => COLOR_WATCH_LINE_NUMBER_ADD,
355                    DifferenceType::Rem => COLOR_WATCH_LINE_NUMBER_REM,
356                });
357                let prefix = match row.line_number {
358                    Some(line_number) => format!("{line_number:>header_width$} | "),
359                    None => format!("{:>header_width$} | ", ""),
360                };
361                row.watch_line
362                    .spans
363                    .insert(0, tui::text::Span::styled(prefix, style));
364            }
365            row.watch_line
366        })
367        .collect()
368}
369
370pub fn render_diff_rows_as_batch<'a>(
371    rows: Vec<DiffRow<'a>>,
372    is_color: bool,
373    is_line_number: bool,
374    header_width: usize,
375) -> Vec<String> {
376    rows.into_iter()
377        .map(|row| {
378            if is_line_number {
379                match row.line_number {
380                    Some(line_number) => format!(
381                        "{}{}",
382                        gen_counter_str(is_color, line_number, header_width, row.diff_type),
383                        row.batch_line
384                    ),
385                    None => format!("{:>header_width$} | {}", "", row.batch_line),
386                }
387            } else {
388                row.batch_line
389            }
390        })
391        .collect()
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use tui::text::Span;
398
399    #[test]
400    fn expand_tabs_replaces_tabs_with_spaces() {
401        assert_eq!("a\tb".expand_tabs(4), "a   b");
402    }
403
404    #[test]
405    fn expand_tabs_with_zero_tab_size_removes_tab_padding() {
406        assert_eq!("a\tb".expand_tabs(0), "ab");
407    }
408
409    #[test]
410    fn diff_mode_options_round_trip_each_flag() {
411        let mut options = DiffModeOptions::new();
412        options.set_color(true);
413        options.set_line_number(true);
414        options.set_only_diffline(true);
415        options.set_ignore_spaceblock(true);
416
417        assert!(options.get_color());
418        assert!(options.get_line_number());
419        assert!(options.get_only_diffline());
420        assert!(options.get_ignore_spaceblock());
421    }
422
423    #[test]
424    fn expand_line_tab_expands_each_line_independently() {
425        assert_eq!(expand_line_tab("a\tb\n12\tc", 4), "a   b\n12  c");
426    }
427
428    #[test]
429    fn normalize_space_blocks_collapses_runs_per_line() {
430        assert_eq!(
431            normalize_space_blocks("a  b\t\tc\n  d    e\n"),
432            "a b c\n d e\n"
433        );
434    }
435
436    #[test]
437    fn text_eq_ignoring_space_blocks_matches_equivalent_text() {
438        assert!(text_eq_ignoring_space_blocks("a  b", "a   b", true));
439        assert!(text_eq_ignoring_space_blocks("a\tb", "a    b", true));
440        assert!(!text_eq_ignoring_space_blocks("ab", "a b", true));
441        assert!(!text_eq_ignoring_space_blocks("a  b", "a   b", false));
442    }
443
444    #[test]
445    fn gen_counter_str_without_color_is_plain_text() {
446        assert_eq!(
447            gen_counter_str(false, 12, 4, DifferenceType::Same),
448            "  12 | "
449        );
450    }
451
452    #[test]
453    fn gen_counter_str_with_color_wraps_output_in_ansi_sequences() {
454        let counter = gen_counter_str(true, 7, 3, DifferenceType::Add);
455
456        assert!(counter.contains("\u{1b}["));
457        assert!(counter.contains("7"));
458        assert!(counter.ends_with(" | \u{1b}[0m"));
459    }
460
461    #[test]
462    fn expand_output_vec_element_data_returns_batch_strings() {
463        let output = expand_output_vec_element_data(
464            true,
465            vec![
466                OutputVecElementData::String("first".to_string()),
467                OutputVecElementData::Line(Line::from(vec![Span::raw("ignored")])),
468                OutputVecElementData::String("second".to_string()),
469            ],
470        );
471
472        match output {
473            OutputVecData::Strings(strings) => {
474                assert_eq!(strings, vec!["first".to_string(), "second".to_string()]);
475            }
476            OutputVecData::Lines(_) => panic!("expected string output"),
477        }
478    }
479
480    #[test]
481    fn expand_output_vec_element_data_returns_watch_lines() {
482        let output = expand_output_vec_element_data(
483            false,
484            vec![
485                OutputVecElementData::String("ignored".to_string()),
486                OutputVecElementData::Line(Line::from("watch line")),
487            ],
488        );
489
490        match output {
491            OutputVecData::Lines(lines) => {
492                assert_eq!(lines.len(), 1);
493                assert_eq!(lines[0].spans[0].content.as_ref(), "watch line");
494            }
495            OutputVecData::Strings(_) => panic!("expected line output"),
496        }
497    }
498}