Skip to main content

muxr_client/
file_links.rs

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        // A bare word is ambiguous in TUI prose. Require it to name an existing file or directory when it has no
86        // extension, while allowing extension-bearing names so links to files that are about to be created still work.
87        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
244// Path markers keep candidate expansion linear in the visible reference; bare names use the pane cwd below because
245// they have no lexical marker that can distinguish them from surrounding prose.
246fn 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    // An unresolved raw path wins over a parsed location. This preserves valid names such as `report:2026` while
399    // existing `report:2026` references still resolve as a location when `report` exists. Two numeric suffixes remain
400    // an unambiguous `path:line:column` reference even when the target file is not present yet.
401    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(&region, 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(&region, 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(&region, 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(&region, 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(&region, 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(&region, 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(&region, 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(&region, 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(&region, 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(&region, 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(&region, 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(&region, 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            &region,
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(&region, 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(&region, 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(&region, 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            &region,
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            &region,
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            &region,
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            &region,
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            &region,
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(&region, 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(&region, 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}