Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to the Path Server will be documented in this file.

## [Unreleased]
### Added
- **Core**: Added support for parsing bare path tokens delimited by whitespace (e.g., `src/ui/menu.ts` in YAML or other non-string contexts).
- **Core**: Added support for parsing paths from colon-delimited tokens, enabling docker-compose volume mount path extraction (e.g., `./src:/app/src`).

### Improved
- **Core**: Replaced `ClientMetadata` with `EditorInfo`/`ServerInfo` for better logging.

## [1.2.0] - 2026-05-08
Path Server is now published to crates.io! You can now install it via `cargo install path-server`.

Expand Down
53 changes: 0 additions & 53 deletions src/client.rs

This file was deleted.

2 changes: 2 additions & 0 deletions src/document/language.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub enum Language {
dart,
deno,
dockerfile,
#[strum(serialize = "dockercompose")]
docker_compose,
elixir,
elm,
emmet,
Expand Down
51 changes: 51 additions & 0 deletions src/editor_info.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use std::fmt::Display;

use tower_lsp_server::ls_types;

use strum_macros::{Display, EnumString};

#[derive(EnumString, Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Display)]
pub enum Editor {
Zed,
VSCode,
#[strum(default)]
Unknown(String),
}

#[derive(Debug, Clone)]
pub struct EditorInfo {
pub editor: Editor,
pub support_document_link: bool,
}

impl Display for EditorInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"\n editor: {}\n support_document_link: {}",
self.editor, self.support_document_link
)
}
}

impl EditorInfo {
pub fn from_initialize_params(params: &ls_types::InitializeParams) -> EditorInfo {
let editor = params
.initialization_options
.as_ref()
.and_then(|options| options.get("editor"))
.and_then(|editor| editor.as_str())
.map(Editor::from)
.unwrap_or_else(|| Editor::Unknown("unknown".into()));
let support_document_link = params
.capabilities
.text_document
.as_ref()
.and_then(|td| td.document_link.clone())
.is_some();
EditorInfo {
editor,
support_document_link,
}
}
}
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
//!
//! This crate is primarily designed to be distributed as a standalone binary. And has no intention to maintain as a library dependency for other projects for now.

mod client;
mod config;
mod document;
mod editor_info;
mod error;
mod fs;
mod logger;
mod parser;
mod providers;
mod resolver;
mod server;
mod server_info;
#[doc(hidden)]
pub use crate::server::PathServer;
#[doc(hidden)]
Expand Down
70 changes: 70 additions & 0 deletions src/parser/path/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ fn extract_paths_from_string(path_ref: PathCandidate) -> Vec<PathCandidate> {
// Level 2: the part of string (split by space) is a path or not
results.extend(path_ref.split(&[' ', '\n']));

// Level 3: the part of string (split by colon) is a path or not
// Handles docker-compose volume mounts (e.g., ./src:/app/src)
// and PATH-like environment variables (e.g., /usr/bin:/usr/local/bin)
results.extend(path_ref.split(&[':']));

Comment on lines +44 to +48
results
}

Expand Down Expand Up @@ -87,6 +92,71 @@ mod tests {
assert!(res.iter().any(|p| p.content == "/var/log/syslog"));
}

#[test]
fn test_extract_paths_from_volume_mount() {
// docker-compose volume mount: host_path:container_path
let candidate = PathCandidate {
content: "./src:/app/src".to_string(),
start_byte: 0,
end_byte: 14,
};
let res = extract_paths_from_string(candidate);
for p in &res {
eprintln!("Extracted: {};", p.content);
}
assert!(res.iter().any(|p| p.content == "./src"));
assert!(res.iter().any(|p| p.content == "/app/src"));
}

#[test]
fn test_extract_paths_from_absolute_volume_mount() {
let candidate = PathCandidate {
content: "/host/path:/container/path".to_string(),
start_byte: 0,
end_byte: 26,
};
let res = extract_paths_from_string(candidate);
for p in &res {
eprintln!("Extracted: {};", p.content);
}
assert!(res.iter().any(|p| p.content == "/host/path"));
assert!(res.iter().any(|p| p.content == "/container/path"));
}

