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());