1use std::collections::HashSet;
2use std::path::Path;
3use std::path::PathBuf;
4
5use muxr_core::ClientMousePosition;
6use muxr_core::PaneRegionSnapshot;
7use muxr_core::RenderCell;
8use muxr_core::RenderCellWidth;
9use muxr_core::RowWrap;
10
11use crate::frame_buffer::FrameBuffer;
12
13const PATH_START_PREFIXES: [&str; 4] = ["/", "~/", "./", "../"];
14
15#[derive(Clone, Debug, Eq, Hash, PartialEq)]
16pub struct FileLink {
17 pub path: PathBuf,
18 pub line: Option<u32>,
19 pub column: Option<u32>,
20}
21
22#[derive(Clone, Copy)]
23enum FileLinkReferenceMode {
24 Delimited,
25 UnquotedSpaces,
26}
27
28#[derive(Clone, Copy)]
29enum FileLinkExplicitness {
30 Implicit,
31 Explicit,
32}
33
34#[derive(Clone, Copy)]
35enum FileLinkFallbackOrder {
36 PreservePath,
37 PreserveLocation,
38}
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41enum FileLinkExistence {
42 Existing,
43 Missing,
44}
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47enum FileLinkWrap {
48 SingleRow,
49 SoftWrapped,
50}
51
52impl FileLinkWrap {
53 const fn join(self, other: Self) -> Self {
54 if matches!(self, Self::SoftWrapped) || matches!(other, Self::SoftWrapped) {
55 Self::SoftWrapped
56 } else {
57 Self::SingleRow
58 }
59 }
60}
61
62struct ReferenceParts {
63 chars: Vec<String>,
64 wrap: FileLinkWrap,
65}
66
67struct FileLinkExtraction {
68 raw: String,
69 raw_reference: String,
70 clicked_index: usize,
71 explicitness: FileLinkExplicitness,
72 wrap: FileLinkWrap,
73}
74
75impl FileLink {
76 #[cfg(test)]
77 fn resolve_from_cwd(&self, cwd: &str) -> Option<Self> {
78 self.resolve_with_existence(cwd).map(|resolved| resolved.link)
79 }
80
81 fn resolve_with_existence(&self, cwd: &str) -> Option<ResolvedFileLink> {
82 let raw_path = self.path.to_str()?;
83 let path = self::resolve_path(raw_path, cwd)?;
84 let existence = self::file_link_existence(&path);
85 if self::is_bare_name(raw_path)
88 && matches!(existence, FileLinkExistence::Missing)
89 && Path::new(raw_path).extension().is_none()
90 {
91 return None;
92 }
93 Some(ResolvedFileLink {
94 link: Self {
95 path,
96 line: self.line,
97 column: self.column,
98 },
99 existence,
100 })
101 }
102}
103
104struct ResolvedFileLink {
105 link: FileLink,
106 existence: FileLinkExistence,
107}
108
109impl FrameBuffer {
110 #[cfg(test)]
111 fn file_link_at(&self, region: &PaneRegionSnapshot, position: ClientMousePosition) -> Option<FileLink> {
112 self.at_with_reference_mode(region, position, FileLinkReferenceMode::Delimited)
113 .and_then(|extraction| self::file_link_from_reference(&extraction.raw))
114 }
115
116 pub fn file_link_at_resolved(
117 &self,
118 region: &PaneRegionSnapshot,
119 position: ClientMousePosition,
120 cwd: &str,
121 ) -> Option<FileLink> {
122 let unquoted = self.at_with_reference_mode(region, position, FileLinkReferenceMode::UnquotedSpaces);
123 let resolved_unquoted = unquoted
124 .as_ref()
125 .and_then(|extraction| extraction.resolve_unquoted(cwd));
126 if resolved_unquoted.is_none()
127 && unquoted.as_ref().is_some_and(|extraction| {
128 extraction.raw.chars().any(char::is_whitespace)
129 && matches!(extraction.explicitness, FileLinkExplicitness::Implicit)
130 })
131 {
132 return None;
133 }
134 let delimited = self.at_with_reference_mode(region, position, FileLinkReferenceMode::Delimited);
135 let resolved_delimited = delimited.as_ref().and_then(|extraction| extraction.resolve_single(cwd));
136 resolved_unquoted.or(resolved_delimited)
137 }
138
139 fn at_with_reference_mode(
140 &self,
141 region: &PaneRegionSnapshot,
142 position: ClientMousePosition,
143 mode: FileLinkReferenceMode,
144 ) -> Option<FileLinkExtraction> {
145 if region.containment(position.row, position.col) != muxr_core::PaneRegionContainment::Inside {
146 return None;
147 }
148
149 let position = self::reference_position_for_click(self, region, position)?;
150 let clicked = self::cell_text(self.cell(position.row, position.col))?;
151 if !self::is_reference_text(clicked) {
152 return None;
153 }
154
155 let left = self::collect_left(self, region, position, mode);
156 let right = self::collect_right(self, region, position, mode);
157 let wrap = left.wrap.join(right.wrap);
158 let mut raw = left.chars.iter().rev().fold(String::new(), |mut raw, text| {
159 raw.push_str(text);
160 raw
161 });
162 let clicked_index = raw.len();
163 raw.push_str(clicked);
164 for text in &right.chars {
165 raw.push_str(text);
166 }
167
168 let raw_start = raw.trim_start();
169 let explicitness = if raw_start.starts_with('"')
170 || raw_start.starts_with('\'')
171 || raw_start.starts_with('`')
172 || raw.contains("\\ ")
173 || (raw_start.starts_with('[') && self::markdown_target(raw_start).is_some())
174 {
175 FileLinkExplicitness::Explicit
176 } else {
177 FileLinkExplicitness::Implicit
178 };
179 let (trimmed, trim_start) = self::trim_reference(&raw);
180 let trimmed_end = trim_start.checked_add(trimmed.len())?;
181 if !(trim_start..trimmed_end).contains(&clicked_index) {
182 return None;
183 }
184
185 self::file_link_from_reference(trimmed)?;
186 let raw_reference = raw.get(trim_start..)?.to_owned();
187 Some(FileLinkExtraction {
188 raw: raw_reference.clone(),
189 raw_reference,
190 clicked_index: clicked_index.checked_sub(trim_start)?,
191 explicitness,
192 wrap,
193 })
194 }
195}
196
197impl FileLinkExtraction {
198 fn resolve_unquoted(&self, cwd: &str) -> Option<FileLink> {
199 if !self.raw.chars().any(char::is_whitespace) {
200 return self::resolve_first_file_link(self::file_link_candidates(&self.raw_reference), cwd, self.wrap);
201 }
202
203 let boundaries = self::whitespace_boundaries(&self.raw);
204
205 let clicked_word_start = boundaries
206 .partition_point(|boundary| *boundary <= self.clicked_index)
207 .saturating_sub(1);
208 let clicked_word_end = clicked_word_start.saturating_add(1);
209 if let Some(resolved) =
210 self::resolve_anchored_unquoted_reference(self, cwd, &boundaries, clicked_word_start, clicked_word_end)
211 {
212 return Some(resolved);
213 }
214 if let Some(resolved) = self::resolve_bare_unquoted_reference(self, cwd, &boundaries) {
215 return Some(resolved);
216 }
217
218 if self.raw.starts_with('[') && self.raw.contains("](") && self::markdown_target(&self.raw).is_none() {
219 return None;
220 }
221 self.resolve_single(cwd)
222 }
223
224 fn resolve_single(&self, cwd: &str) -> Option<FileLink> {
225 let resolved = self::resolve_first_file_link(self::file_link_candidates(&self.raw_reference), cwd, self.wrap)?;
226 if matches!(self.explicitness, FileLinkExplicitness::Implicit)
227 && matches!(self::file_link_existence(&resolved.path), FileLinkExistence::Missing)
228 {
229 return None;
230 }
231 Some(resolved)
232 }
233}
234
235fn whitespace_boundaries(raw: &str) -> Vec<usize> {
236 let mut boundaries = vec![0];
237 for (index, ch) in raw.char_indices().filter(|(_, ch)| ch.is_whitespace()) {
238 boundaries.push(index.saturating_add(ch.len_utf8()));
239 }
240 boundaries.push(raw.len());
241 boundaries
242}
243
244fn resolve_anchored_unquoted_reference(
247 extraction: &FileLinkExtraction,
248 cwd: &str,
249 boundaries: &[usize],
250 clicked_word_start: usize,
251 clicked_word_end: usize,
252) -> Option<FileLink> {
253 let mut seen_candidates = HashSet::new();
254 let mut longest_existing = None;
255 for start_index in 0..=clicked_word_start {
256 let Some(&start) = boundaries.get(start_index) else {
257 continue;
258 };
259 let Some(&word_end) = boundaries.get(start_index.saturating_add(1)) else {
260 continue;
261 };
262 let Some(word) = extraction.raw.get(start..word_end).map(str::trim) else {
263 continue;
264 };
265 if !self::is_path_anchor_word(word) {
266 continue;
267 }
268 for end_index in clicked_word_end..boundaries.len() {
269 let Some(&end) = boundaries.get(end_index) else {
270 continue;
271 };
272 let Some(candidate_raw) = extraction.raw.get(start..end).map(str::trim) else {
273 continue;
274 };
275 for candidate in self::file_link_candidates(candidate_raw) {
276 if !seen_candidates.insert(candidate.clone()) {
277 continue;
278 }
279 let Some(resolved) = candidate.resolve_with_existence(cwd) else {
280 continue;
281 };
282 if matches!(resolved.existence, FileLinkExistence::Missing) {
283 continue;
284 }
285 if longest_existing
286 .as_ref()
287 .is_none_or(|(length, _)| candidate_raw.len() > *length)
288 {
289 longest_existing = Some((candidate_raw.len(), resolved.link));
290 }
291 }
292 }
293 }
294 longest_existing.map(|(_, link)| link)
295}
296
297fn resolve_bare_unquoted_reference(
298 extraction: &FileLinkExtraction,
299 cwd: &str,
300 boundaries: &[usize],
301) -> Option<FileLink> {
302 let entries = std::fs::read_dir(cwd).ok()?;
303 let mut seen_candidates = HashSet::new();
304 let mut longest_existing = None;
305 for entry in entries.flatten() {
306 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
307 continue;
308 };
309 for (match_start, _) in extraction.raw.match_indices(&name) {
310 let Some(match_end) = match_start.checked_add(name.len()) else {
311 continue;
312 };
313 if extraction.clicked_index < match_start
314 || extraction.clicked_index >= match_end
315 || !self::is_bare_name_match(&extraction.raw, match_start, match_end)
316 {
317 continue;
318 }
319 let start_index = boundaries
320 .partition_point(|boundary| *boundary <= match_start)
321 .saturating_sub(1);
322 let end_index = boundaries.partition_point(|boundary| *boundary < match_end);
323 let Some(&start) = boundaries.get(start_index) else {
324 continue;
325 };
326 let Some(&end) = boundaries.get(end_index) else {
327 continue;
328 };
329 let Some(candidate_raw) = extraction.raw.get(start..end).map(str::trim) else {
330 continue;
331 };
332 for candidate in self::file_link_candidates(candidate_raw) {
333 if !seen_candidates.insert(candidate.clone()) {
334 continue;
335 }
336 let Some(resolved) = candidate.resolve_with_existence(cwd) else {
337 continue;
338 };
339 if matches!(resolved.existence, FileLinkExistence::Missing) {
340 continue;
341 }
342 if longest_existing
343 .as_ref()
344 .is_none_or(|(length, _)| candidate_raw.len() > *length)
345 {
346 longest_existing = Some((candidate_raw.len(), resolved.link));
347 }
348 }
349 }
350 }
351 longest_existing.map(|(_, link)| link)
352}
353
354fn is_path_anchor_word(raw: &str) -> bool {
355 let path = self::trim_reference(raw).0;
356 !path.starts_with("http://")
357 && !path.starts_with("https://")
358 && !path.starts_with("file://")
359 && (PATH_START_PREFIXES.iter().any(|prefix| path.starts_with(prefix)) || path.contains('/'))
360}
361
362fn is_bare_name_match(raw: &str, start: usize, end: usize) -> bool {
363 let before = raw.get(..start).and_then(|prefix| prefix.chars().next_back());
364 let after = raw.get(end..).and_then(|suffix| suffix.chars().next());
365 before.is_none_or(|ch| ch.is_whitespace() || matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '`'))
366 && after.is_none_or(|ch| {
367 ch.is_whitespace()
368 || matches!(
369 ch,
370 '/' | ':' | '.' | ',' | ';' | '!' | '?' | ')' | ']' | '}' | '>' | '"' | '\'' | '`'
371 )
372 })
373}
374
375fn resolve_first_file_link(candidates: Vec<FileLink>, cwd: &str, wrap: FileLinkWrap) -> Option<FileLink> {
376 let mut fallback_plain = None;
377 let mut fallback_with_location = None;
378 let mut fallback_order = FileLinkFallbackOrder::PreservePath;
379 for candidate in candidates {
380 let Some(resolved) = candidate.resolve_with_existence(cwd) else {
381 continue;
382 };
383 if matches!(wrap, FileLinkWrap::SoftWrapped) && matches!(resolved.existence, FileLinkExistence::Missing) {
384 continue;
385 }
386 if matches!(resolved.existence, FileLinkExistence::Existing) {
387 return Some(resolved.link);
388 }
389 if candidate.line.is_some() || candidate.column.is_some() {
390 if candidate.line.is_some() && candidate.column.is_some() {
391 fallback_order = FileLinkFallbackOrder::PreserveLocation;
392 }
393 fallback_with_location = Some(resolved.link);
394 } else if fallback_plain.is_none() {
395 fallback_plain = Some(resolved.link);
396 }
397 }
398 match fallback_order {
402 FileLinkFallbackOrder::PreservePath => fallback_plain.or(fallback_with_location),
403 FileLinkFallbackOrder::PreserveLocation => fallback_with_location.or(fallback_plain),
404 }
405}
406
407fn file_link_from_reference(raw: &str) -> Option<FileLink> {
408 let (path, line, column) = self::parse_reference(raw)?;
409 Some(FileLink {
410 path: PathBuf::from(self::unescape_path(path)),
411 line,
412 column,
413 })
414}
415
416fn file_link_candidates(raw: &str) -> Vec<FileLink> {
417 let mut candidates = Vec::new();
418 let mut add = |path: &str, line: Option<u32>, column: Option<u32>| {
419 let candidate = FileLink {
420 path: PathBuf::from(self::unescape_path(path)),
421 line,
422 column,
423 };
424 if !candidates.contains(&candidate) {
425 candidates.push(candidate);
426 }
427 };
428
429 if let Some(path) = self::raw_path_for_resolution(raw)
430 && !path.is_empty()
431 && !path.starts_with("http://")
432 && !path.starts_with("https://")
433 && !path.starts_with("file://")
434 {
435 add(path, None, None);
436 }
437 if let Some((path, line, column)) = self::parse_reference_preserving_path_punctuation(raw) {
438 add(path, line, column);
439 }
440 if let Some(link) = self::file_link_from_reference(raw)
441 && !candidates.contains(&link)
442 {
443 candidates.push(link);
444 }
445 candidates
446}
447
448fn collect_left(
449 frame_buffer: &FrameBuffer,
450 region: &PaneRegionSnapshot,
451 position: ClientMousePosition,
452 mode: FileLinkReferenceMode,
453) -> ReferenceParts {
454 let mut chars = Vec::new();
455 let mut wrap = FileLinkWrap::SingleRow;
456 let quoted_delimiter = self::quoted_reference_delimiter(frame_buffer, region, position);
457 let inside_quote = quoted_delimiter.is_some();
458 let origin_row = position.row;
459 let mut current = self::previous_reference_position(frame_buffer, region, position);
460 while let Some(position) = current {
461 if position.row != origin_row
462 && self::row_wrap(region, position.row.saturating_sub(1)) == Some(RowWrap::EndsWithSoftWrap)
463 {
464 wrap = FileLinkWrap::SoftWrapped;
465 }
466 let Some(text) = self::cell_text(frame_buffer.cell(position.row, position.col)) else {
467 break;
468 };
469 if self::is_whitespace_text(text) {
470 if self::is_continuation_row_prefix(frame_buffer, region, position) {
471 wrap = FileLinkWrap::SoftWrapped;
472 current = self::previous_row_end(region, position.row);
473 continue;
474 }
475 if self::is_soft_wrap_row_suffix(frame_buffer, region, position) {
476 wrap = FileLinkWrap::SoftWrapped;
477 current = self::previous_reference_position(frame_buffer, region, position);
478 continue;
479 }
480 if matches!(mode, FileLinkReferenceMode::UnquotedSpaces)
481 && !self::row_prefix_is_whitespace(frame_buffer, region, position)
482 {
483 chars.push(text.to_owned());
484 current = self::previous_reference_position(frame_buffer, region, position);
485 continue;
486 }
487 if inside_quote || self::is_escaped_space(frame_buffer, region, position) {
488 chars.push(text.to_owned());
489 current = self::previous_reference_position(frame_buffer, region, position);
490 continue;
491 }
492 break;
493 }
494 if !self::is_reference_text(text) {
495 break;
496 }
497 chars.push(text.to_owned());
498 if inside_quote && self::quote_text_matches(text, quoted_delimiter) {
499 break;
500 }
501 current = self::previous_reference_position(frame_buffer, region, position);
502 }
503 ReferenceParts { chars, wrap }
504}
505
506fn collect_right(
507 frame_buffer: &FrameBuffer,
508 region: &PaneRegionSnapshot,
509 position: ClientMousePosition,
510 mode: FileLinkReferenceMode,
511) -> ReferenceParts {
512 let mut chars = Vec::new();
513 let mut wrap = FileLinkWrap::SingleRow;
514 let quoted_delimiter = self::quoted_reference_delimiter(frame_buffer, region, position);
515 let mut inside_quote = quoted_delimiter.is_some();
516 let origin_row = position.row;
517 let mut current = self::next_reference_position(frame_buffer, region, position);
518 let mut skip_continuation_prefix = current.is_some_and(|next| next.row != position.row);
519 while let Some(position) = current {
520 if position.row != origin_row
521 && self::row_wrap(region, position.row.saturating_sub(1)) == Some(RowWrap::EndsWithSoftWrap)
522 {
523 wrap = FileLinkWrap::SoftWrapped;
524 }
525 let Some(text) = self::cell_text(frame_buffer.cell(position.row, position.col)) else {
526 break;
527 };
528 if self::is_whitespace_text(text) {
529 if skip_continuation_prefix && self::row_prefix_is_whitespace(frame_buffer, region, position) {
530 wrap = FileLinkWrap::SoftWrapped;
531 let next = self::next_reference_position(frame_buffer, region, position);
532 skip_continuation_prefix = next.is_some_and(|next| next.row != position.row);
533 current = next;
534 continue;
535 }
536 if self::is_soft_wrap_row_suffix(frame_buffer, region, position) {
537 wrap = FileLinkWrap::SoftWrapped;
538 let Some(next_row) = position.row.checked_add(1) else {
539 break;
540 };
541 current = Some(ClientMousePosition {
542 row: next_row,
543 col: region.col(),
544 });
545 skip_continuation_prefix = true;
546 continue;
547 }
548 if matches!(mode, FileLinkReferenceMode::UnquotedSpaces)
549 && !self::row_suffix_is_whitespace(frame_buffer, region, position)
550 {
551 chars.push(text.to_owned());
552 let next = self::next_reference_position(frame_buffer, region, position);
553 skip_continuation_prefix = next.is_some_and(|next| next.row != position.row);
554 current = next;
555 continue;
556 }
557 if inside_quote || self::is_escaped_space(frame_buffer, region, position) {
558 chars.push(text.to_owned());
559 let next = self::next_reference_position(frame_buffer, region, position);
560 skip_continuation_prefix = next.is_some_and(|next| next.row != position.row);
561 current = next;
562 continue;
563 }
564 break;
565 }
566 skip_continuation_prefix = false;
567 if !self::is_reference_text(text) {
568 break;
569 }
570 chars.push(text.to_owned());
571 if inside_quote && self::quote_text_matches(text, quoted_delimiter) {
572 inside_quote = false;
573 }
574 let next = self::next_reference_position(frame_buffer, region, position);
575 if let Some(next) = next
576 && next.row != position.row
577 {
578 skip_continuation_prefix = true;
579 }
580 current = next;
581 }
582 ReferenceParts { chars, wrap }
583}
584
585fn previous_position(region: &PaneRegionSnapshot, position: ClientMousePosition) -> Option<ClientMousePosition> {
586 if position.col > region.col() {
587 return Some(ClientMousePosition {
588 row: position.row,
589 col: position.col.saturating_sub(1),
590 });
591 }
592 if position.row <= region.row()
593 || self::row_wrap(region, position.row.saturating_sub(1)) != Some(RowWrap::EndsWithSoftWrap)
594 {
595 return None;
596 }
597 self::previous_row_end(region, position.row)
598}
599
600fn previous_reference_position(
601 frame_buffer: &FrameBuffer,
602 region: &PaneRegionSnapshot,
603 position: ClientMousePosition,
604) -> Option<ClientMousePosition> {
605 let mut position = self::previous_position(region, position);
606 while let Some(current) = position
607 && frame_buffer
608 .cell(current.row, current.col)
609 .is_some_and(|cell| cell.width() == RenderCellWidth::WideContinuation)
610 {
611 position = self::previous_position(region, current);
612 }
613 position
614}
615
616fn next_position(region: &PaneRegionSnapshot, position: ClientMousePosition) -> Option<ClientMousePosition> {
617 let last_col = region.col().checked_add(region.cols())?.checked_sub(1)?;
618 if position.col < last_col {
619 return Some(ClientMousePosition {
620 row: position.row,
621 col: position.col.saturating_add(1),
622 });
623 }
624 if position.row >= region.row().checked_add(region.rows())?.saturating_sub(1)
625 || self::row_wrap(region, position.row) != Some(RowWrap::EndsWithSoftWrap)
626 {
627 return None;
628 }
629 Some(ClientMousePosition {
630 row: position.row.saturating_add(1),
631 col: region.col(),
632 })
633}
634
635fn next_reference_position(
636 frame_buffer: &FrameBuffer,
637 region: &PaneRegionSnapshot,
638 position: ClientMousePosition,
639) -> Option<ClientMousePosition> {
640 let mut position = self::next_position(region, position);
641 while let Some(current) = position
642 && frame_buffer
643 .cell(current.row, current.col)
644 .is_some_and(|cell| cell.width() == RenderCellWidth::WideContinuation)
645 {
646 position = self::next_position(region, current);
647 }
648 position
649}
650
651fn reference_position_for_click(
652 frame_buffer: &FrameBuffer,
653 region: &PaneRegionSnapshot,
654 position: ClientMousePosition,
655) -> Option<ClientMousePosition> {
656 if frame_buffer
657 .cell(position.row, position.col)
658 .is_some_and(|cell| cell.width() == RenderCellWidth::WideContinuation)
659 {
660 let previous = self::previous_position(region, position)?;
661 return frame_buffer
662 .cell(previous.row, previous.col)
663 .is_some_and(|cell| cell.width() == RenderCellWidth::Wide)
664 .then_some(previous);
665 }
666 Some(position)
667}
668
669fn previous_row_end(region: &PaneRegionSnapshot, row: u16) -> Option<ClientMousePosition> {
670 Some(ClientMousePosition {
671 row: row.checked_sub(1)?,
672 col: region.col().checked_add(region.cols())?.checked_sub(1)?,
673 })
674}
675
676fn row_wrap(region: &PaneRegionSnapshot, row: u16) -> Option<RowWrap> {
677 let local_row = row.checked_sub(region.row())?;
678 let content_row = region.visible_top_row().checked_add(u64::from(local_row))?;
679 Some(region.content_row_wrap(content_row))
680}
681
682fn is_continuation_row_prefix(
683 frame_buffer: &FrameBuffer,
684 region: &PaneRegionSnapshot,
685 position: ClientMousePosition,
686) -> bool {
687 position.row > region.row()
688 && self::row_wrap(region, position.row.saturating_sub(1)) == Some(RowWrap::EndsWithSoftWrap)
689 && self::row_prefix_is_whitespace(frame_buffer, region, position)
690}
691
692fn is_soft_wrap_row_suffix(
693 frame_buffer: &FrameBuffer,
694 region: &PaneRegionSnapshot,
695 position: ClientMousePosition,
696) -> bool {
697 self::row_wrap(region, position.row) == Some(RowWrap::EndsWithSoftWrap)
698 && position.col >= region.col()
699 && position.col < region.col().saturating_add(region.cols())
700 && self::row_suffix_is_whitespace(frame_buffer, region, position)
701}
702
703fn row_prefix_is_whitespace(
704 frame_buffer: &FrameBuffer,
705 region: &PaneRegionSnapshot,
706 position: ClientMousePosition,
707) -> bool {
708 (region.col()..=position.col)
709 .all(|col| self::cell_text(frame_buffer.cell(position.row, col)).is_some_and(self::is_whitespace_text))
710}
711
712fn row_suffix_is_whitespace(
713 frame_buffer: &FrameBuffer,
714 region: &PaneRegionSnapshot,
715 position: ClientMousePosition,
716) -> bool {
717 let Some(last_col) = region
718 .col()
719 .checked_add(region.cols())
720 .and_then(|end| end.checked_sub(1))
721 else {
722 return false;
723 };
724 (position.col..=last_col)
725 .all(|col| self::cell_text(frame_buffer.cell(position.row, col)).is_some_and(self::is_whitespace_text))
726}
727
728fn parse_reference(raw: &str) -> Option<(&str, Option<u32>, Option<u32>)> {
729 let (raw, _) = self::trim_reference(raw);
730 let raw = self::markdown_target(raw).unwrap_or(raw);
731 let (raw, _) = self::trim_reference(raw);
732 let mut path = raw;
733 let mut line = None;
734 let mut column = None;
735
736 if let Some((before_column, raw_column)) = raw.rsplit_once(':')
737 && let Some(parsed_column) = self::positive_number(raw_column)
738 {
739 if let Some((before_line, raw_line)) = before_column.rsplit_once(':')
740 && let Some(parsed_line) = self::positive_number(raw_line)
741 {
742 path = before_line;
743 line = Some(parsed_line);
744 column = Some(parsed_column);
745 } else {
746 path = before_column;
747 line = Some(parsed_column);
748 }
749 }
750
751 path = self::trim_reference(path).0;
752
753 if path.is_empty() || path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") {
754 return None;
755 }
756
757 Some((path, line, column))
758}
759
760fn parse_reference_preserving_path_punctuation(raw: &str) -> Option<(&str, Option<u32>, Option<u32>)> {
761 let (_, start) = self::trim_reference(raw);
762 let raw = raw.get(start..)?;
763 let raw = self::markdown_target(raw).unwrap_or(raw);
764 let mut path = raw;
765 let mut line = None;
766 let mut column = None;
767
768 if let Some((before_column, raw_column)) = raw.rsplit_once(':')
769 && let Some(parsed_column) = self::positive_number(self::trim_location_punctuation(raw_column))
770 {
771 if let Some((before_line, raw_line)) = before_column.rsplit_once(':')
772 && let Some(parsed_line) = self::positive_number(self::trim_location_punctuation(raw_line))
773 {
774 path = before_line;
775 line = Some(parsed_line);
776 column = Some(parsed_column);
777 } else {
778 path = before_column;
779 line = Some(parsed_column);
780 }
781 }
782
783 if path.is_empty() || path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") {
784 return None;
785 }
786 Some((path, line, column))
787}
788
789fn raw_path_for_resolution(raw: &str) -> Option<&str> {
790 let (_, start) = self::trim_reference(raw);
791 let raw = raw.get(start..)?;
792 let path = self::markdown_target(raw).unwrap_or(raw);
793 (!path.is_empty()).then_some(path)
794}
795
796fn trim_location_punctuation(raw: &str) -> &str {
797 raw.trim_end_matches(['.', ',', ';', '!', '?', ')', ']', '}', '>', '"', '\'', '`'])
798}
799
800fn trim_reference(raw: &str) -> (&str, usize) {
801 let mut start = 0;
802 let mut end = raw.len();
803 while let Some(ch) = raw.get(start..).and_then(|rest| rest.chars().next())
804 && matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '`')
805 && !(start == 0 && ch == '[' && raw.contains("]("))
806 {
807 start = start.saturating_add(ch.len_utf8());
808 }
809 while let Some(ch) = raw.get(..end).and_then(|rest| rest.chars().next_back())
810 && matches!(ch, '.' | ',' | ';' | '!' | '?' | ']' | '}' | '>' | '"' | '\'' | '`')
811 {
812 end = end.saturating_sub(ch.len_utf8());
813 }
814 let mut trimmed = raw.get(start..end).unwrap_or_default();
815 let mut unmatched_closing_parentheses = trimmed
816 .matches(')')
817 .count()
818 .saturating_sub(trimmed.matches('(').count());
819 while unmatched_closing_parentheses > 0 && trimmed.ends_with(')') {
820 let Some(without_wrapper) = trimmed.strip_suffix(')') else {
821 break;
822 };
823 trimmed = without_wrapper.trim_end_matches(['.', ',', ';', '!', '?', ')', ']', '}', '>', '"', '\'', '`']);
824 unmatched_closing_parentheses = unmatched_closing_parentheses.saturating_sub(1);
825 }
826 (trimmed, start)
827}
828
829fn markdown_target(raw: &str) -> Option<&str> {
830 let body = raw.strip_prefix('[')?;
831 let (_, target) = body.split_once("](")?;
832 let target = target.strip_suffix(')')?;
833 Some(
834 target
835 .strip_prefix('<')
836 .and_then(|target| target.strip_suffix('>'))
837 .unwrap_or(target),
838 )
839}
840
841fn resolve_path(path: &str, cwd: &str) -> Option<PathBuf> {
842 let home = std::env::var_os("HOME").map(PathBuf::from);
843 self::resolve_path_with_home(path, cwd, home.as_deref())
844}
845
846fn resolve_path_with_home(path: &str, cwd: &str, home: Option<&Path>) -> Option<PathBuf> {
847 let expand_home = |path: &str| -> Option<PathBuf> {
848 if path == "~" {
849 Some(home?.to_owned())
850 } else if let Some(rest) = path.strip_prefix("~/") {
851 Some(home?.join(rest))
852 } else {
853 Some(PathBuf::from(path))
854 }
855 };
856 let path = expand_home(path)?;
857 if path.is_absolute() {
858 return Some(path);
859 }
860 Some(expand_home(cwd)?.join(path))
861}
862
863fn is_bare_name(path: &str) -> bool {
864 !PATH_START_PREFIXES.iter().any(|prefix| path.starts_with(prefix)) && !path.contains('/')
865}
866
867fn file_link_existence(path: &Path) -> FileLinkExistence {
868 match path.metadata() {
869 Ok(metadata) if metadata.is_file() || metadata.is_dir() => FileLinkExistence::Existing,
870 _ => FileLinkExistence::Missing,
871 }
872}
873
874fn positive_number(raw: &str) -> Option<u32> {
875 let value = raw.parse::<u32>().ok()?;
876 (value > 0).then_some(value)
877}
878
879fn cell_text(cell: Option<&RenderCell>) -> Option<&str> {
880 let text = cell?.text();
881 (!text.is_empty() && text.chars().all(|ch| !ch.is_control())).then_some(text)
882}
883
884fn is_whitespace_text(text: &str) -> bool {
885 text.chars().all(char::is_whitespace)
886}
887
888fn is_reference_text(text: &str) -> bool {
889 !text.is_empty() && text.chars().all(|ch| !ch.is_whitespace() && !ch.is_control())
890}
891
892fn quoted_reference_delimiter(
893 frame_buffer: &FrameBuffer,
894 region: &PaneRegionSnapshot,
895 position: ClientMousePosition,
896) -> Option<char> {
897 let mut positions = Vec::new();
898 let mut current = self::previous_reference_position(frame_buffer, region, position);
899 while let Some(current_position) = current {
900 positions.push(current_position);
901 current = self::previous_reference_position(frame_buffer, region, current_position);
902 }
903 positions.reverse();
904
905 let mut delimiter = None;
906 let mut backslashes = 0_usize;
907 for position in positions {
908 let Some(text) = self::cell_text(frame_buffer.cell(position.row, position.col)) else {
909 continue;
910 };
911 if text == "\\" {
912 backslashes = backslashes.saturating_add(1);
913 continue;
914 }
915 if let Some(quote) = self::quote_char(text)
916 && backslashes.is_multiple_of(2)
917 && !self::is_word_internal_quote(frame_buffer, region, position)
918 {
919 if delimiter == Some(quote) {
920 delimiter = None;
921 } else if delimiter.is_none() {
922 delimiter = Some(quote);
923 }
924 }
925 backslashes = 0;
926 }
927 delimiter
928}
929
930fn quote_char(text: &str) -> Option<char> {
931 match text {
932 "'" => Some('\''),
933 "\"" => Some('"'),
934 _ => None,
935 }
936}
937
938fn quote_text_matches(text: &str, delimiter: Option<char>) -> bool {
939 delimiter.is_some_and(|delimiter| quote_char(text) == Some(delimiter))
940}
941
942fn is_word_internal_quote(
943 frame_buffer: &FrameBuffer,
944 region: &PaneRegionSnapshot,
945 position: ClientMousePosition,
946) -> bool {
947 let previous = self::previous_reference_position(frame_buffer, region, position)
948 .and_then(|position| self::cell_text(frame_buffer.cell(position.row, position.col)));
949 let next = self::next_reference_position(frame_buffer, region, position)
950 .and_then(|position| self::cell_text(frame_buffer.cell(position.row, position.col)));
951 previous.is_some_and(self::is_word_text) && next.is_some_and(self::is_word_text)
952}
953
954fn is_word_text(text: &str) -> bool {
955 !text.is_empty() && text.chars().all(|ch| ch.is_alphanumeric() || ch == '_')
956}
957
958fn is_escaped_space(frame_buffer: &FrameBuffer, region: &PaneRegionSnapshot, position: ClientMousePosition) -> bool {
959 let mut backslashes: usize = 0;
960 let mut current = self::previous_reference_position(frame_buffer, region, position);
961 while let Some(position) = current {
962 if self::cell_text(frame_buffer.cell(position.row, position.col)) != Some("\\") {
963 break;
964 }
965 backslashes = backslashes.saturating_add(1);
966 current = self::previous_reference_position(frame_buffer, region, position);
967 }
968 backslashes % 2 == 1
969}
970
971fn unescape_path(raw: &str) -> String {
972 let mut path = String::with_capacity(raw.len());
973 let mut chars = raw.chars().peekable();
974 while let Some(ch) = chars.next() {
975 if ch == '\\' && chars.peek().is_some_and(|next| matches!(next, ' ' | '\\' | '\'' | '"')) {
976 let _ = chars.next().map(|escaped| path.push(escaped));
977 } else {
978 path.push(ch);
979 }
980 }
981 path
982}
983
984#[cfg(test)]
985mod tests {
986 use muxr_core::PaneId;
987 use muxr_core::PaneMouseMode;
988 use muxr_core::RenderBaseline;
989 use muxr_core::RenderCell;
990 use muxr_core::RenderCursor;
991 use muxr_core::RenderCursorShape;
992 use muxr_core::RenderRowSpan;
993 use muxr_core::RenderStyle;
994 use muxr_core::RowWrap;
995 use muxr_core::TerminalSize;
996 use test_that::prelude::*;
997
998 use super::*;
999
1000 #[test]
1001 fn test_at_when_soft_wrap_has_continuation_indentation_returns_complete_reference() -> rootcause::Result<()> {
1002 let frame_buffer = self::frame_buffer(&["/tmp/foo", " :42:7 "])?;
1003 let region = PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, 8, 2, PaneMouseMode::None, 0)?
1004 .with_wrapped_rows(vec![RowWrap::EndsWithSoftWrap, RowWrap::EndsBeforeSoftWrap])?;
1005
1006 let link = frame_buffer.file_link_at(®ion, ClientMousePosition { row: 1, col: 4 });
1007 assert_that!(
1008 link,
1009 some(eq(FileLink {
1010 path: PathBuf::from("/tmp/foo"),
1011 line: Some(42),
1012 column: Some(7),
1013 }))
1014 );
1015 Ok(())
1016 }
1017
1018 #[test]
1019 fn test_at_resolved_when_soft_wrap_joins_adjacent_output_entries_rejects_joined_name() -> rootcause::Result<()> {
1020 let directory = tempfile::tempdir()?;
1021 let cwd = directory
1022 .path()
1023 .to_str()
1024 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1025 let frame_buffer = self::frame_buffer(&["first", "second.rs"])?;
1026 let region = PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, 10, 2, PaneMouseMode::None, 0)?
1027 .with_wrapped_rows(vec![RowWrap::EndsWithSoftWrap, RowWrap::EndsBeforeSoftWrap])?;
1028
1029 let link = frame_buffer.file_link_at_resolved(®ion, ClientMousePosition { row: 0, col: 2 }, cwd);
1030
1031 assert_that!(link, none());
1032 Ok(())
1033 }
1034
1035 #[test]
1036 fn test_at_resolved_when_soft_wrap_has_indentation_returns_existing_joined_path() -> rootcause::Result<()> {
1037 let directory = tempfile::tempdir()?;
1038 let path = directory.path().join("nested/firstsecond.rs");
1039 std::fs::create_dir_all(path.parent().expect("nested path has parent"))?;
1040 std::fs::write(&path, b"content")?;
1041 let cwd = directory
1042 .path()
1043 .to_str()
1044 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1045 let frame_buffer = self::frame_buffer(&["nested/first", " second.rs"])?;
1046 let region = PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, 12, 2, PaneMouseMode::None, 0)?
1047 .with_wrapped_rows(vec![RowWrap::EndsWithSoftWrap, RowWrap::EndsBeforeSoftWrap])?;
1048
1049 let link = frame_buffer.file_link_at_resolved(®ion, ClientMousePosition { row: 1, col: 5 }, cwd);
1050
1051 assert_that!(
1052 link,
1053 some(eq(FileLink {
1054 path,
1055 line: None,
1056 column: None,
1057 }))
1058 );
1059 Ok(())
1060 }
1061
1062 #[test]
1063 fn test_at_when_hard_row_boundary_ends_reference() -> rootcause::Result<()> {
1064 let frame_buffer = self::frame_buffer(&["/tmp/foo", ":42:7 "])?;
1065 let region = PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, 8, 2, PaneMouseMode::None, 0)?;
1066
1067 let link = frame_buffer.file_link_at(®ion, ClientMousePosition { row: 0, col: 7 });
1068 assert_that!(
1069 link,
1070 some(eq(FileLink {
1071 path: PathBuf::from("/tmp/foo"),
1072 line: None,
1073 column: None,
1074 }))
1075 );
1076 Ok(())
1077 }
1078
1079 #[test]
1080 fn test_parse_reference_when_location_and_punctuation_are_supplied_returns_trimmed_reference() {
1081 assert_eq!(
1082 parse_reference("../foo/baz:42:7,"),
1083 Some(("../foo/baz", Some(42), Some(7)))
1084 );
1085 }
1086
1087 #[test]
1088 fn test_parse_reference_when_bare_file_name_is_supplied_returns_reference() {
1089 assert_eq!(parse_reference("README.md"), Some(("README.md", None, None)));
1090 }
1091
1092 #[test]
1093 fn test_at_when_unicode_path_is_supplied_returns_reference() -> rootcause::Result<()> {
1094 let frame_buffer = self::frame_buffer(&["/tmp/über.rs"])?;
1095 let region = PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, 12, 1, PaneMouseMode::None, 0)?;
1096
1097 let link = frame_buffer.file_link_at(®ion, ClientMousePosition { row: 0, col: 5 });
1098 assert_that!(
1099 link,
1100 some(eq(FileLink {
1101 path: PathBuf::from("/tmp/über.rs"),
1102 line: None,
1103 column: None,
1104 }))
1105 );
1106 Ok(())
1107 }
1108
1109 #[test]
1110 fn test_at_when_wide_unicode_path_is_clicked_returns_reference() -> rootcause::Result<()> {
1111 let style = RenderStyle::default();
1112 let cells = vec![
1113 RenderCell::narrow("/", style),
1114 RenderCell::narrow("t", style),
1115 RenderCell::narrow("m", style),
1116 RenderCell::narrow("p", style),
1117 RenderCell::narrow("/", style),
1118 RenderCell::wide("界", style),
1119 RenderCell::wide_continuation(style),
1120 RenderCell::narrow(".", style),
1121 RenderCell::narrow("r", style),
1122 RenderCell::narrow("s", style),
1123 ];
1124 let frame_buffer = self::frame_buffer_from_cells(cells)?;
1125 let region = PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, 10, 1, PaneMouseMode::None, 0)?;
1126
1127 let link = frame_buffer.file_link_at(®ion, ClientMousePosition { row: 0, col: 6 });
1128 assert_that!(
1129 link,
1130 some(eq(FileLink {
1131 path: PathBuf::from("/tmp/界.rs"),
1132 line: None,
1133 column: None,
1134 }))
1135 );
1136 Ok(())
1137 }
1138
1139 #[test]
1140 fn test_at_when_markdown_link_is_clicked_returns_target_reference() -> rootcause::Result<()> {
1141 let text = "[src/foo.rs](src/foo.rs)";
1142 let frame_buffer = self::frame_buffer(&[text])?;
1143 let region = PaneRegionSnapshot::new(
1144 PaneId::new(1)?,
1145 0,
1146 0,
1147 u16::try_from(text.chars().count())?,
1148 1,
1149 PaneMouseMode::None,
1150 0,
1151 )?;
1152
1153 let link = frame_buffer.file_link_at(®ion, ClientMousePosition { row: 0, col: 5 });
1154 assert_that!(
1155 link,
1156 some(eq(FileLink {
1157 path: PathBuf::from("src/foo.rs"),
1158 line: None,
1159 column: None,
1160 }))
1161 );
1162 Ok(())
1163 }
1164
1165 #[test]
1166 fn test_at_when_cell_contains_combining_character_preserves_cell_text() -> rootcause::Result<()> {
1167 let style = RenderStyle::default();
1168 let cells = vec![
1169 RenderCell::narrow("/", style),
1170 RenderCell::narrow("t", style),
1171 RenderCell::narrow("m", style),
1172 RenderCell::narrow("p", style),
1173 RenderCell::narrow("/", style),
1174 RenderCell::narrow("c", style),
1175 RenderCell::narrow("a", style),
1176 RenderCell::narrow("f", style),
1177 RenderCell::narrow("e\u{301}", style),
1178 RenderCell::narrow(".", style),
1179 RenderCell::narrow("r", style),
1180 RenderCell::narrow("s", style),
1181 ];
1182 let frame_buffer = self::frame_buffer_from_cells(cells)?;
1183 let region = PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, 12, 1, PaneMouseMode::None, 0)?;
1184
1185 let link = frame_buffer.file_link_at(®ion, ClientMousePosition { row: 0, col: 8 });
1186 assert_that!(
1187 link,
1188 some(eq(FileLink {
1189 path: PathBuf::from("/tmp/cafe\u{301}.rs"),
1190 line: None,
1191 column: None,
1192 }))
1193 );
1194 Ok(())
1195 }
1196
1197 #[test]
1198 fn test_at_when_quoted_path_contains_spaces_returns_reference() -> rootcause::Result<()> {
1199 let text = r#""/tmp/a file.rs":42"#;
1200 let frame_buffer = self::frame_buffer(&[text])?;
1201 let region = PaneRegionSnapshot::new(
1202 PaneId::new(1)?,
1203 0,
1204 0,
1205 u16::try_from(text.chars().count())?,
1206 1,
1207 PaneMouseMode::None,
1208 0,
1209 )?;
1210
1211 let link = frame_buffer.file_link_at(®ion, ClientMousePosition { row: 0, col: 9 });
1212 assert_that!(
1213 link,
1214 some(eq(FileLink {
1215 path: PathBuf::from("/tmp/a file.rs"),
1216 line: Some(42),
1217 column: None,
1218 }))
1219 );
1220 Ok(())
1221 }
1222
1223 #[test]
1224 fn test_at_when_quoted_path_soft_wrap_has_indentation_returns_trimmed_reference() -> rootcause::Result<()> {
1225 let frame_buffer = self::frame_buffer(&[r#""/tmp/a"#, " file.rs\":42"])?;
1226 let region = PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, 14, 2, PaneMouseMode::None, 0)?
1227 .with_wrapped_rows(vec![RowWrap::EndsWithSoftWrap, RowWrap::EndsBeforeSoftWrap])?;
1228
1229 let link = frame_buffer.file_link_at(®ion, ClientMousePosition { row: 1, col: 5 });
1230 assert_that!(
1231 link,
1232 some(eq(FileLink {
1233 path: PathBuf::from("/tmp/afile.rs"),
1234 line: Some(42),
1235 column: None,
1236 }))
1237 );
1238 Ok(())
1239 }
1240
1241 #[test]
1242 fn test_at_when_escaped_path_contains_spaces_returns_reference() -> rootcause::Result<()> {
1243 let text = r"/tmp/a\ file.rs";
1244 let frame_buffer = self::frame_buffer(&[text])?;
1245 let region = PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, 15, 1, PaneMouseMode::None, 0)?;
1246
1247 let link = frame_buffer.file_link_at(®ion, ClientMousePosition { row: 0, col: 8 });
1248 assert_that!(
1249 link,
1250 some(eq(FileLink {
1251 path: PathBuf::from("/tmp/a file.rs"),
1252 line: None,
1253 column: None,
1254 }))
1255 );
1256 Ok(())
1257 }
1258
1259 #[test]
1260 fn test_at_when_click_is_between_quoted_references_does_not_join_them() -> rootcause::Result<()> {
1261 let text = r#""foo" text "bar.rs""#;
1262 let frame_buffer = self::frame_buffer(&[text])?;
1263 let region = PaneRegionSnapshot::new(
1264 PaneId::new(1)?,
1265 0,
1266 0,
1267 u16::try_from(text.chars().count())?,
1268 1,
1269 PaneMouseMode::None,
1270 0,
1271 )?;
1272 let link = frame_buffer.file_link_at(®ion, ClientMousePosition { row: 0, col: 7 });
1273 assert_that!(
1274 link,
1275 some(eq(FileLink {
1276 path: PathBuf::from("text"),
1277 line: None,
1278 column: None,
1279 }))
1280 );
1281 Ok(())
1282 }
1283
1284 #[test]
1285 fn test_at_resolved_when_bare_file_name_contains_spaces_uses_cwd() -> rootcause::Result<()> {
1286 let directory = tempfile::tempdir()?;
1287 let path = directory.path().join("a file.rs");
1288 std::fs::write(&path, b"content")?;
1289 let cwd = directory
1290 .path()
1291 .to_str()
1292 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1293 let text = "Open a file.rs now";
1294 let frame_buffer = self::frame_buffer(&[text])?;
1295 let region = PaneRegionSnapshot::new(
1296 PaneId::new(1)?,
1297 0,
1298 0,
1299 u16::try_from(text.chars().count())?,
1300 1,
1301 PaneMouseMode::None,
1302 0,
1303 )?;
1304
1305 let link = frame_buffer.file_link_at_resolved(
1306 ®ion,
1307 ClientMousePosition {
1308 row: 0,
1309 col: u16::try_from(text.find("file").expect("file name is present"))?,
1310 },
1311 cwd,
1312 );
1313 assert_that!(
1314 link,
1315 some(eq(FileLink {
1316 path,
1317 line: None,
1318 column: None,
1319 }))
1320 );
1321 Ok(())
1322 }
1323
1324 #[test]
1325 fn test_at_resolved_when_existing_path_contains_colon_and_trailing_punctuation_preserves_path()
1326 -> rootcause::Result<()> {
1327 let directory = tempfile::tempdir()?;
1328 let path = directory.path().join("name:with-colon!");
1329 std::fs::write(&path, b"content")?;
1330 let cwd = directory
1331 .path()
1332 .to_str()
1333 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1334 let text = "name:with-colon!";
1335 let frame_buffer = self::frame_buffer(&[text])?;
1336 let region = PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, 16, 1, PaneMouseMode::None, 0)?;
1337
1338 let link = frame_buffer.file_link_at_resolved(®ion, ClientMousePosition { row: 0, col: 6 }, cwd);
1339
1340 assert_that!(
1341 link,
1342 some(eq(FileLink {
1343 path,
1344 line: None,
1345 column: None,
1346 }))
1347 );
1348 Ok(())
1349 }
1350
1351 #[test]
1352 fn test_at_resolved_when_nonexistent_path_contains_numeric_colon_preserves_path() -> rootcause::Result<()> {
1353 let directory = tempfile::tempdir()?;
1354 let path = directory.path().join("report:2026");
1355 let text = path
1356 .to_str()
1357 .ok_or_else(|| rootcause::report!("temporary path is not UTF-8"))?;
1358 let cwd = directory
1359 .path()
1360 .to_str()
1361 .ok_or_else(|| rootcause::report!("temporary directory is not UTF-8"))?;
1362 let frame_buffer = self::frame_buffer(&[text])?;
1363 let region = PaneRegionSnapshot::new(
1364 PaneId::new(1)?,
1365 0,
1366 0,
1367 u16::try_from(text.chars().count())?,
1368 1,
1369 PaneMouseMode::None,
1370 0,
1371 )?;
1372
1373 assert_that!(
1374 frame_buffer.file_link_at_resolved(®ion, ClientMousePosition { row: 0, col: 3 }, cwd),
1375 some(eq(FileLink {
1376 path,
1377 line: None,
1378 column: None,
1379 }))
1380 );
1381 Ok(())
1382 }
1383
1384 #[test]
1385 fn test_at_resolved_when_existing_path_precedes_numeric_colon_parses_location() -> rootcause::Result<()> {
1386 let directory = tempfile::tempdir()?;
1387 std::fs::write(directory.path().join("report"), b"content")?;
1388 let cwd = directory
1389 .path()
1390 .to_str()
1391 .ok_or_else(|| rootcause::report!("temporary directory is not UTF-8"))?;
1392 let text = "report:2026";
1393 let frame_buffer = self::frame_buffer(&[text])?;
1394 let region = PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, 11, 1, PaneMouseMode::None, 0)?;
1395
1396 assert_that!(
1397 frame_buffer.file_link_at_resolved(®ion, ClientMousePosition { row: 0, col: 3 }, cwd),
1398 some(eq(FileLink {
1399 path: directory.path().join("report"),
1400 line: Some(2026),
1401 column: None,
1402 }))
1403 );
1404 Ok(())
1405 }
1406
1407 #[test]
1408 fn test_at_resolved_when_explicit_path_with_spaces_is_followed_by_prose_uses_existing_path() -> rootcause::Result<()>
1409 {
1410 let directory = tempfile::tempdir()?;
1411 let path = directory.path().join("a file.rs");
1412 std::fs::write(&path, b"content")?;
1413 let cwd = directory
1414 .path()
1415 .to_str()
1416 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1417 let text = format!("{} (line 1)", path.display());
1418 let frame_buffer = self::frame_buffer(&[&text])?;
1419 let region = PaneRegionSnapshot::new(
1420 PaneId::new(1)?,
1421 0,
1422 0,
1423 u16::try_from(text.chars().count())?,
1424 1,
1425 PaneMouseMode::None,
1426 0,
1427 )?;
1428
1429 let link = frame_buffer.file_link_at_resolved(
1430 ®ion,
1431 ClientMousePosition {
1432 row: 0,
1433 col: u16::try_from(
1434 text.find("file")
1435 .ok_or_else(|| rootcause::report!("file name not found"))?,
1436 )?,
1437 },
1438 cwd,
1439 );
1440 assert_that!(
1441 link,
1442 some(eq(FileLink {
1443 path,
1444 line: None,
1445 column: None,
1446 }))
1447 );
1448 Ok(())
1449 }
1450
1451 #[test]
1452 fn test_at_resolved_when_existing_path_follows_prose_uses_path() -> rootcause::Result<()> {
1453 let directory = tempfile::tempdir()?;
1454 let path = directory.path().join("existing.rs");
1455 std::fs::write(&path, b"content")?;
1456 let cwd = directory
1457 .path()
1458 .to_str()
1459 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1460 let text = format!("Open {}", path.display());
1461 let frame_buffer = self::frame_buffer(&[&text])?;
1462 let region = PaneRegionSnapshot::new(
1463 PaneId::new(1)?,
1464 0,
1465 0,
1466 u16::try_from(text.chars().count())?,
1467 1,
1468 PaneMouseMode::None,
1469 0,
1470 )?;
1471
1472 let link = frame_buffer.file_link_at_resolved(
1473 ®ion,
1474 ClientMousePosition {
1475 row: 0,
1476 col: u16::try_from(
1477 text.find("existing")
1478 .ok_or_else(|| rootcause::report!("file name not found"))?,
1479 )?,
1480 },
1481 cwd,
1482 );
1483
1484 assert_that!(
1485 link,
1486 some(eq(FileLink {
1487 path,
1488 line: None,
1489 column: None,
1490 }))
1491 );
1492 Ok(())
1493 }
1494
1495 #[test]
1496 fn test_at_resolved_when_path_component_with_spaces_precedes_slash_uses_path() -> rootcause::Result<()> {
1497 let directory = tempfile::tempdir()?;
1498 let path = directory.path().join("a directory/file.rs");
1499 let parent = path
1500 .parent()
1501 .ok_or_else(|| rootcause::report!("file path has no parent"))?;
1502 std::fs::create_dir_all(parent)?;
1503 std::fs::write(&path, b"content")?;
1504 let cwd = directory
1505 .path()
1506 .to_str()
1507 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1508 let relative_path = path
1509 .strip_prefix(directory.path())
1510 .map_err(|error| rootcause::report!("failed to make test path relative: {error}"))?;
1511 let text = format!("Open {} now", relative_path.display());
1512 let frame_buffer = self::frame_buffer(&[&text])?;
1513 let region = PaneRegionSnapshot::new(
1514 PaneId::new(1)?,
1515 0,
1516 0,
1517 u16::try_from(text.chars().count())?,
1518 1,
1519 PaneMouseMode::None,
1520 0,
1521 )?;
1522
1523 let link = frame_buffer.file_link_at_resolved(
1524 ®ion,
1525 ClientMousePosition {
1526 row: 0,
1527 col: u16::try_from(text.find("a directory").expect("path component is present"))?,
1528 },
1529 cwd,
1530 );
1531
1532 assert_that!(
1533 link,
1534 some(eq(FileLink {
1535 path,
1536 line: None,
1537 column: None,
1538 }))
1539 );
1540 Ok(())
1541 }
1542
1543 #[test]
1544 fn test_at_resolved_when_long_path_follows_prose_uses_complete_path() -> rootcause::Result<()> {
1545 let directory = tempfile::tempdir()?;
1546 let filename = (0..65)
1547 .map(|index| format!("w{}", index % 10))
1548 .collect::<Vec<_>>()
1549 .join(" ");
1550 let path = directory.path().join(filename);
1551 std::fs::write(&path, b"content")?;
1552 let cwd = directory
1553 .path()
1554 .to_str()
1555 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1556 let text = format!("Open {} now", path.display());
1557 let frame_buffer = self::frame_buffer(&[&text])?;
1558 let region = PaneRegionSnapshot::new(
1559 PaneId::new(1)?,
1560 0,
1561 0,
1562 u16::try_from(text.chars().count())?,
1563 1,
1564 PaneMouseMode::None,
1565 0,
1566 )?;
1567
1568 let link = frame_buffer.file_link_at_resolved(
1569 ®ion,
1570 ClientMousePosition {
1571 row: 0,
1572 col: u16::try_from(
1573 text.find("w6")
1574 .ok_or_else(|| rootcause::report!("long path word is not present"))?,
1575 )?,
1576 },
1577 cwd,
1578 );
1579
1580 assert_that!(
1581 link,
1582 some(eq(FileLink {
1583 path,
1584 line: None,
1585 column: None,
1586 }))
1587 );
1588 Ok(())
1589 }
1590
1591 #[test]
1592 fn test_at_resolved_when_backtick_wrapped_path_contains_spaces_trims_wrapper() -> rootcause::Result<()> {
1593 let directory = tempfile::tempdir()?;
1594 let path = directory.path().join("a file.rs");
1595 std::fs::write(&path, b"content")?;
1596 let cwd = directory
1597 .path()
1598 .to_str()
1599 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1600 let text = format!("`{}`", path.display());
1601 let frame_buffer = self::frame_buffer(&[&text])?;
1602 let region = PaneRegionSnapshot::new(
1603 PaneId::new(1)?,
1604 0,
1605 0,
1606 u16::try_from(text.chars().count())?,
1607 1,
1608 PaneMouseMode::None,
1609 0,
1610 )?;
1611
1612 let link = frame_buffer.file_link_at_resolved(
1613 ®ion,
1614 ClientMousePosition {
1615 row: 0,
1616 col: u16::try_from(
1617 text.find("file")
1618 .ok_or_else(|| rootcause::report!("file name not found"))?,
1619 )?,
1620 },
1621 cwd,
1622 );
1623
1624 assert_that!(
1625 link,
1626 some(eq(FileLink {
1627 path,
1628 line: None,
1629 column: None,
1630 }))
1631 );
1632 Ok(())
1633 }
1634
1635 #[test]
1636 fn test_at_resolved_when_unquoted_nonexistent_name_is_followed_by_prose_rejects_ambiguous_reference()
1637 -> rootcause::Result<()> {
1638 let directory = tempfile::tempdir()?;
1639 let cwd = directory
1640 .path()
1641 .to_str()
1642 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1643 let text = "new file.rs (not yet created)";
1644 let frame_buffer = self::frame_buffer(&[text])?;
1645 let region = PaneRegionSnapshot::new(
1646 PaneId::new(1)?,
1647 0,
1648 0,
1649 u16::try_from(text.chars().count())?,
1650 1,
1651 PaneMouseMode::None,
1652 0,
1653 )?;
1654
1655 let link = frame_buffer.file_link_at_resolved(®ion, ClientMousePosition { row: 0, col: 1 }, cwd);
1656
1657 assert_that!(link, none());
1658 Ok(())
1659 }
1660
1661 #[test]
1662 fn test_at_resolved_when_markdown_link_is_followed_by_prose_uses_existing_target() -> rootcause::Result<()> {
1663 let directory = tempfile::tempdir()?;
1664 let path = directory.path().join("foo.rs");
1665 std::fs::write(&path, b"content")?;
1666 let cwd = directory
1667 .path()
1668 .to_str()
1669 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1670 let text = format!("[file]({}) description", path.display());
1671 let frame_buffer = self::frame_buffer(&[&text])?;
1672 let region = PaneRegionSnapshot::new(
1673 PaneId::new(1)?,
1674 0,
1675 0,
1676 u16::try_from(text.chars().count())?,
1677 1,
1678 PaneMouseMode::None,
1679 0,
1680 )?;
1681
1682 let link = frame_buffer.file_link_at_resolved(®ion, ClientMousePosition { row: 0, col: 1 }, cwd);
1683 assert_that!(
1684 link,
1685 some(eq(FileLink {
1686 path,
1687 line: None,
1688 column: None,
1689 }))
1690 );
1691 Ok(())
1692 }
1693
1694 #[test]
1695 fn test_file_link_resolve_from_cwd_when_bare_file_exists_uses_cwd() -> rootcause::Result<()> {
1696 let directory = tempfile::tempdir()?;
1697 let path = directory.path().join("README");
1698 std::fs::write(&path, b"content")?;
1699 let cwd = directory
1700 .path()
1701 .to_str()
1702 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1703 let link = FileLink {
1704 path: PathBuf::from("README"),
1705 line: None,
1706 column: None,
1707 };
1708
1709 assert_that!(
1710 link.resolve_from_cwd(cwd),
1711 eq(Some(FileLink {
1712 path,
1713 line: None,
1714 column: None,
1715 }))
1716 );
1717 Ok(())
1718 }
1719
1720 #[test]
1721 fn test_file_link_resolve_from_cwd_when_bare_directory_exists_uses_cwd() -> rootcause::Result<()> {
1722 let directory = tempfile::tempdir()?;
1723 let path = directory.path().join("src");
1724 std::fs::create_dir(&path)?;
1725 let cwd = directory
1726 .path()
1727 .to_str()
1728 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1729 let link = FileLink {
1730 path: PathBuf::from("src"),
1731 line: None,
1732 column: None,
1733 };
1734
1735 assert_that!(
1736 link.resolve_from_cwd(cwd),
1737 eq(Some(FileLink {
1738 path,
1739 line: None,
1740 column: None,
1741 }))
1742 );
1743 Ok(())
1744 }
1745
1746 #[test]
1747 fn test_file_link_resolve_from_cwd_when_bare_non_file_word_is_supplied_rejects_it() -> rootcause::Result<()> {
1748 let directory = tempfile::tempdir()?;
1749 let cwd = directory
1750 .path()
1751 .to_str()
1752 .ok_or_else(|| rootcause::report!("temporary test directory is not UTF-8"))?;
1753 let link = FileLink {
1754 path: PathBuf::from("status"),
1755 line: None,
1756 column: None,
1757 };
1758
1759 assert_that!(link.resolve_from_cwd(cwd), none());
1760 Ok(())
1761 }
1762
1763 #[test]
1764 fn test_file_link_resolve_from_cwd_when_path_is_relative_returns_absolute_path() {
1765 let link = FileLink {
1766 path: PathBuf::from("../new-file.rs"),
1767 line: Some(3),
1768 column: Some(4),
1769 };
1770 assert_eq!(
1771 link.resolve_from_cwd("/tmp/project"),
1772 Some(FileLink {
1773 path: PathBuf::from("/tmp/project/../new-file.rs"),
1774 line: Some(3),
1775 column: Some(4),
1776 })
1777 );
1778 }
1779
1780 fn frame_buffer(lines: &[&str]) -> rootcause::Result<FrameBuffer> {
1781 let cols = lines.iter().map(|line| line.chars().count()).max().unwrap_or(1);
1782 let size = TerminalSize::new(u16::try_from(cols)?, u16::try_from(lines.len())?)?;
1783 let rows = lines
1784 .iter()
1785 .enumerate()
1786 .map(|(row, line)| {
1787 let mut cells = line
1788 .chars()
1789 .map(|ch| RenderCell::narrow(ch.to_string(), RenderStyle::default()))
1790 .collect::<Vec<_>>();
1791 while cells.len() < cols {
1792 cells.push(RenderCell::narrow(" ", RenderStyle::default()));
1793 }
1794 RenderRowSpan::new(u16::try_from(row)?, 0, cells)
1795 })
1796 .collect::<rootcause::Result<Vec<_>>>()?;
1797 self::frame_buffer_from_rows(size, rows)
1798 }
1799
1800 fn frame_buffer_from_cells(cells: Vec<RenderCell>) -> rootcause::Result<FrameBuffer> {
1801 let size = TerminalSize::new(u16::try_from(cells.len())?, 1)?;
1802 let rows = vec![RenderRowSpan::new(0, 0, cells)?];
1803 self::frame_buffer_from_rows(size, rows)
1804 }
1805
1806 fn frame_buffer_from_rows(size: TerminalSize, rows: Vec<RenderRowSpan>) -> rootcause::Result<FrameBuffer> {
1807 let update = muxr_core::RenderUpdate::Baseline(RenderBaseline::new(
1808 1,
1809 size,
1810 RenderCursor {
1811 row: 0,
1812 col: 0,
1813 shape: RenderCursorShape::Default,
1814 visibility: muxr_core::RenderCursorVisibility::Visible,
1815 },
1816 rows,
1817 )?);
1818 let mut frame_buffer = FrameBuffer::default();
1819 frame_buffer.apply(update)?;
1820 Ok(frame_buffer)
1821 }
1822}