1use 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 static FILE_CMD_CACHE: RefCell<HashMap<String, FileCmdOutput>> = RefCell::new(HashMap::new());
29}
30
31pub 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(¤t_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(¤t_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 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 if word_end_idx.saturating_sub(out.len()) == 0 {
98 'outer: for idx in (0..cursor_pos.row.saturating_sub(1)).rev() {
99 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 if word_end_idx >= window_width {
118 'outer: for idx in cursor_pos.row..usize::MAX {
119 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(¤t_line, cursor_pos.col).map(ToOwned::to_owned)
142}
143
144#[derive(Clone, Debug, Serialize)]
155#[serde(tag = "kind", content = "value")]
156#[cfg_attr(test, derive(Eq, PartialEq))]
157pub enum TokenUnderCursor {
158 Url(String),
160 BinaryFile(String),
162 TextFile {
164 path: String,
165 lnum: Option<i64>,
166 col: Option<i64>,
167 },
168 Directory(String),
170 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 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
197impl 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
280fn 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
293fn get_word_at_index(s: &str, idx: usize) -> Option<&str> {
300 let byte_idx = convert_visual_to_byte_idx(s, idx)?;
301
302 if s[byte_idx..].chars().next().is_some_and(char::is_whitespace) {
304 return None;
305 }
306
307 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
320fn 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 #[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 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}