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