fix(acp): read L10-L20 and L10 line fragments on resource links

parse_line_range only understood L207:219 and L207-219, so a link to L207-L219 (GitHub style) or a single line L207 had no range and the whole file went to the model instead of the selection. It now accepts the same forms Zed's mention parser does, and a range that starts at 0 or runs backwards is treated as no range. Zed itself sends L<start>:<end>, which already worked; the query Zed adds (?column=, ?symbol=) never reached the path because url::Url splits it off. Tests now cover both. Co-Authored-By: siGit Code v1.6.3 <noreply@sigit.si> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 8, 2026 at 14:38 UTC 23c26e1b1e2cfcef388449201c07fe3aa6b9689b
2 files changed +53 -6
CHANGELOG.md
+6
@@ -28,6 +28,12 @@
28
29 ### Fixed
30
31 +- **A linked selection written as `L10-L20` or `L10` reads just those lines.**
32 + siGit Code read the line range of a `resource_link` only as `L10:20` or
33 + `L10-20`, so `L10-L20` (the GitHub style) and a single line `L10` sent the
34 + whole file instead of the selection. It now reads the same forms Zed's own
35 + mention parser does, and a range that starts at line 0 or runs backwards
36 + counts as no range.
37 - **Closing a thread that is waiting at a permission prompt no longer hangs.**
38 `session/close` cancels the session's running turn and waits for it to end,
39 but a turn stopped at a permission prompt only went on when the client
src/main.rs
+47 -6
@@ -4945,14 +4945,20 @@ fn parse_file_link(uri: &str) -> Option<(PathBuf, Option<(usize, usize)>)> {
4945 Some((path, range))
4946 }
4947
4948 -/// Parses a `L207:219` or `L207-219` fragment into `(207, 219)`.
4948 +/// Parses a line fragment into a 1-based inclusive `(start, end)`: `L207:219`
4949 +/// (what Zed sends), `L207-219`, `L207-L219` (GitHub style), or a single line
4950 +/// `L207`. These are the forms Zed's own mention parser accepts. A range that
4951 +/// starts at 0 or runs backwards is no range, so the whole file is read.
4952 fn parse_line_range(fragment: &str) -> Option<(usize, usize)> {
4953 let rest = fragment.strip_prefix('L')?;
4951 - let sep = if rest.contains(':') { ':' } else { '-' };
4952 - let mut parts = rest.splitn(2, sep);
4953 - let start = parts.next()?.parse::<usize>().ok()?;
4954 - let end = parts.next()?.parse::<usize>().ok()?;
4955 - Some((start, end))
4954 + let (start, end) = rest
4955 + .split_once(':')
4956 + .or_else(|| rest.split_once('-'))
4957 + .unwrap_or((rest, rest));
4958 + let end = end.strip_prefix('L').unwrap_or(end);
4959 + let start = start.parse::<usize>().ok()?;
4960 + let end = end.parse::<usize>().ok()?;
4961 + (start >= 1 && end >= start).then_some((start, end))
4962 }
4963
4964 /// ACP clients may prepend context as separate text blocks before the user's
@@ -6246,6 +6252,41 @@ mod tests {
6252 assert_eq!(range, Some((3, 5)));
6253 }
6254
6255 + #[test]
6256 + fn line_fragments_accept_the_forms_editors_send() {
6257 + assert_eq!(parse_line_range("L207:219"), Some((207, 219)));
6258 + assert_eq!(parse_line_range("L207-219"), Some((207, 219)));
6259 + assert_eq!(parse_line_range("L207-L219"), Some((207, 219)));
6260 + assert_eq!(parse_line_range("L7"), Some((7, 7)));
6261 + for no_range in ["L0:3", "L5:2", "207:219", "Lx", "L", ""] {
6262 + assert_eq!(parse_line_range(no_range), None, "{no_range}");
6263 + }
6264 + }
6265 +
6266 + /// Zed adds `?column=` to a selection and `?symbol=` to a symbol; the query
6267 + /// must not end up in the path.
6268 + #[cfg(unix)]
6269 + #[test]
6270 + fn file_links_ignore_the_query_zed_adds() {
6271 + let (path, range) = parse_file_link("file:///tmp/a.rs?column=5#L10:20").unwrap();
6272 + assert_path_components(&path, "/tmp/a.rs");
6273 + assert_eq!(range, Some((10, 20)));
6274 + let (path, range) = parse_file_link("file:///tmp/a.rs?symbol=main#L3:9").unwrap();
6275 + assert_path_components(&path, "/tmp/a.rs");
6276 + assert_eq!(range, Some((3, 9)));
6277 + }
6278 +
6279 + #[cfg(windows)]
6280 + #[test]
6281 + fn file_links_ignore_the_query_zed_adds() {
6282 + let (path, range) = parse_file_link("file:///C:/tmp/a.rs?column=5#L10:20").unwrap();
6283 + assert_path_components(&path, "C:/tmp/a.rs");
6284 + assert_eq!(range, Some((10, 20)));
6285 + let (path, range) = parse_file_link("file:///C:/tmp/a.rs?symbol=main#L3:9").unwrap();
6286 + assert_path_components(&path, "C:/tmp/a.rs");
6287 + assert_eq!(range, Some((3, 9)));
6288 + }
6289 +
6290 #[test]
6291 fn file_links_reject_non_file_uris() {
6292 assert!(parse_file_link("https://example.com/a.rs").is_none());