Skip to main content

parley/domain/
reference.rs

1#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2pub struct FileReference {
3    pub raw: String,
4    pub path: String,
5    pub line: Option<u32>,
6    pub start_char: usize,
7    pub end_char: usize,
8}
9
10#[must_use]
11pub fn parse_file_references(input: &str) -> Vec<FileReference> {
12    let chars: Vec<char> = input.chars().collect();
13    let mut out = Vec::new();
14    let mut i = 0usize;
15    while i < chars.len() {
16        if chars[i] == '['
17            && let Some((reference, next_index)) = parse_markdown_reference(&chars, i)
18        {
19            out.push(reference);
20            i = next_index;
21            continue;
22        }
23
24        if chars[i] != '@' {
25            i += 1;
26            continue;
27        }
28
29        if i > 0 && is_identifier_char(chars[i - 1]) {
30            i += 1;
31            continue;
32        }
33
34        let start = i;
35        i += 1;
36        let path_start = i;
37        while i < chars.len() && is_path_char(chars[i]) {
38            i += 1;
39        }
40        if i == path_start {
41            continue;
42        }
43
44        let path: String = chars[path_start..i].iter().collect();
45        if !path.contains('/') && !path.contains('.') {
46            continue;
47        }
48
49        let mut line = None;
50        let mut end = i;
51        if i + 1 < chars.len() && chars[i] == ':' && chars[i + 1].is_ascii_digit() {
52            let line_start = i + 1;
53            let mut j = line_start;
54            while j < chars.len() && chars[j].is_ascii_digit() {
55                j += 1;
56            }
57            let line_text: String = chars[line_start..j].iter().collect();
58            if let Ok(value) = line_text.parse::<u32>() {
59                if value > 0 {
60                    line = Some(value);
61                    end = j;
62                    i = j;
63                } else {
64                    i = j;
65                }
66            } else {
67                i = j;
68            }
69        }
70
71        let raw: String = chars[start..end].iter().collect();
72        out.push(FileReference {
73            raw,
74            path,
75            line,
76            start_char: start,
77            end_char: end,
78        });
79    }
80    out
81}
82
83fn parse_markdown_reference(chars: &[char], start: usize) -> Option<(FileReference, usize)> {
84    let mut close_label = start + 1;
85    while close_label < chars.len() && chars[close_label] != ']' {
86        close_label += 1;
87    }
88    if close_label + 2 >= chars.len() || chars[close_label + 1] != '(' {
89        return None;
90    }
91
92    let mut close_target = close_label + 2;
93    while close_target < chars.len() && chars[close_target] != ')' {
94        close_target += 1;
95    }
96    if close_target >= chars.len() {
97        return None;
98    }
99
100    let target_start = close_label + 2;
101    let target: String = chars[target_start..close_target].iter().collect();
102    let (path, line) = parse_reference_target(target.trim())?;
103    let raw: String = chars[start..=close_target].iter().collect();
104    Some((
105        FileReference {
106            raw,
107            path,
108            line,
109            start_char: start,
110            end_char: close_target + 1,
111        },
112        close_target + 1,
113    ))
114}
115
116fn parse_reference_target(target: &str) -> Option<(String, Option<u32>)> {
117    if target.is_empty() {
118        return None;
119    }
120
121    let mut path_part = target;
122    let mut line = None;
123    if let Some((base, anchor)) = target.split_once('#') {
124        path_part = base;
125        let upper = anchor.to_ascii_uppercase();
126        if let Some(raw) = upper.strip_prefix('L')
127            && let Ok(value) = raw.parse::<u32>()
128            && value > 0
129        {
130            line = Some(value);
131        }
132    }
133
134    if line.is_none()
135        && let Some((base, raw_line)) = split_path_line_suffix(path_part)
136        && let Ok(value) = raw_line.parse::<u32>()
137        && value > 0
138    {
139        path_part = base;
140        line = Some(value);
141    }
142
143    let path = path_part.trim();
144    if path.is_empty() || (!path.contains('/') && !path.contains('.')) {
145        return None;
146    }
147    Some((path.to_string(), line))
148}
149
150fn split_path_line_suffix(path: &str) -> Option<(&str, &str)> {
151    let (base, line) = path.rsplit_once(':')?;
152    if line.chars().all(|ch| ch.is_ascii_digit()) {
153        Some((base, line))
154    } else {
155        None
156    }
157}
158
159fn is_identifier_char(ch: char) -> bool {
160    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/')
161}
162
163fn is_path_char(ch: char) -> bool {
164    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/')
165}
166
167#[cfg(test)]
168mod tests {
169    use super::parse_file_references;
170
171    #[test]
172    fn parses_path_with_line() {
173        let refs = parse_file_references("fix @src/tui/app/input.rs:30 now");
174        assert_eq!(refs.len(), 1);
175        assert_eq!(refs[0].path, "src/tui/app/input.rs");
176        assert_eq!(refs[0].line, Some(30));
177    }
178
179    #[test]
180    fn parses_markdown_link_reference() {
181        let refs = parse_file_references(
182            "changed [src/tui/app/input.rs](/workspace/parley/src/tui/app/input.rs#L30)",
183        );
184        assert_eq!(refs.len(), 1);
185        assert_eq!(refs[0].path, "/workspace/parley/src/tui/app/input.rs");
186        assert_eq!(refs[0].line, Some(30));
187    }
188
189    #[test]
190    fn ignores_non_paths() {
191        let refs = parse_file_references("@reviewer ping @AI resolved");
192        assert!(refs.is_empty());
193    }
194}