#[test]
fn test_extract_paths_from_volume_mount_with_readonly() {
// docker-compose volume mount with :ro mode flag
let candidate = PathCandidate {
content: "./data:/app/data:ro".to_string(),
start_byte: 0,
end_byte: 19,
};
let res = extract_paths_from_string(candidate);
for p in &res {
eprintln!("Extracted: {};", p.content);
}
assert!(res.iter().any(|p| p.content == "./data"));
assert!(res.iter().any(|p| p.content == "/app/data"));
// "ro" should NOT be extracted (no path separator)
assert!(!res.iter().any(|p| p.content == "ro"));
}

#[test]
fn test_extract_paths_from_windows_absolute_not_broken() {
// Windows absolute path C:\Users\file.txt should not be broken by colon split
let candidate = PathCandidate {
content: "C:\\Users\\file.txt".to_string(),
start_byte: 0,
end_byte: 17,
};
let res = extract_paths_from_string(candidate);
for p in &res {
eprintln!("Extracted: {};", p.content);
}
// The whole path should be present
assert!(res.iter().any(|p| p.content == "C:\\Users\\file.txt"));
}

#[test]
fn test_extract_paths_with_trailing_spaces() {
let candidate = PathCandidate {
Expand Down
23 changes: 21 additions & 2 deletions src/parser/path/regex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,30 @@ pub fn extract_string(document: &Document) -> Option<Vec<PathCandidate>> {
r#""(?:[^"\\]|\\.)*""#, // match string in double quote, support escaped \"
r#"'(?:[^'\\]|\\.)*'"#, // match string in single quote, support escaped \'
r#"`(?:[^`\\]|\\.)*`"#, // match string in back tick, and support escaped \`
r#"\S*[/\\]\S*"#, // match token wrapped by space and contain at least one slash or backslash
];
let regex = Regex::new(&string_regexes.join("|"))
.map_err(|e| PathServerError::Unknown(format!("Failed to compile regex expression: {}", e)))
.unwrap();
let mut strings = vec![];
for matched in regex.find_iter(&document.text) {
let content = matched.as_str();
// slice quotes
if content.len() >= 2 {
let first_char = content.chars().next();
let is_quoted = matches!(first_char, Some('"' | '\'' | '`'));
if is_quoted && content.len() >= 2 {
// strip surrounding quotes for quoted strings
strings.push(PathCandidate {
content: content[1..content.len() - 1].to_string(),
start_byte: matched.start() + 1,
end_byte: matched.end() - 1,
})
} else if !is_quoted && !content.is_empty() {
// bare path tokens — use the match as-is
strings.push(PathCandidate {
content: content.to_string(),
start_byte: matched.start(),
end_byte: matched.end(),
})
}
}
Some(strings)
Expand All @@ -44,6 +54,15 @@ mod tests {
assert_eq!(res.len(), 3);
}

#[test]
fn test_extract_bare_path_tokens() {
let src = r#" - id: meta-lint-ci
name: Running src/ui/menu.ts to ensure changes will pass linting on CI"#;
let doc = Document::new(src.to_string(), "yaml").expect("failed to create Document");
let res = extract_string(&doc).unwrap_or_default();
assert!(res.iter().any(|p| p.content == "src/ui/menu.ts"));
}

#[test]
fn test_extract_no_strings() {
let src = "let x = 42;";
Expand Down
5 changes: 1 addition & 4 deletions src/providers/document_link.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use tower_lsp_server::ls_types;

use crate::client::get_client;
use crate::config::Config;
use crate::document::Document;
use crate::error::*;
Expand All @@ -13,9 +12,7 @@ pub async fn provide_document_links(
config: &Config,
workspace_roots: &[String],
) -> PathServerResult<Vec<ls_types::DocumentLink>> {
let client = get_client().await;
assert!(client.support_document_link);
assert!(config.highlight.enable); // these should be checked by server
assert!(config.highlight.enable); // this should be checked by server
let tokens = resolver::resolve_all(doc, config, workspace_roots, doc_parent).await?;
let filtered = tokens
.iter()
Expand Down
Loading
Loading