pub fn path_to_url(path: &std::path::Path) -> Option<String> {
let hostname = if cfg!(windows) {
None
} else {
crate::hostname().ok().and_then(|os| os.into_string().ok())
};
if path.is_dir() {
dir_to_url(hostname.as_deref(), path)
} else {
file_to_url(hostname.as_deref(), path)
}
}
pub fn file_to_url(hostname: Option<&str>, path: &std::path::Path) -> Option<String> {
let mut url = "file://".to_owned();
if let Some(hostname) = hostname {
url.push_str(hostname);
}
encode_path(path, &mut url);
Some(url)
}
pub fn dir_to_url(hostname: Option<&str>, path: &std::path::Path) -> Option<String> {
let mut url = file_to_url(hostname, path)?;
if !url.ends_with(URL_PATH_SEP) {
url.push_str(URL_PATH_SEP);
}
Some(url)
}
const URL_PATH_SEP: &str = "/";
const FRAGMENT: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
.add(b' ')
.add(b'"')
.add(b'<')
.add(b'>')
.add(b'`');
const PATH: &percent_encoding::AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}');
const PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH.add(b'/').add(b'%');
const SPECIAL_PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH_SEGMENT.add(b'\\');
#[allow(missing_docs)]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Editor {
Cursor,
Grepp,
Kitty,
MacVim,
TextMate,
VSCode,
VSCodeInsiders,
VSCodium,
}
impl Editor {
pub fn all() -> impl Iterator<Item = Self> {
[
Self::Cursor,
Self::Grepp,
Self::Kitty,
Self::MacVim,
Self::TextMate,
Self::VSCode,
Self::VSCodeInsiders,
Self::VSCodium,
]
.into_iter()
}
pub fn to_url(
&self,
hostname: Option<&str>,
file: &std::path::Path,
line: usize,
col: usize,
) -> Option<String> {
let mut path = String::new();
encode_path(file, &mut path);
let url = match self {
Self::Cursor => {
format!("cursor://file{path}:{line}:{col}")
}
Self::Grepp => format!("grep+://{path}:{line}"),
Self::Kitty => format!("file://{}{path}#{line}", hostname.unwrap_or_default()),
Self::MacVim => {
format!("mvim://open?url=file://{path}&line={line}&column={col}")
}
Self::TextMate => {
format!("txmt://open?url=file://{path}&line={line}&column={col}")
}
Self::VSCode => format!("vscode://file{path}:{line}:{col}"),
Self::VSCodeInsiders => {
format!("vscode-insiders://file{path}:{line}:{col}")
}
Self::VSCodium => format!("vscodium://file{path}:{line}:{col}"),
};
Some(url)
}
}
impl core::fmt::Display for Editor {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let name = match self {
Self::Cursor => "cursor",
Self::Grepp => "grepp",
Self::Kitty => "kitty",
Self::MacVim => "macvim",
Self::TextMate => "textmate",
Self::VSCode => "vscode",
Self::VSCodeInsiders => "vscode-insiders",
Self::VSCodium => "vscodium",
};
f.write_str(name)
}
}
impl core::str::FromStr for Editor {
type Err = ParseEditorError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"cursor" => Ok(Self::Cursor),
"grepp" => Ok(Self::Grepp),
"kitty" => Ok(Self::Kitty),
"macvim" => Ok(Self::MacVim),
"textmate" => Ok(Self::TextMate),
"vscode" => Ok(Self::VSCode),
"vscode-insiders" => Ok(Self::VSCodeInsiders),
"vscodium" => Ok(Self::VSCodium),
_ => Err(ParseEditorError),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ParseEditorError;
impl core::fmt::Display for ParseEditorError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("unknown editor")
}
}
fn encode_path(path: &std::path::Path, url: &mut String) {
let mut is_path_empty = true;
for component in path.components() {
is_path_empty = false;
match component {
std::path::Component::Prefix(prefix) => {
url.push_str(URL_PATH_SEP);
let component = prefix.as_os_str().to_string_lossy();
url.push_str(&component);
}
std::path::Component::RootDir => {}
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
url.push_str(URL_PATH_SEP);
url.push_str("..");
}
std::path::Component::Normal(part) => {
url.push_str(URL_PATH_SEP);
let component = part.to_string_lossy();
url.extend(percent_encoding::percent_encode(
component.as_bytes(),
SPECIAL_PATH_SEGMENT,
));
}
}
}
if is_path_empty {
url.push_str(URL_PATH_SEP);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn funky_file_path() {
let editor_urls = Editor::all()
.map(|editor| editor.to_url(None, "/tmp/a b#c".as_ref(), 1, 1))
.map(|editor| editor.unwrap_or_else(|| "-".to_owned()))
.collect::<Vec<_>>()
.join("\n");
snapbox::assert_data_eq!(
editor_urls,
snapbox::str![[r#"
cursor://file/tmp/a%20b%23c:1:1
grep+:///tmp/a%20b%23c:1
file:///tmp/a%20b%23c#1
mvim://open?url=file:///tmp/a%20b%23c&line=1&column=1
txmt://open?url=file:///tmp/a%20b%23c&line=1&column=1
vscode://file/tmp/a%20b%23c:1:1
vscode-insiders://file/tmp/a%20b%23c:1:1
vscodium://file/tmp/a%20b%23c:1:1
"#]]
);
}
#[test]
fn with_hostname() {
let editor_urls = Editor::all()
.map(|editor| editor.to_url(Some("localhost"), "/home/foo/file.txt".as_ref(), 1, 1))
.map(|editor| editor.unwrap_or_else(|| "-".to_owned()))
.collect::<Vec<_>>()
.join("\n");
snapbox::assert_data_eq!(
editor_urls,
snapbox::str![[r#"
cursor://file/home/foo/file.txt:1:1
grep+:///home/foo/file.txt:1
file://localhost/home/foo/file.txt#1
mvim://open?url=file:///home/foo/file.txt&line=1&column=1
txmt://open?url=file:///home/foo/file.txt&line=1&column=1
vscode://file/home/foo/file.txt:1:1
vscode-insiders://file/home/foo/file.txt:1:1
vscodium://file/home/foo/file.txt:1:1
"#]]
);
}
#[test]
#[cfg(windows)]
fn windows_file_path() {
let editor_urls = Editor::all()
.map(|editor| editor.to_url(None, "C:\\Users\\foo\\help.txt".as_ref(), 1, 1))
.map(|editor| editor.unwrap_or_else(|| "-".to_owned()))
.collect::<Vec<_>>()
.join("\n");
snapbox::assert_data_eq!(
editor_urls,
snapbox::str![[r#"
cursor://file/C:/Users/foo/help.txt:1:1
grep+:///C:/Users/foo/help.txt:1
file:///C:/Users/foo/help.txt#1
mvim://open?url=file:///C:/Users/foo/help.txt&line=1&column=1
txmt://open?url=file:///C:/Users/foo/help.txt&line=1&column=1
vscode://file/C:/Users/foo/help.txt:1:1
vscode-insiders://file/C:/Users/foo/help.txt:1:1
vscodium://file/C:/Users/foo/help.txt:1:1
"#]]
);
}
#[test]
fn editor_strings_round_trip() {
let editors = Editor::all().collect::<Vec<_>>();
let parsed = editors
.iter()
.map(|editor| editor.to_string().parse())
.collect::<Result<Vec<_>, _>>();
assert_eq!(parsed, Ok(editors));
}
#[test]
fn invalid_editor_string_errors() {
assert_eq!("code".parse::<Editor>(), Err(ParseEditorError));
}
}