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 color_eyre::eyre::Context;
13use color_eyre::eyre::eyre;
14use nvim_oxi::Dictionary;
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 [`color_eyre::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) -> color_eyre::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) -> color_eyre::Result<String> {
112    fn u8_color_code_from_rgb_split(rgb: &mut Split<'_, char>, color: &str) -> color_eyre::Result<u8> {
113        rgb.next()
114            .ok_or_else(|| eyre!("missing color component {color}"))
115            .and_then(|s| {
116                s.trim()
117                    .parse::<u8>()
118                    .wrap_err_with(|| format!("cannot parse str as u8 color code | str={s:?}"))
119            })
120    }
121
122    let mut rgb_split = input.split(',');
123    let r = u8_color_code_from_rgb_split(&mut rgb_split, "R")?;
124    let g = u8_color_code_from_rgb_split(&mut rgb_split, "G")?;
125    let b = u8_color_code_from_rgb_split(&mut rgb_split, "B")?;
126
127    Ok(format!("#{r:02x}{g:02x}{b:02x}"))
128}
129
130/// Converts a date/time string to the appropriate chrono `parse_from_str` code snippet.
131///
132/// Attempts to parse the input with various chrono types and formats:
133/// - [`DateTime`] with offset
134/// - [`NaiveDateTime`]
135/// - [`NaiveDate`]
136/// - [`NaiveTime`]
137///
138/// # Errors
139/// Returns an error if the input cannot be parsed with any supported format.
140fn date_time_str_to_chrono_parse_from_str(input: &str) -> color_eyre::Result<String> {
141    if DateTime::parse_from_str(input, "%d-%m-%Y,%H:%M:%S%z").is_ok() {
142        return Ok(format!(
143            r#"DateTime::parse_from_str("{input}", "%d-%m-%Y,%H:%M:%S%Z").unwrap()"#
144        ));
145    }
146    if NaiveDateTime::parse_from_str(input, "%d-%m-%Y,%H:%M:%S").is_ok() {
147        return Ok(format!(
148            r#"NaiveDateTime::parse_from_str("{input}", "%d-%m-%Y,%H:%M:%S").unwrap()"#
149        ));
150    }
151    if NaiveDate::parse_from_str(input, "%d-%m-%Y").is_ok() {
152        return Ok(format!(r#"NaiveDate::parse_from_str("{input}", "%d-%m-%Y").unwrap()"#));
153    }
154    if NaiveTime::parse_from_str(input, "%H:%M:%S").is_ok() {
155        return Ok(format!(r#"NaiveTime::parse_from_str("{input}", "%H:%M:%S").unwrap()"#));
156    }
157    Err(eyre!(
158        "cannot get chrono parse_from_str for supplied input | input={input:?}"
159    ))
160}
161
162fn unix_timestamp_to_iso_8601_date_time(input: &str) -> color_eyre::Result<String> {
163    input
164        .parse::<i64>()
165        .wrap_err_with(|| format!("cannot convert input to i64 | input={input:?}"))
166        .and_then(|timestamp| {
167            DateTime::from_timestamp_secs(timestamp)
168                .ok_or_else(|| eyre!("cannot convert timestamp to DateTime<Utc> | timestamp={timestamp}"))
169        })
170        .map(|dt| dt.to_rfc3339())
171}
172
173#[cfg(test)]
174mod tests {
175    use rstest::rstest;
176
177    use super::*;
178
179    #[rstest]
180    #[case::red("255,0,0", "#ff0000")]
181    #[case::red_with_spaces(" 255 , 0 , 0 ", "#ff0000")]
182    #[case::black("0,0,0", "#000000")]
183    #[case::white("255,255,255", "#ffffff")]
184    #[case::red_with_extra_component("255,0,0,123", "#ff0000")]
185    fn rgb_to_hex_when_valid_rgb_returns_hex(#[case] input: &str, #[case] expected: &str) {
186        assert2::let_assert!(Ok(actual) = rgb_to_hex(input));
187        pretty_assertions::assert_eq!(actual, expected);
188    }
189
190    #[rstest]
191    #[case::empty_input("", "cannot parse str as u8 color code | str=\"\"")]
192    #[case::single_component("0", "missing color component G")]
193    #[case::two_components("255,0", "missing color component B")]
194    #[case::out_of_range_red("256,0,0", "cannot parse str as u8 color code | str=\"256\"")]
195    #[case::invalid_green("255,abc,0", "cannot parse str as u8 color code | str=\"abc\"")]
196    #[case::invalid_blue("255,0,def", "cannot parse str as u8 color code | str=\"def\"")]
197    fn rgb_to_hex_when_invalid_input_returns_error(#[case] input: &str, #[case] expected_error: &str) {
198        let result = rgb_to_hex(input);
199        assert2::let_assert!(Err(err) = result);
200        pretty_assertions::assert_eq!(err.to_string(), expected_error);
201    }
202
203    #[rstest]
204    #[case::datetime_with_offset(
205        "25-12-2023,14:30:45+00:00",
206        r#"DateTime::parse_from_str("25-12-2023,14:30:45+00:00", "%d-%m-%Y,%H:%M:%S%Z").unwrap()"#
207    )]
208    #[case::naive_datetime(
209        "25-12-2023,14:30:45",
210        r#"NaiveDateTime::parse_from_str("25-12-2023,14:30:45", "%d-%m-%Y,%H:%M:%S").unwrap()"#
211    )]
212    #[case::naive_date("25-12-2023", r#"NaiveDate::parse_from_str("25-12-2023", "%d-%m-%Y").unwrap()"#)]
213    #[case::naive_time("14:30:45", r#"NaiveTime::parse_from_str("14:30:45", "%H:%M:%S").unwrap()"#)]
214    fn date_time_str_to_chrono_parse_from_str_when_valid_input_returns_correct_code(
215        #[case] input: &str,
216        #[case] expected: &str,
217    ) {
218        assert2::let_assert!(Ok(actual) = date_time_str_to_chrono_parse_from_str(input));
219        pretty_assertions::assert_eq!(actual, expected);
220    }
221
222    #[test]
223    fn date_time_str_to_chrono_parse_from_str_when_invalid_input_returns_error() {
224        assert2::let_assert!(Err(err) = date_time_str_to_chrono_parse_from_str("invalid"));
225        pretty_assertions::assert_eq!(
226            err.to_string(),
227            "cannot get chrono parse_from_str for supplied input | input=\"invalid\""
228        );
229    }
230
231    #[rstest]
232    #[case::non_numeric_input("abc", "cannot convert input to i64 | input=\"abc\"")]
233    #[case::empty_input("", "cannot convert input to i64 | input=\"\"")]
234    fn unix_timestamp_to_iso_8601_date_time_when_invalid_input_returns_error(
235        #[case] input: &str,
236        #[case] expected_error: &str,
237    ) {
238        let result = unix_timestamp_to_iso_8601_date_time(input);
239        assert2::let_assert!(Err(err) = result);
240        pretty_assertions::assert_eq!(err.to_string(), expected_error);
241    }
242}