Files
zed/crates/markdown/src/path_range.rs
Max Brunsfeld 03f9cf4414 Represent relative paths using a dedicated, separator-agnostic type (#38744)
Closes https://github.com/zed-industries/zed/issues/38690
Closes #37353

### Background

On Windows, paths are normally separated by `\`, unlike mac and linux
where they are separated by `/`. When editing code in a project that
uses a different path style than your local system (e.g. remoting from
Windows to Linux, using WSL, and collaboration between windows and unix
users), the correct separator for a path may differ from the "native"
separator.

Previously, to work around this, Zed converted paths' separators in
numerous places. This was applied to both absolute and relative paths,
leading to incorrect conversions in some cases.

### Solution

Many code paths in Zed use paths that are *relative* to either a
worktree root or a git repository. This PR introduces a dedicated type
for these paths called `RelPath`, which stores the path in the same way
regardless of host platform, and offers `Path`-like manipulation APIs.
RelPath supports *displaying* the path using either separator, so that
we can display paths in a style that is determined at runtime based on
the current project.

The representation of absolute paths is left untouched, for now.
Absolute paths are different from relative paths because (except in
contexts where we know that the path refers to the local filesystem)
they should generally be treated as opaque strings. Currently we use a
mix of types for these paths (std::path::Path, String, SanitizedPath).

Release Notes:

- N/A

---------

Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
Co-authored-by: Peter Tripp <petertripp@gmail.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
Co-authored-by: Lukas Wirth <me@lukaswirth.dev>
2025-09-24 18:57:33 -04:00

246 lines
8.2 KiB
Rust

use std::{ops::Range, sync::Arc};
#[derive(Debug, Clone, PartialEq)]
pub struct PathWithRange {
pub path: Arc<str>,
pub range: Option<Range<LineCol>>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct LineCol {
pub line: u32,
pub col: Option<u32>,
}
impl std::fmt::Debug for LineCol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.col {
Some(col) => write!(f, "L{}:{}", self.line, col),
None => write!(f, "L{}", self.line),
}
}
}
impl LineCol {
pub fn new(str: impl AsRef<str>) -> Option<Self> {
let str = str.as_ref();
match str.split_once(':') {
Some((line, col)) => match (line.parse::<u32>(), col.parse::<u32>()) {
(Ok(line), Ok(col)) => Some(Self {
line,
col: Some(col),
}),
_ => None,
},
None => match str.parse::<u32>() {
Ok(line) => Some(Self { line, col: None }),
Err(_) => None,
},
}
}
}
impl PathWithRange {
// Note: We could try out this as an alternative, and see how it does on evals.
//
// The closest to a standard way of including a filename is this:
// ```rust filename="path/to/file.rs#42:43"
// ```
//
// or, alternatively,
// ```rust filename="path/to/file.rs" lines="42:43"
// ```
//
// Examples where it's used this way:
// - https://mdxjs.com/guides/syntax-highlighting/#syntax-highlighting-with-the-meta-field
// - https://docusaurus.io/docs/markdown-features/code-blocks
// - https://spec.commonmark.org/0.31.2/#example-143
pub fn new(str: impl AsRef<str>) -> Self {
let str = str.as_ref();
// Sometimes the model will include a language at the start,
// e.g. "```rust zed/crates/markdown/src/markdown.rs#L1"
// We just discard that.
let str = match str.trim_end().rfind(' ') {
Some(space) => &str[space + 1..],
None => str.trim_start(),
};
match str.rsplit_once('#') {
Some((path, after_hash)) => {
// Be tolerant to the model omitting the "L" prefix, lowercasing it,
// or including it more than once.
let after_hash = after_hash.replace(['L', 'l'], "");
let range = {
let mut iter = after_hash.split('-').flat_map(LineCol::new);
iter.next()
.map(|start| iter.next().map(|end| start..end).unwrap_or(start..start))
};
Self {
path: path.into(),
range,
}
}
None => Self {
path: str.into(),
range: None,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_linecol_parsing() {
let line_col = LineCol::new("10:5");
assert_eq!(
line_col,
Some(LineCol {
line: 10,
col: Some(5)
})
);
let line_only = LineCol::new("42");
assert_eq!(
line_only,
Some(LineCol {
line: 42,
col: None
})
);
assert_eq!(LineCol::new(""), None);
assert_eq!(LineCol::new("not a number"), None);
assert_eq!(LineCol::new("10:not a number"), None);
assert_eq!(LineCol::new("not:5"), None);
}
#[test]
fn test_pathrange_parsing() {
let path_range = PathWithRange::new("file.rs#L10-L20");
assert_eq!(path_range.path.as_ref(), "file.rs");
assert!(path_range.range.is_some());
if let Some(range) = path_range.range {
assert_eq!(range.start.line, 10);
assert_eq!(range.start.col, None);
assert_eq!(range.end.line, 20);
assert_eq!(range.end.col, None);
}
let single_line = PathWithRange::new("file.rs#L15");
assert_eq!(single_line.path.as_ref(), "file.rs");
assert!(single_line.range.is_some());
if let Some(range) = single_line.range {
assert_eq!(range.start.line, 15);
assert_eq!(range.end.line, 15);
}
let no_range = PathWithRange::new("file.rs");
assert_eq!(no_range.path.as_ref(), "file.rs");
assert!(no_range.range.is_none());
let lowercase = PathWithRange::new("file.rs#l5-l10");
assert_eq!(lowercase.path.as_ref(), "file.rs");
assert!(lowercase.range.is_some());
if let Some(range) = lowercase.range {
assert_eq!(range.start.line, 5);
assert_eq!(range.end.line, 10);
}
let complex = PathWithRange::new("src/path/to/file.rs#L100");
assert_eq!(complex.path.as_ref(), "src/path/to/file.rs");
assert!(complex.range.is_some());
}
#[test]
fn test_pathrange_from_str() {
let with_range = PathWithRange::new("file.rs#L10-L20");
assert!(with_range.range.is_some());
assert_eq!(with_range.path.as_ref(), "file.rs");
let without_range = PathWithRange::new("file.rs");
assert!(without_range.range.is_none());
let single_line = PathWithRange::new("file.rs#L15");
assert!(single_line.range.is_some());
}
#[test]
fn test_pathrange_leading_text_trimming() {
let with_language = PathWithRange::new("```rust file.rs#L10");
assert_eq!(with_language.path.as_ref(), "file.rs");
assert!(with_language.range.is_some());
if let Some(range) = with_language.range {
assert_eq!(range.start.line, 10);
}
let with_spaces = PathWithRange::new("``` file.rs#L10-L20");
assert_eq!(with_spaces.path.as_ref(), "file.rs");
assert!(with_spaces.range.is_some());
let with_words = PathWithRange::new("```rust code example file.rs#L15:10");
assert_eq!(with_words.path.as_ref(), "file.rs");
assert!(with_words.range.is_some());
if let Some(range) = with_words.range {
assert_eq!(range.start.line, 15);
assert_eq!(range.start.col, Some(10));
}
let with_whitespace = PathWithRange::new(" file.rs#L5");
assert_eq!(with_whitespace.path.as_ref(), "file.rs");
assert!(with_whitespace.range.is_some());
let no_leading = PathWithRange::new("file.rs#L10");
assert_eq!(no_leading.path.as_ref(), "file.rs");
assert!(no_leading.range.is_some());
}
#[test]
fn test_pathrange_with_line_and_column() {
let line_and_col = PathWithRange::new("file.rs#L10:5");
assert_eq!(line_and_col.path.as_ref(), "file.rs");
assert!(line_and_col.range.is_some());
if let Some(range) = line_and_col.range {
assert_eq!(range.start.line, 10);
assert_eq!(range.start.col, Some(5));
assert_eq!(range.end.line, 10);
assert_eq!(range.end.col, Some(5));
}
let full_range = PathWithRange::new("file.rs#L10:5-L20:15");
assert_eq!(full_range.path.as_ref(), "file.rs");
assert!(full_range.range.is_some());
if let Some(range) = full_range.range {
assert_eq!(range.start.line, 10);
assert_eq!(range.start.col, Some(5));
assert_eq!(range.end.line, 20);
assert_eq!(range.end.col, Some(15));
}
let mixed_range1 = PathWithRange::new("file.rs#L10:5-L20");
assert_eq!(mixed_range1.path.as_ref(), "file.rs");
assert!(mixed_range1.range.is_some());
if let Some(range) = mixed_range1.range {
assert_eq!(range.start.line, 10);
assert_eq!(range.start.col, Some(5));
assert_eq!(range.end.line, 20);
assert_eq!(range.end.col, None);
}
let mixed_range2 = PathWithRange::new("file.rs#L10-L20:15");
assert_eq!(mixed_range2.path.as_ref(), "file.rs");
assert!(mixed_range2.range.is_some());
if let Some(range) = mixed_range2.range {
assert_eq!(range.start.line, 10);
assert_eq!(range.start.col, None);
assert_eq!(range.end.line, 20);
assert_eq!(range.end.col, Some(15));
}
}
}