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 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 #[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 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}