1use 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
26pub 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(¤t_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(¤t_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#[derive(Clone, Debug, Serialize)]
64#[serde(tag = "kind", content = "value")]
65#[cfg_attr(test, derive(Eq, PartialEq))]
66pub enum TokenUnderCursor {
67 Url(String),
69 BinaryFile(String),
71 TextFile {
73 path: String,
74 lnum: Option<i64>,
75 col: Option<i64>,
76 },
77 Directory(String),
79 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 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
108impl 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 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 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 if word_end_idx.saturating_sub(out.len()) == 0 {
237 'outer: for idx in (0..cursor_pos.row.saturating_sub(1)).rev() {
238 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 if word_end_idx >= window_width {
257 'outer: for idx in cursor_pos.row..usize::MAX {
258 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(¤t_line, cursor_pos.col).map(ToOwned::to_owned)
281}
282
283fn 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
296fn get_word_at_index(s: &str, idx: usize) -> Option<&str> {
303 let byte_idx = convert_visual_to_byte_idx(s, idx)?;
304
305 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 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
326fn 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 #[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 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}