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
388    use super::*;
389
390    #[rstest]
391    #[case("open file.txt now", 7, Some("file.txt"))]
392    #[case("yes run main.rs", 8, Some("main.rs"))]
393    #[case("yes run main.rs", 14, Some("main.rs"))]
394    #[case("hello  world", 5, None)]
395    #[case("hello  world", 6, None)]
396    #[case("/usr/local/bin", 0, Some("/usr/local/bin"))]
397    #[case("/usr/local/bin", 14, Some("/usr/local/bin"))]
398    #[case("print(arg)", 5, Some("print(arg)"))]
399    #[case("abc", 10, None)]
400    #[case("αβ γ", 0, Some("αβ"))]
401    #[case("αβ γ", 1, Some("αβ"))]
402    #[case("αβ γ", 4, Some("γ"))]
403    #[case("αβ γ", 5, None)]
404    #[case("hello\nworld", 0, Some("hello"))]
405    #[case("hello\nworld", 6, Some("world"))]
406    #[case("hello\nworld", 5, None)]
407    #[case("hello\n\nworld", 5, None)]
408    #[case("hello\n\nworld", 6, None)]
409    fn test_get_word_at_index_when_cursor_position_varies_returns_expected_word(
410        #[case] s: &str,
411        #[case] idx: usize,
412        #[case] expected: Option<&str>,
413    ) {
414        pretty_assertions::assert_eq!(get_word_at_index(s, idx), expected);
415    }
416
417    // Tests are skipped in CI because [`TokenUnderCursor::from`] calls `file` command and that
418    // behaves differently based on the platform (e.g. macOS vs Linux)
419
420    #[test]
421    #[cfg(target_os = "macos")]
422    fn test_token_under_cursor_classify_valid_url_returns_url() {
423        let input = "https://example.com".to_string();
424        let result = TokenUnderCursor::classify(&input);
425        assert2::assert!(let Ok(actual) = result);
426        pretty_assertions::assert_eq!(actual, TokenUnderCursor::Url(input));
427    }
428
429    #[test]
430    #[cfg(target_os = "macos")]
431    fn test_token_under_cursor_classify_invalid_url_plain_word_returns_word() {
432        let input = "noturl".to_string();
433        let result = TokenUnderCursor::classify(&input);
434        assert2::assert!(let Ok(actual) = result);
435        pretty_assertions::assert_eq!(
436            actual,
437            TokenUnderCursor::MaybeTextFile {
438                value: input,
439                lnum: None,
440                col: None
441            }
442        );
443    }
444
445    #[test]
446    #[cfg(target_os = "macos")]
447    fn test_token_under_cursor_classify_path_to_text_file_returns_text_file() {
448        let mut temp_file = NamedTempFile::new().unwrap();
449        std::io::Write::write_all(&mut temp_file, b"hello world").unwrap();
450        let path = temp_file.path().to_string_lossy().into_owned();
451        let result = TokenUnderCursor::classify(&path);
452        assert2::assert!(let Ok(actual) = result);
453        pretty_assertions::assert_eq!(
454            actual,
455            TokenUnderCursor::TextFile {
456                path,
457                lnum: None,
458                col: None
459            }
460        );
461    }
462
463    #[test]
464    #[cfg(target_os = "macos")]
465    fn test_token_under_cursor_classify_path_lnum_to_text_file_returns_text_file_with_lnum() {
466        let mut temp_file = NamedTempFile::new().unwrap();
467        std::io::Write::write_all(&mut temp_file, b"hello world").unwrap();
468        let path = temp_file.path().to_string_lossy().into_owned();
469        let result = TokenUnderCursor::classify(&format!("{path}:10"));
470        assert2::assert!(let Ok(actual) = result);
471        pretty_assertions::assert_eq!(
472            actual,
473            TokenUnderCursor::TextFile {
474                path,
475                lnum: Some(10),
476                col: None
477            }
478        );
479    }
480
481    #[test]
482    #[cfg(target_os = "macos")]
483    fn test_token_under_cursor_classify_path_lnum_col_to_text_file_returns_text_file_with_lnum_col() {
484        let mut temp_file = NamedTempFile::new().unwrap();
485        std::io::Write::write_all(&mut temp_file, b"hello world").unwrap();
486        let path = temp_file.path().to_string_lossy().into_owned();
487        let result = TokenUnderCursor::classify(&format!("{path}:10:5"));
488        assert2::assert!(let Ok(actual) = result);
489        pretty_assertions::assert_eq!(
490            actual,
491            TokenUnderCursor::TextFile {
492                path,
493                lnum: Some(10),
494                col: Some(5)
495            }
496        );
497    }
498
499    #[test]
500    #[cfg(target_os = "macos")]
501    fn test_token_under_cursor_classify_path_to_directory_returns_directory() {
502        let temp_dir = TempDir::new().unwrap();
503        let path = temp_dir.path().to_string_lossy().into_owned();
504        let result = TokenUnderCursor::classify(&path);
505        assert2::assert!(let Ok(actual) = result);
506        pretty_assertions::assert_eq!(actual, TokenUnderCursor::Directory(path));
507    }
508
509    #[test]
510    #[cfg(target_os = "macos")]
511    fn test_token_under_cursor_classify_path_to_binary_file_returns_binary_file() {
512        let mut temp_file = NamedTempFile::new().unwrap();
513        // Write some binary data
514        std::io::Write::write_all(&mut temp_file, &[0, 1, 2, 255]).unwrap();
515        let path = temp_file.path().to_string_lossy().into_owned();
516        let result = TokenUnderCursor::classify(&path);
517        assert2::assert!(let Ok(actual) = result);
518        pretty_assertions::assert_eq!(actual, TokenUnderCursor::BinaryFile(path));
519    }
520
521    #[test]
522    #[cfg(target_os = "macos")]
523    fn test_token_under_cursor_classify_nonexistent_path_returns_maybe_text_file() {
524        let path = "/nonexistent/path".to_string();
525        let result = TokenUnderCursor::classify(&path);
526        assert2::assert!(let Ok(actual) = result);
527        pretty_assertions::assert_eq!(
528            actual,
529            TokenUnderCursor::MaybeTextFile {
530                value: path,
531                lnum: None,
532                col: None
533            }
534        );
535    }
536
537    #[test]
538    #[cfg(target_os = "macos")]
539    fn test_token_under_cursor_classify_path_with_invalid_lnum_returns_maybe_text_file() {
540        let temp_file = NamedTempFile::new().unwrap();
541        let path = temp_file.path().to_string_lossy().into_owned();
542        let input = format!("{path}:invalid");
543        let result = TokenUnderCursor::classify(&input);
544        assert2::assert!(let Ok(actual) = result);
545        pretty_assertions::assert_eq!(
546            actual,
547            TokenUnderCursor::MaybeTextFile {
548                value: path,
549                lnum: None,
550                col: None
551            }
552        );
553    }
554
555    #[test]
556    #[cfg(target_os = "macos")]
557    fn test_token_under_cursor_classify_path_with_invalid_col_returns_maybe_text_file() {
558        let temp_file = NamedTempFile::new().unwrap();
559        let path = temp_file.path().to_string_lossy().into_owned();
560        let input = format!("{path}:10:invalid");
561        let result = TokenUnderCursor::classify(&input);
562        assert2::assert!(let Ok(actual) = result);
563        pretty_assertions::assert_eq!(
564            actual,
565            TokenUnderCursor::MaybeTextFile {
566                value: path,
567                lnum: Some(10),
568                col: None
569            }
570        );
571    }
572
573    #[test]
574    #[cfg(target_os = "macos")]
575    fn test_token_under_cursor_classify_path_lnum_col_extra_ignores_extra() {
576        let mut temp_file = NamedTempFile::new().unwrap();
577        std::io::Write::write_all(&mut temp_file, b"hello world").unwrap();
578        let path = temp_file.path().to_string_lossy().into_owned();
579        let result = TokenUnderCursor::classify(&format!("{path}:10:5:extra"));
580        assert2::assert!(let Ok(actual) = result);
581        pretty_assertions::assert_eq!(
582            actual,
583            TokenUnderCursor::TextFile {
584                path,
585                lnum: Some(10),
586                col: Some(5)
587            }
588        );
589    }
590
591    #[rstest]
592    #[case("https://example.com", "https://example.com")]
593    #[case("http://example.com", "http://example.com")]
594    #[case("\"https://example.com\"", "https://example.com")]
595    #[case("`https://example.com`", "https://example.com")]
596    #[case("'https://example.com'", "https://example.com")]
597    #[case("{https://example.com}", "https://example.com")]
598    #[case("(https://example.com)", "https://example.com")]
599    #[case("[text](https://example.com)", "https://example.com")]
600    #[case("[[text]](https://example.com)", "https://example.com")]
601    #[case("https://example.com extra", "https://example.com")]
602    #[case("http://example.com with text", "http://example.com")]
603    #[case("(http://example.com)", "http://example.com")]
604    #[case("`http://example.com`", "http://example.com")]
605    fn test_classify_url_returns_the_token_url_under_curos(#[case] input: &str, #[case] expected_value: &str) {
606        assert2::assert!(let Ok(actual) = TokenUnderCursor::classify_url(input));
607        pretty_assertions::assert_eq!(actual, TokenUnderCursor::Url(expected_value.to_string()));
608    }
609
610    #[rstest]
611    #[case("not a url")]
612    #[case("[text](noturl)")]
613    fn test_classify_url_when_cannot_classify_url_returns_the_expected_error(#[case] input: &str) {
614        assert2::assert!(let Err(err) = TokenUnderCursor::classify_url(input));
615        assert!(err.downcast_current_context::<url::ParseError>().is_some());
616    }
617
618    #[rstest]
619    #[case("[hello](world)", Some("world"))]
620    #[case("[hello world](https://example.com)", Some("https://example.com"))]
621    #[case("[text](url with spaces)", Some("url with spaces"))]
622    #[case("[a](1)[b](2)", Some("1"))]
623    #[case("[hello]()", Some(""))]
624    #[case("[hello](world", Some("world"))]
625    #[case("hello](world)", Some("world"))]
626    #[case("hello](world", Some("world"))]
627    #[case("no link", None)]
628    #[case("[incomplete", None)]
629    #[case("](empty)", Some("empty"))]
630    fn test_extract_markdown_link_when_input_varies_returns_expected_link(
631        #[case] input: &str,
632        #[case] expected: Option<&str>,
633    ) {
634        pretty_assertions::assert_eq!(extract_markdown_link(input), expected);
635    }
636
637    #[rstest]
638    #[case("https://example.com", Some("https://example.com"))]
639    #[case("http://site.org", Some("http://site.org"))]
640    #[case("https://example.com with text", Some("https://example.com"))]
641    #[case("http://site.org more", Some("http://site.org"))]
642    #[case("text https://example.com", None)]
643    #[case("no link here", None)]
644    #[case("https://first.com https://second.com", Some("https://first.com"))]
645    #[case("http://a.com https://b.com", Some("http://a.com"))]
646    #[case("https://a.com http://b.com", Some("https://a.com"))]
647    #[case("https://example.com/path?query=value", Some("https://example.com/path?query=value"))]
648    #[case("https://example.com:8080", Some("https://example.com:8080"))]
649    fn test_extract_https_or_http_link_when_input_varies_returns_expected_link(
650        #[case] input: &str,
651        #[case] expected: Option<&str>,
652    ) {
653        pretty_assertions::assert_eq!(extract_https_or_http_link(input), expected);
654    }
655}