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