diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bacb6f..f964849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index 61795b7..0000000 --- a/src/client.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Module to manage environment about client -use std::fmt::Display; -use std::sync::OnceLock; - -use tokio::sync::RwLock; - -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 ClientMetadata { - pub editor: Editor, - pub support_document_link: bool, -} - -impl Default for ClientMetadata { - fn default() -> Self { - Self { - editor: Editor::Unknown("unknown".into()), - support_document_link: true, - } - } -} - -impl Display for ClientMetadata { - 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 - ) - } -} - -static ENV: OnceLock> = OnceLock::new(); - -pub async fn set_client(client: ClientMetadata) { - let lock = ENV.get_or_init(|| RwLock::new(ClientMetadata::default())); - let mut guard = lock.write().await; - *guard = client; -} - -pub async fn get_client() -> ClientMetadata { - let lock = ENV.get_or_init(|| RwLock::new(ClientMetadata::default())); - lock.read().await.clone() -} diff --git a/src/document/language.rs b/src/document/language.rs index 778be8e..9a33ab9 100644 --- a/src/document/language.rs +++ b/src/document/language.rs @@ -23,6 +23,8 @@ pub enum Language { dart, deno, dockerfile, + #[strum(serialize = "dockercompose")] + docker_compose, elixir, elm, emmet, diff --git a/src/editor_info.rs b/src/editor_info.rs new file mode 100644 index 0000000..34ea71c --- /dev/null +++ b/src/editor_info.rs @@ -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, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 0d5095e..20ac42f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,9 +4,9 @@ //! //! 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; @@ -14,6 +14,7 @@ mod parser; mod providers; mod resolver; mod server; +mod server_info; #[doc(hidden)] pub use crate::server::PathServer; #[doc(hidden)] diff --git a/src/parser/path/mod.rs b/src/parser/path/mod.rs index 34200dc..b8aa706 100644 --- a/src/parser/path/mod.rs +++ b/src/parser/path/mod.rs @@ -41,6 +41,11 @@ fn extract_paths_from_string(path_ref: PathCandidate) -> Vec { // 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(&[':'])); + results } @@ -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 { diff --git a/src/parser/path/regex.rs b/src/parser/path/regex.rs index 0b0d832..f028f8e 100644 --- a/src/parser/path/regex.rs +++ b/src/parser/path/regex.rs @@ -9,6 +9,7 @@ pub fn extract_string(document: &Document) -> Option> { 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))) @@ -16,13 +17,22 @@ pub fn extract_string(document: &Document) -> Option> { 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) @@ -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;"; diff --git a/src/providers/document_link.rs b/src/providers/document_link.rs index 0fe52ec..cca1fdc 100644 --- a/src/providers/document_link.rs +++ b/src/providers/document_link.rs @@ -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::*; @@ -13,9 +12,7 @@ pub async fn provide_document_links( config: &Config, workspace_roots: &[String], ) -> PathServerResult> { - 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() diff --git a/src/server.rs b/src/server.rs index 27a7b10..deb99c1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,30 +1,32 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; +use std::sync::OnceLock; use std::time::Instant; use tokio::sync::RwLock; use tower_lsp_server::jsonrpc; use tower_lsp_server::ls_types; -use crate::client::{ClientMetadata, Editor, get_client, set_client}; use crate::config; use crate::document::Document; +use crate::editor_info::EditorInfo; use crate::error::*; use crate::fs; use crate::logger::{self}; use crate::parser::tree_sitter_supported; use crate::providers; +use crate::server_info::ServerInfo; use crate::{lsp_debug, lsp_error, lsp_info}; -const VERSION: &str = env!("CARGO_PKG_VERSION"); - #[derive(Debug)] pub struct PathServer { client: tower_lsp_server::Client, workspace_roots: RwLock>, /// file path -> document documents: RwLock>, + editor_info: OnceLock, + server_info: OnceLock, config_cache: RwLock>>, } @@ -35,6 +37,8 @@ impl PathServer { client, workspace_roots: RwLock::new(HashSet::new()), documents: RwLock::new(HashMap::new()), + editor_info: OnceLock::new(), + server_info: OnceLock::new(), config_cache: RwLock::new(None), } } @@ -54,33 +58,6 @@ impl PathServer { *guard = Some(Arc::new(cfg)); } - pub fn parse_editor_env(&self, params: &ls_types::InitializeParams) -> Editor { - let Some(options) = ¶ms.initialization_options else { - return Editor::Unknown("unknown".into()); - }; - let Some(editor) = options.get("editor") else { - return Editor::Unknown("unknown".into()); - }; - if let Some(editor_str) = editor.as_str() { - return Editor::from(editor_str); - } - Editor::Unknown("unknown".into()) - } - - pub fn parse_client_env(&self, params: &ls_types::InitializeParams) -> ClientMetadata { - let editor = self.parse_editor_env(params); - let support_document_link = params - .capabilities - .text_document - .as_ref() - .and_then(|td| td.document_link.clone()) - .is_some(); - ClientMetadata { - editor, - support_document_link, - } - } - pub async fn workspace_paths(&self) -> Vec { let lock_guard = self.workspace_roots.read().await; lock_guard @@ -104,14 +81,18 @@ impl tower_lsp_server::LanguageServer for PathServer { params: ls_types::InitializeParams, ) -> jsonrpc::Result { lsp_info!("Initializing Path Server...").await; - // set editor env - let client_env = self.parse_client_env(¶ms); - lsp_info!("Client Env: {}", client_env).await; - set_client(client_env).await; + // set editor info + let editor_info = EditorInfo::from_initialize_params(¶ms); + lsp_info!("Editor Info: {}", editor_info).await; + self.editor_info.set(editor_info).unwrap(); + // set server info + let server_info = ServerInfo::new(); + lsp_info!("Server Info: {}", server_info).await; + self.server_info.set(server_info).unwrap(); // get workspace roots - // for backward compatibility #[allow(deprecated)] if let Some(uri) = params.root_uri { + // for backward compatibility let mut roots = self.workspace_roots.write().await; roots.insert(uri); } @@ -163,12 +144,6 @@ impl tower_lsp_server::LanguageServer for PathServer { async fn initialized(&self, _: ls_types::InitializedParams) { lsp_info!("Path Server initialized").await; - lsp_info!("Path Server version: {}", VERSION).await; - if cfg!(debug_assertions) { - lsp_info!("Running in debug mode").await; - } else { - lsp_info!("Running in release mode").await; - } } async fn shutdown(&self) -> jsonrpc::Result<()> { @@ -330,8 +305,11 @@ impl tower_lsp_server::LanguageServer for PathServer { ) -> jsonrpc::Result>> { let start = Instant::now(); let config = self.get_config().await; - let client = get_client().await; - if !client.support_document_link { + let editor_info = self + .editor_info + .get() + .expect("Editor info must be initialized"); + if !editor_info.support_document_link { lsp_info!("[Document Link] Client does not support document link").await; return Ok(None); }; @@ -447,9 +425,12 @@ impl tower_lsp_server::LanguageServer for PathServer { params.text_document_position_params.position.character ) .await; - let client = get_client().await; + let editor_info = self + .editor_info + .get() + .expect("Editor info must be initialized"); let config = self.get_config().await; - if client.support_document_link && config.highlight.enable { + if editor_info.support_document_link && config.highlight.enable { lsp_info!("[Hover] Client support document link and highlight is enabled, provide nothing to avoid duplicated hover item in {:?}", start.elapsed()).await; return Ok(None); }; diff --git a/src/server_info.rs b/src/server_info.rs new file mode 100644 index 0000000..d5c3112 --- /dev/null +++ b/src/server_info.rs @@ -0,0 +1,37 @@ +use std::fmt::Display; + +use strum_macros::{Display, EnumString}; + +#[derive(EnumString, Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Display)] +pub enum ServerMode { + Debug, + Release, +} + +#[derive(Debug, Clone)] +pub struct ServerInfo { + version: String, + mode: ServerMode, +} + +impl Display for ServerInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "\n version: {}\n mode: {}", + self.version, self.mode + ) + } +} + +impl ServerInfo { + pub fn new() -> ServerInfo { + let version = env!("CARGO_PKG_VERSION").to_string(); + let mode = if cfg!(debug_assertions) { + ServerMode::Debug + } else { + ServerMode::Release + }; + ServerInfo { version, mode } + } +}