Skip to main content

nvrim/buffer/
token_under_cursor.rs

1//! Token classification under cursor (URL / file / directory / word).
2//!
3//! Retrieves current line + cursor column, extracts contiguous non‑whitespace token, classifies via
4//! filesystem inspection or URL parsing, returning a tagged Lua table.
5
6use std::borrow::Cow;
7use std::cell::RefCell;
8use std::collections::HashMap;
9use std::ffi::c_int;
10
11use nvim_oxi::Object;
12use nvim_oxi::api::Buffer;
13use nvim_oxi::api::Window;
14use nvim_oxi::conversion::ToObject;
15use nvim_oxi::lua::ffi::State;
16use nvim_oxi::serde::Serializer;
17use rootcause::prelude::ResultExt;
18use rootcause::report;
19use serde::Serialize;
20use url::Url;
21use ytil_noxi::buffer::BufferExt;
22use ytil_noxi::buffer::CursorPosition;
23use ytil_sys::file::FileCmdOutput;
24use ytil_sys::lsof::ProcessFilter;
25
26/// Retrieve and classify the non-whitespace token under the cursor in the current window.
27///
28/// Returns [`Option::None`] if the current line or cursor position cannot be obtained,
29/// or if the cursor is on whitespace. On errors a notification is emitted to Nvim.
30/// On success returns a classified [`TokenUnderCursor`].
31pub fn get(_: ()) -> Option<TokenUnderCursor> {
32    let current_buffer = nvim_oxi::api::get_current_buf();
33    let cursor_pos = CursorPosition::get_current()?;
34
35    let token_under_cursor = if current_buffer.is_terminal() {
36        get_token_under_cursor_in_terminal_buffer(&current_buffer, &cursor_pos)
37    } else {
38        get_token_under_cursor_in_normal_buffer(&cursor_pos)
39    }
40    .as_deref()
41    .map(TokenUnderCursor::classify)?
42    .inspect_err(|err| ytil_noxi::notify::error(format!("error classifying word under cursor | error={err:?}")))
43    .ok()?;
44
45    let token_under_cursor = token_under_cursor
46        .refine_word(&current_buffer)
47        .inspect_err(|err| ytil_noxi::notify::error(format!("error refining word under cursor | error={err:?}")))
48        .ok()?;
49
50    Some(token_under_cursor)
51}
52
53/// Classified representation of the token found under the cursor.
54///
55/// Used to distinguish between:
56/// - URLs
57/// - existing binary files
58/// - existing text files
59/// - existing directories
60/// - plain tokens (fallback [`TokenUnderCursor::MaybeTextFile`])
61///
62/// Serialized to Lua as a tagged table (`{ kind = "...", value = "..." }`).
63#[derive(Clone, Debug, Serialize)]
64#[serde(tag = "kind", content = "value")]
65#[cfg_attr(test, derive(Eq, PartialEq))]
66pub enum TokenUnderCursor {
67    /// A string that successfully parsed as a [`Url`] via [`Url::parse`].
68    Url(String),
69    /// A filesystem path identified as a binary file by [`ytil_sys::file::exec_file_cmd`].
70    BinaryFile(String),
71    /// A filesystem path identified as a text file by [`ytil_sys::file::exec_file_cmd`].
72    TextFile {
73        path: String,
74        lnum: Option<i64>,
75        col: Option<i64>,
76    },
77    /// A filesystem path identified as a directory by [`ytil_sys::file::exec_file_cmd`].
78    Directory(String),
79    /// A fallback plain token (word) when no more specific classification applied.
80    MaybeTextFile {
81        value: String,
82        lnum: Option<i64>,
83        col: Option<i64>,
84    },
85}
86
87impl nvim_oxi::lua::Pushable for TokenUnderCursor {
88    unsafe fn push(self, lstate: *mut State) -> Result<c_int, nvim_oxi::lua::Error> {
89        // SAFETY: The caller (nvim-oxi framework) guarantees that:
90        // 1. `lstate` is a valid pointer to an initialized Lua state
91        // 2. The Lua stack has sufficient capacity for the pushed value
92        unsafe {
93            nvim_oxi::lua::Pushable::push(
94                self.to_object()
95                    .map_err(nvim_oxi::lua::Error::push_error_from_err::<Self, _>)?,
96                lstate,
97            )
98        }
99    }
100}
101
102impl ToObject for TokenUnderCursor {
103    fn to_object(self) -> Result<Object, nvim_oxi::conversion::Error> {
104        self.serialize(Serializer::new()).map_err(Into::into)
105    }
106}
107
108/// Classify a [`String`] captured under the cursor into a [`TokenUnderCursor`].
109///
110/// 1. If it parses as a URL with [`Url::parse`], returns [`TokenUnderCursor::Url`].
111/// 2. Otherwise, invokes [`ytil_sys::file::exec_file_cmd`] to check filesystem type.
112/// 3. Falls back to [`TokenUnderCursor::MaybeTextFile`] on errors or unknown kinds.
113impl TokenUnderCursor {
114    fn classify(value: &str) -> rootcause::Result<Self> {
115        Self::classify_url(value).or_else(|_| Self::classify_not_url(value))
116    }
117
118    fn classify_url(value: &str) -> rootcause::Result<Self> {
119        let value = value
120            .trim_matches('"')
121            .trim_matches('`')
122            .trim_matches('\'')
123            .trim_start_matches('[')
124            .trim_end_matches(']')
125            .trim_start_matches('(')
126            .trim_end_matches(')')
127            .trim_start_matches('{')
128            .trim_end_matches('}');
129
130        let maybe_md_link = extract_markdown_link(value)
131            .or_else(|| extract_https_or_http_link(value))
132            .unwrap_or(value);
133
134        Ok(Url::parse(maybe_md_link).map(|_| Self::Url(maybe_md_link.to_string()))?)
135    }
136
137    fn classify_not_url(value: &str) -> rootcause::Result<Self> {
138        let mut parts = value.split(':');
139
140        let Some(maybe_path) = parts.next() else {
141            return Ok(Self::MaybeTextFile {
142                value: value.to_string(),
143                lnum: None,
144                col: None,
145            });
146        };
147
148        let lnum = parts.next().map(str::parse).transpose().ok().flatten();
149        let col = parts.next().map(str::parse).transpose().ok().flatten();
150
151        Ok(match exec_file_cmd_cached(maybe_path)? {
152            FileCmdOutput::BinaryFile(x) => Self::BinaryFile(x),
153            FileCmdOutput::TextFile(path) => Self::TextFile { path, lnum, col },
154            FileCmdOutput::Directory(x) => Self::Directory(x),
155            FileCmdOutput::NotFound(path) | FileCmdOutput::Unknown(path) => {
156                Self::MaybeTextFile { value: path, lnum, col }
157            }
158        })
159    }
160
161    fn refine_word(&self, buffer: &Buffer) -> rootcause::Result<Self> {
162        if let Self::MaybeTextFile { value, lnum, col } = self {
163            let pid = buffer.get_pid()?;
164
165            let mut lsof_res = ytil_sys::lsof::lsof(&ProcessFilter::Pid(&pid))?;
166
167            let Some(process_desc) = lsof_res.get_mut(0) else {
168                return Err(report!("error no process found for pid")).attach_with(|| format!("pid={pid:?}"));
169            };
170
171            let maybe_path = {
172                process_desc.cwd.push(value);
173                let mut tmp = process_desc.cwd.to_string_lossy().into_owned();
174                if let Some(lnum) = lnum {
175                    tmp.push(':');
176                    tmp.push_str(&lnum.to_string());
177                }
178                if let Some(col) = col {
179                    tmp.push(':');
180                    tmp.push_str(&col.to_string());
181                }
182                tmp
183            };
184
185            return Self::classify_not_url(&maybe_path);
186        }
187        Ok(self.clone())
188    }
189}
190
191thread_local! {
192    /// Cache `file -I` results to avoid spawning a process per cursor movement / hover.
193    /// Keyed by path string; only successful results are cached.
194    static FILE_CMD_CACHE: RefCell<HashMap<String, FileCmdOutput>> = RefCell::new(HashMap::new());
195}
196
197fn get_token_under_cursor_in_terminal_buffer(buffer: &Buffer, cursor_pos: &CursorPosition) -> Option<String> {
198    let window_width = Window::current()
199        .get_width()
200        .context("error getting window width")
201        .and_then(|x| {
202            usize::try_from(x)
203                .context("error converting window width to usize")
204                .attach_with(|| format!("width={x}"))
205        })
206        .inspect_err(|err| ytil_noxi::notify::error(format!("{err}")))
207        .ok()?
208        .saturating_sub(1);
209
210    // Pre-allocate with reasonable capacity for typical token lengths
211    let mut out = Vec::with_capacity(128);
212    let mut word_end_idx = 0;
213    for (idx, current_char) in ytil_noxi::buffer::get_current_line()?.char_indices() {
214        word_end_idx = idx;
215        if idx < cursor_pos.col {
216            if current_char.is_ascii_whitespace() {
217                out.clear();
218            } else {
219                out.push(current_char);
220            }
221        } else if idx > cursor_pos.col {
222            if current_char.is_ascii_whitespace() {
223                break;
224            }
225            out.push(current_char);
226        } else if current_char.is_ascii_whitespace() {
227            out.clear();
228            out.push(current_char);
229            break;
230        } else {
231            out.push(current_char);
232        }
233    }
234
235    // Check rows before the cursor one.
236    if word_end_idx.saturating_sub(out.len()) == 0 {
237        'outer: for idx in (0..cursor_pos.row.saturating_sub(1)).rev() {
238            // Use Cow<str> to avoid allocation when string is valid UTF-8
239            let line_bytes = buffer.get_line(idx).ok()?;
240            let line: Cow<'_, str> = line_bytes.to_string_lossy();
241            if line.is_empty() {
242                break 'outer;
243            }
244            if let Some((_, prev)) = line.rsplit_once(' ') {
245                out.splice(0..0, prev.chars());
246                break;
247            }
248            if line.chars().count() < window_width {
249                break;
250            }
251            out.splice(0..0, line.chars());
252        }
253    }
254
255    // Check rows after the cursor one.
256    if word_end_idx >= window_width {
257        'outer: for idx in cursor_pos.row..usize::MAX {
258            // Use Cow<str> to avoid allocation when string is valid UTF-8
259            let line_bytes = buffer.get_line(idx).ok()?;
260            let line: Cow<'_, str> = line_bytes.to_string_lossy();
261            if line.is_empty() {
262                break 'outer;
263            }
264            if let Some((next, _)) = line.split_once(' ') {
265                out.extend(next.chars());
266                break;
267            }
268            out.extend(line.chars());
269            if line.chars().count() < window_width {
270                break;
271            }
272        }
273    }
274
275    Some(out.into_iter().collect())
276}
277
278fn get_token_under_cursor_in_normal_buffer(cursor_pos: &CursorPosition) -> Option<String> {
279    let current_line = ytil_noxi::buffer::get_current_line()?;
280    get_word_at_index(&current_line, cursor_pos.col).map(ToOwned::to_owned)
281}
282
283/// Cached wrapper around [`ytil_sys::file::exec_file_cmd`] that avoids spawning a `file -I`
284/// process for previously seen paths. Only successful results are cached.
285fn exec_file_cmd_cached(path: &str) -> rootcause::Result<FileCmdOutput> {
286    FILE_CMD_CACHE.with(|cache| {
287        if let Some(cached) = cache.borrow().get(path).cloned() {
288            return Ok(cached);
289        }
290        let result = ytil_sys::file::exec_file_cmd(path)?;
291        cache.borrow_mut().insert(path.to_owned(), result.clone());
292        Ok(result)
293    })
294}
295
296/// Find the non-whitespace token in the supplied string `s` containing the visual index `idx`.
297///
298/// Returns [`Option::None`] if:
299/// - `idx` Is out of bounds.
300/// - `idx` Does not point to a character boundary.
301/// - The character at `idx` is whitespace
302fn get_word_at_index(s: &str, idx: usize) -> Option<&str> {
303    let byte_idx = convert_visual_to_byte_idx(s, idx)?;
304
305    // If pointing to whitespace, no word.
306    if s.get(byte_idx..)
307        .and_then(|tail| tail.chars().next())
308        .is_some_and(char::is_whitespace)
309    {
310        return None;
311    }
312
313    // Scan split words and see which span contains `byte_idx`.
314    let mut pos = 0;
315    for word in s.split_ascii_whitespace() {
316        let start = s.get(pos..)?.find(word)?.saturating_add(pos);
317        let end = start.saturating_add(word.len());
318        if (start..=end).contains(&byte_idx) {
319            return Some(word);
320        }
321        pos = end;
322    }
323    None
324}
325
326/// Convert a visual (character) index into a byte index for the supplied string `s`.
327///
328/// Returns:
329/// - [`Option::Some`] with the corresponding byte index (including `s.len()` for end-of-line)
330/// - [`Option::None`] if `idx` is past the end
331fn convert_visual_to_byte_idx(s: &str, idx: usize) -> Option<usize> {
332    let mut chars_seen = 0_usize;
333    let mut byte_idx = None;
334    for (b, _) in s.char_indices() {
335        if chars_seen == idx {
336            byte_idx = Some(b);
337            break;
338        }
339        chars_seen = chars_seen.saturating_add(1);
340    }
341    if byte_idx.is_some() {
342        return byte_idx;
343    }
344    if idx == chars_seen {
345        return Some(s.len());
346    }
347    None
348}
349
350fn extract_markdown_link(input: &str) -> Option<&str> {
351    let mid_idx = input.find("](")?;
352    let start_idx = mid_idx.saturating_add(2);
353
354    input.get(start_idx..)?.find(')').map_or_else(
355        || input.get(start_idx..),
356        |end_relative| input.get(start_idx..start_idx.saturating_add(end_relative)),
357    )
358}
359
360#[expect(
361    clippy::similar_names,
362    reason = "http and https index names mirror parsed URL schemes"
363)]
364fn extract_https_or_http_link(input: &str) -> Option<&str> {
365    let start_idx = match (input.find("https://"), input.find("http://")) {
366        (None, None) => None,
367        (None, Some(start_idx)) | (Some(start_idx), None) => Some(start_idx),
368        (Some(start_https_idx), Some(start_http_idx)) => Some(if start_https_idx <= start_http_idx {
369            start_https_idx
370        } else {
371            start_http_idx
372        }),
373    }?;
374    if let Some(end_idx) = input.find(' ') {
375        return input.get(start_idx..end_idx);
376    }
377    input.get(start_idx..)
378}
379
380#[cfg(test)]
381mod tests {
382    use rstest::*;
383    #[cfg(target_os = "macos")]
384    use tempfile::NamedTempFile;
385    #[cfg(target_os = "macos")]
386    use tempfile::TempDir;
387    use test_that::prelude::*;
388
389    use super::*;
390
391    #[rstest]
392    #[case("open file.txt now", 7, Some("file.txt"))]
393    #[case("yes run main.rs", 8, Some("main.rs"))]
394    #[case("yes run main.rs", 14, Some("main.rs"))]
395    #[case("hello  world", 5, None)]
396    #[case("hello  world", 6, None)]
397    #[case("/usr/local/bin", 0, Some("/usr/local/bin"))]
398    #[case("/usr/local/bin", 14, Some("/usr/local/bin"))]
399    #[case("print(arg)", 5, Some("print(arg)"))]
400    #[case("abc", 10, None)]
401    #[case("αβ γ", 0, Some("αβ"))]
402    #[case("αβ γ", 1, Some("αβ"))]
403    #[case("αβ γ", 4, Some("γ"))]
404    #[case("αβ γ", 5, None)]
405    #[case("hello\nworld", 0, Some("hello"))]
406    #[case("hello\nworld", 6, Some("world"))]
407    #[case("hello\nworld", 5, None)]
408    #[case("hello\n\nworld", 5, None)]
409    #[case("hello\n\nworld", 6, None)]
410    fn test_get_word_at_index_when_cursor_position_varies_returns_expected_word(
411        #[case] s: &str,
412        #[case] idx: usize,
413        #[case] expected: Option<&str>,
414    ) {
415        assert_that!(get_word_at_index(s, idx), eq(expected));
416    }
417
418    // Tests are skipped in CI because [`TokenUnderCursor::from`] calls `file` command and that
419    // behaves differently based on the platform (e.g. macOS vs Linux)
420
421    #[test]
422    #[cfg(target_os = "macos")]
423    fn test_token_under_cursor_classify_valid_url_returns_url() {
424        let input = "https://example.com".to_string();
425        assert_that!(TokenUnderCursor::classify(&input), ok(eq(TokenUnderCursor::Url(input))));
426    }
427
428    #[test]
429    #[cfg(target_os = "macos")]
430    fn test_token_under_cursor_classify_invalid_url_plain_word_returns_word() {
431        let input = "noturl".to_string();
432        assert_that!(
433            TokenUnderCursor::classify(&input),
434            ok(eq(TokenUnderCursor::MaybeTextFile {
435                value: input,
436                lnum: None,
437                col: None
438            }))
439        );
440    }
441
442    #[test]
443    #[cfg(target_os = "macos")]
444    fn test_token_under_cursor_classify_path_to_text_file_returns_text_file() {
445        let mut temp_file = NamedTempFile::new().unwrap();
446        std::io::Write::write_all(&mut temp_file, b"hello world").unwrap();
447        let path = temp_file.path().to_string_lossy().into_owned();
448        assert_that!(
449            TokenUnderCursor::classify(&path),
450            ok(eq(TokenUnderCursor::TextFile {
451                path,
452                lnum: None,
453                col: None
454            }))
455        );
456    }
457
458    #[test]
459    #[cfg(target_os = "macos")]
460    fn test_token_under_cursor_classify_path_lnum_to_text_file_returns_text_file_with_lnum() {
461        let mut temp_file = NamedTempFile::new().unwrap();
462        std::io::Write::write_all(&mut temp_file, b"hello world").unwrap();
463        let path = temp_file.path().to_string_lossy().into_owned();
464        assert_that!(
465            TokenUnderCursor::classify(&format!("{path}:10")),
466            ok(eq(TokenUnderCursor::TextFile {
467                path,
468                lnum: Some(10),
469                col: None
470            }))
471        );
472    }
473
474    #[test]
475    #[cfg(target_os = "macos")]
476    fn test_token_under_cursor_classify_path_lnum_col_to_text_file_returns_text_file_with_lnum_col() {
477        let mut temp_file = NamedTempFile::new().unwrap();
478        std::io::Write::write_all(&mut temp_file, b"hello world").unwrap();
479        let path = temp_file.path().to_string_lossy().into_owned();
480        assert_that!(
481            TokenUnderCursor::classify(&format!("{path}:10:5")),
482            ok(eq(TokenUnderCursor::TextFile {
483                path,
484                lnum: Some(10),
485                col: Some(5)
486            }))
487        );
488    }
489
490    #[test]
491    #[cfg(target_os = "macos")]
492    fn test_token_under_cursor_classify_path_to_directory_returns_directory() {
493        let temp_dir = TempDir::new().unwrap();
494        let path = temp_dir.path().to_string_lossy().into_owned();
495        assert_that!(
496            TokenUnderCursor::classify(&path),
497            ok(eq(TokenUnderCursor::Directory(path)))
498        );
499    }
500
501    #[test]
502    #[cfg(target_os = "macos")]
503    fn test_token_under_cursor_classify_path_to_binary_file_returns_binary_file() {
504        let mut temp_file = NamedTempFile::new().unwrap();
505        // Write some binary data
506        std::io::Write::write_all(&mut temp_file, &[0, 1, 2, 255]).unwrap();
507        let path = temp_file.path().to_string_lossy().into_owned();
508        assert_that!(
509            TokenUnderCursor::classify(&path),
510            ok(eq(TokenUnderCursor::BinaryFile(path)))
511        );
512    }
513
514    #[test]
515    #[cfg(target_os = "macos")]
516    fn test_token_under_cursor_classify_nonexistent_path_returns_maybe_text_file() {
517        let path = "/nonexistent/path".to_string();
518        assert_that!(
519            TokenUnderCursor::classify(&path),
520            ok(eq(TokenUnderCursor::MaybeTextFile {
521                value: path,
522                lnum: None,
523                col: None
524            }))
525        );
526    }
527
528    #[test]
529    #[cfg(target_os = "macos")]
530    fn test_token_under_cursor_classify_path_with_invalid_lnum_returns_maybe_text_file() {
531        let temp_file = NamedTempFile::new().unwrap();
532        let path = temp_file.path().to_string_lossy().into_owned();
533        let input = format!("{path}:invalid");
534        assert_that!(
535            TokenUnderCursor::classify(&input),
536            ok(eq(TokenUnderCursor::MaybeTextFile {
537                value: path,
538                lnum: None,
539                col: None
540            }))
541        );
542    }
543
544    #[test]
545    #[cfg(target_os = "macos")]
546    fn test_token_under_cursor_classify_path_with_invalid_col_returns_maybe_text_file() {
547        let temp_file = NamedTempFile::new().unwrap();
548        let path = temp_file.path().to_string_lossy().into_owned();
549        let input = format!("{path}:10:invalid");
550        assert_that!(
551            TokenUnderCursor::classify(&input),
552            ok(eq(TokenUnderCursor::MaybeTextFile {
553                value: path,
554                lnum: Some(10),
555                col: None
556            }))
557        );
558    }
559
560    #[test]
561    #[cfg(target_os = "macos")]
562    fn test_token_under_cursor_classify_path_lnum_col_extra_ignores_extra() {
563        let mut temp_file = NamedTempFile::new().unwrap();
564        std::io::Write::write_all(&mut temp_file, b"hello world").unwrap();
565        let path = temp_file.path().to_string_lossy().into_owned();
566        assert_that!(
567            TokenUnderCursor::classify(&format!("{path}:10:5:extra")),
568            ok(eq(TokenUnderCursor::TextFile {
569                path,
570                lnum: Some(10),
571                col: Some(5)
572            }))
573        );
574    }
575
576    #[rstest]
577    #[case("https://example.com", "https://example.com")]
578    #[case("http://example.com", "http://example.com")]
579    #[case("\"https://example.com\"", "https://example.com")]
580    #[case("`https://example.com`", "https://example.com")]
581    #[case("'https://example.com'", "https://example.com")]
582    #[case("{https://example.com}", "https://example.com")]
583    #[case("(https://example.com)", "https://example.com")]
584    #[case("[text](https://example.com)", "https://example.com")]
585    #[case("[[text]](https://example.com)", "https://example.com")]
586    #[case("https://example.com extra", "https://example.com")]
587    #[case("http://example.com with text", "http://example.com")]
588    #[case("(http://example.com)", "http://example.com")]
589    #[case("`http://example.com`", "http://example.com")]
590    fn test_classify_url_returns_the_token_url_under_curos(#[case] input: &str, #[case] expected_value: &str) {
591        assert_that!(
592            TokenUnderCursor::classify_url(input),
593            ok(eq(TokenUnderCursor::Url(expected_value.to_string())))
594        );
595    }
596
597    #[rstest]
598    #[case("not a url")]
599    #[case("[text](noturl)")]
600    fn test_classify_url_when_cannot_classify_url_returns_the_expected_error(#[case] input: &str) {
601        assert_that!(
602            TokenUnderCursor::classify_url(input).map(|_| ()),
603            err(result_of!(
604                |err: &rootcause::Report| err.downcast_current_context::<url::ParseError>(),
605                some(anything())
606            ))
607        );
608    }
609
610    #[rstest]
611    #[case("[hello](world)", Some("world"))]
612    #[case("[hello world](https://example.com)", Some("https://example.com"))]
613    #[case("[text](url with spaces)", Some("url with spaces"))]
614    #[case("[a](1)[b](2)", Some("1"))]
615    #[case("[hello]()", Some(""))]
616    #[case("[hello](world", Some("world"))]
617    #[case("hello](world)", Some("world"))]
618    #[case("hello](world", Some("world"))]
619    #[case("no link", None)]
620    #[case("[incomplete", None)]
621    #[case("](empty)", Some("empty"))]
622    fn test_extract_markdown_link_when_input_varies_returns_expected_link(
623        #[case] input: &str,
624        #[case] expected: Option<&str>,
625    ) {
626        assert_that!(extract_markdown_link(input), eq(expected));
627    }
628
629    #[rstest]
630    #[case("https://example.com", Some("https://example.com"))]
631    #[case("http://site.org", Some("http://site.org"))]
632    #[case("https://example.com with text", Some("https://example.com"))]
633    #[case("http://site.org more", Some("http://site.org"))]
634    #[case("text https://example.com", None)]
635    #[case("no link here", None)]
636    #[case("https://first.com https://second.com", Some("https://first.com"))]
637    #[case("http://a.com https://b.com", Some("http://a.com"))]
638    #[case("https://a.com http://b.com", Some("https://a.com"))]
639    #[case("https://example.com/path?query=value", Some("https://example.com/path?query=value"))]
640    #[case("https://example.com:8080", Some("https://example.com:8080"))]
641    fn test_extract_https_or_http_link_when_input_varies_returns_expected_link(
642        #[case] input: &str,
643        #[case] expected: Option<&str>,
644    ) {
645        assert_that!(extract_https_or_http_link(input), eq(expected));
646    }
647}