Skip to main content

nvrim/plugins/
genconv.rs

1//! General conversions helpers for the current Visual selection.
2//!
3//! Provides a namespaced [`Dictionary`] exposing selection conversion
4//! functionality (RGB to HEX and date/time to chrono parse code).
5
6use std::str::Split;
7
8use chrono::DateTime;
9use chrono::NaiveDate;
10use chrono::NaiveDateTime;
11use chrono::NaiveTime;
12use nvim_oxi::Dictionary;
13use rootcause::prelude::ResultExt;
14use rootcause::report;
15use strum::EnumIter;
16use strum::IntoEnumIterator;
17
18/// Namespaced dictionary of general conversion helpers.
19///
20/// Entries:
21/// - `"convert_selection"`: wraps [`convert_selection`] and converts the active Visual selection using a user-selected
22///   conversion option.
23pub fn dict() -> Dictionary {
24    dict! {
25        "convert_selection": fn_from!(convert_selection),
26    }
27}
28
29/// Converts the current visual selection using a user-chosen conversion option.
30///
31/// Prompts the user (via [`ytil_noxi::vim_ui_select::open`]) to select a conversion
32/// option, then applies the conversion to the selected text in place.
33///
34/// Returns early if:
35/// - No active Visual selection is detected.
36/// - The user cancels the prompt.
37/// - The conversion fails (an error is reported via [`ytil_noxi::notify::error`]).
38/// - Writing the converted text back to the buffer fails (an error is reported via [`ytil_noxi::notify::error`]).
39///
40/// # Errors
41/// Errors from [`ytil_noxi::vim_ui_select::open`] are reported via [`ytil_noxi::notify::error`]
42/// using the direct display representation of [`rootcause::Report`].
43/// Conversion errors are also reported similarly.
44///
45/// # Notes
46/// Currently supports single-line selections; multiline could be added later.
47fn convert_selection(_: ()) {
48    let Some(selection) = ytil_noxi::visual_selection::get(()) else {
49        return;
50    };
51
52    let opts = ConversionOption::iter();
53
54    let callback = {
55        let opts = opts.clone();
56        move |choice_idx| {
57            let Some(opt) = opts.get(choice_idx) else { return };
58            let Ok(transformed_line) = opt
59                    // Conversion should work only with 1 single line but maybe multiline could be
60                    // supported at some point.
61                    .convert(&selection.lines().to_vec().join("\n"))
62                    .inspect_err(|err| {
63                        ytil_noxi::notify::error(format!(
64                            "error setting lines of buffer | start={:#?} end={:#?} error={err:#?}",
65                            selection.start(),
66                            selection.end()
67                        ));
68                    })
69            else {
70                return;
71            };
72            ytil_noxi::buffer::replace_text_and_notify_if_error(&selection, vec![transformed_line]);
73        }
74    };
75
76    if let Err(err) = ytil_noxi::vim_ui_select::open(opts, &[("prompt", "Select conversion ")], callback, None) {
77        ytil_noxi::notify::error(format!("error converting selection | error={err:#?}"));
78    }
79}
80
81/// Enum representing available conversion options.
82#[derive(strum::Display, EnumIter)]
83enum ConversionOption {
84    /// Converts RGB color values to hexadecimal format.
85    #[strum(to_string = "RGB to HEX")]
86    RgbToHex,
87    /// Converts date/time strings to chrono `parse_from_str` code.
88    #[strum(to_string = "Datetime formatted strings to chrono parse_from_str code")]
89    DateTimeStrToChronoParseFromStr,
90    #[strum(to_string = "Unix timestamp to ISO 8601 date time")]
91    UnixTimestampToIso8601,
92}
93
94impl ConversionOption {
95    pub fn convert(&self, selection: &str) -> rootcause::Result<String> {
96        match self {
97            Self::RgbToHex => rgb_to_hex(selection),
98            Self::DateTimeStrToChronoParseFromStr => date_time_str_to_chrono_parse_from_str(selection),
99            Self::UnixTimestampToIso8601 => unix_timestamp_to_iso_8601_date_time(selection),
100        }
101    }
102}
103
104/// Converts an RGB string to a hexadecimal color code.
105///
106/// Expects an input in the format of [`u8`] R, G, B values.
107/// Whitespaces around components are trimmed.
108///
109/// # Errors
110/// Returns an error if the input format is invalid or components cannot be parsed as u8.
111fn rgb_to_hex(input: &str) -> rootcause::Result<String> {
112    fn u8_color_code_from_rgb_split(rgb: &mut Split<'_, char>, color: &str) -> rootcause::Result<u8> {
113        let s = rgb.next().ok_or_else(|| report!("missing color component {color}"))?;
114        Ok(s.trim()
115            .parse::<u8>()
116            .context("cannot parse str as u8 color code")
117            .attach_with(|| format!("str={s:?}"))?)
118    }
119
120    let mut rgb_split = input.split(',');
121    let r = u8_color_code_from_rgb_split(&mut rgb_split, "R")?;
122    let g = u8_color_code_from_rgb_split(&mut rgb_split, "G")?;
123    let b = u8_color_code_from_rgb_split(&mut rgb_split, "B")?;
124
125    Ok(format!("#{r:02x}{g:02x}{b:02x}"))
126}
127
128/// Converts a date/time string to the appropriate chrono `parse_from_str` code snippet.
129///
130/// Attempts to parse the input with various chrono types and formats:
131/// - [`DateTime`] with offset
132/// - [`NaiveDateTime`]
133/// - [`NaiveDate`]
134/// - [`NaiveTime`]
135///
136/// # Errors
137/// Returns an error if the input cannot be parsed with any supported format.
138fn date_time_str_to_chrono_parse_from_str(input: &str) -> rootcause::Result<String> {
139    if DateTime::parse_from_str(input, "%d-%m-%Y,%H:%M:%S%z").is_ok() {
140        return Ok(format!(
141            r#"DateTime::parse_from_str("{input}", "%d-%m-%Y,%H:%M:%S%Z").unwrap()"#
142        ));
143    }
144    if NaiveDateTime::parse_from_str(input, "%d-%m-%Y,%H:%M:%S").is_ok() {
145        return Ok(format!(
146            r#"NaiveDateTime::parse_from_str("{input}", "%d-%m-%Y,%H:%M:%S").unwrap()"#
147        ));
148    }
149    if NaiveDate::parse_from_str(input, "%d-%m-%Y").is_ok() {
150        return Ok(format!(r#"NaiveDate::parse_from_str("{input}", "%d-%m-%Y").unwrap()"#));
151    }
152    if NaiveTime::parse_from_str(input, "%H:%M:%S").is_ok() {
153        return Ok(format!(r#"NaiveTime::parse_from_str("{input}", "%H:%M:%S").unwrap()"#));
154    }
155    Err(report!("cannot get chrono parse_from_str for supplied input").attach(format!("input={input:?}")))
156}
157
158fn unix_timestamp_to_iso_8601_date_time(input: &str) -> rootcause::Result<String> {
159    let timestamp = input
160        .parse::<i64>()
161        .context("cannot convert input to i64")
162        .attach_with(|| format!("input={input:?}"))?;
163    let dt = DateTime::from_timestamp_secs(timestamp)
164        .ok_or_else(|| report!("cannot convert timestamp to DateTime<Utc>"))
165        .attach_with(|| format!("timestamp={timestamp}"))?;
166    Ok(dt.to_rfc3339())
167}
168
169#[cfg(test)]
170mod tests {
171    use rstest::rstest;
172
173    use super::*;
174
175    #[rstest]
176    #[case::red("255,0,0", "#ff0000")]
177    #[case::red_with_spaces(" 255 , 0 , 0 ", "#ff0000")]
178    #[case::black("0,0,0", "#000000")]
179    #[case::white("255,255,255", "#ffffff")]
180    #[case::red_with_extra_component("255,0,0,123", "#ff0000")]
181    fn rgb_to_hex_when_valid_rgb_returns_hex(#[case] input: &str, #[case] expected: &str) {
182        assert2::assert!(let Ok(actual) = rgb_to_hex(input));
183        pretty_assertions::assert_eq!(actual, expected);
184    }
185
186    #[rstest]
187    #[case::empty_input("", "cannot parse str as u8 color code")]
188    #[case::single_component("0", "missing color component G")]
189    #[case::two_components("255,0", "missing color component B")]
190    #[case::out_of_range_red("256,0,0", "cannot parse str as u8 color code")]
191    #[case::invalid_green("255,abc,0", "cannot parse str as u8 color code")]
192    #[case::invalid_blue("255,0,def", "cannot parse str as u8 color code")]
193    fn rgb_to_hex_when_invalid_input_returns_error(#[case] input: &str, #[case] expected_ctx: &str) {
194        assert2::assert!(let Err(err) = rgb_to_hex(input));
195        assert_eq!(err.format_current_context().to_string(), expected_ctx);
196    }
197
198    #[rstest]
199    #[case::datetime_with_offset(
200        "25-12-2023,14:30:45+00:00",
201        r#"DateTime::parse_from_str("25-12-2023,14:30:45+00:00", "%d-%m-%Y,%H:%M:%S%Z").unwrap()"#
202    )]
203    #[case::naive_datetime(
204        "25-12-2023,14:30:45",
205        r#"NaiveDateTime::parse_from_str("25-12-2023,14:30:45", "%d-%m-%Y,%H:%M:%S").unwrap()"#
206    )]
207    #[case::naive_date("25-12-2023", r#"NaiveDate::parse_from_str("25-12-2023", "%d-%m-%Y").unwrap()"#)]
208    #[case::naive_time("14:30:45", r#"NaiveTime::parse_from_str("14:30:45", "%H:%M:%S").unwrap()"#)]
209    fn date_time_str_to_chrono_parse_from_str_when_valid_input_returns_correct_code(
210        #[case] input: &str,
211        #[case] expected: &str,
212    ) {
213        assert2::assert!(let Ok(actual) = date_time_str_to_chrono_parse_from_str(input));
214        pretty_assertions::assert_eq!(actual, expected);
215    }
216
217    #[test]
218    fn date_time_str_to_chrono_parse_from_str_when_invalid_input_returns_error() {
219        assert2::assert!(let Err(err) = date_time_str_to_chrono_parse_from_str("invalid"));
220        assert_eq!(
221            err.format_current_context().to_string(),
222            "cannot get chrono parse_from_str for supplied input"
223        );
224    }
225
226    #[rstest]
227    #[case::non_numeric_input("abc", "cannot convert input to i64")]
228    #[case::empty_input("", "cannot convert input to i64")]
229    fn unix_timestamp_to_iso_8601_date_time_when_invalid_input_returns_error(
230        #[case] input: &str,
231        #[case] expected_ctx: &str,
232    ) {
233        assert2::assert!(let Err(err) = unix_timestamp_to_iso_8601_date_time(input));
234        assert_eq!(err.format_current_context().to_string(), expected_ctx);
235    }
236}