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