parley/domain/
reference.rs1#[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}