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
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}