diff --git a/.devcontainer/dotfiles/init.lua b/.devcontainer/dotfiles/init.lua index 307ffbc..f868981 100644 --- a/.devcontainer/dotfiles/init.lua +++ b/.devcontainer/dotfiles/init.lua @@ -4,19 +4,30 @@ vim.api.nvim_create_autocmd("FileType", { pattern = "proto", callback = function() local bin = vim.fn.getcwd() .. "/target/debug/protols" - if vim.fn.executable(bin) == 1 then - vim.lsp.start({ - name = "protols-dev", - cmd = { bin }, - root_dir = vim.fn.getcwd(), - init_options = { include_paths = { vim.fn.getcwd() } }, - on_init = function(client) - client.notify("$/setTrace", { value = "verbose" }) - end, - }) + local debug_port = os.getenv("LSP_DEBUG_PORT") + + local lsp_config = { + name = "protols-dev", + root_dir = vim.fn.getcwd(), + init_options = { include_paths = { vim.fn.getcwd() } }, + on_init = function(client) + client.notify("$/setTrace", { value = "verbose" }) + end, + } + + if debug_port and debug_port ~= "" then + lsp_config.cmd = vim.lsp.rpc.connect("127.0.0.1", tonumber(debug_port)) + vim.notify("protols-dev: connecting to port " .. debug_port, vim.log.levels.INFO) else - vim.notify("protols-dev: binary not found. Run 'cargo build' first!", 3) + if vim.fn.executable(bin) == 1 then + lsp_config.cmd = { bin } + else + vim.notify("protols-dev: binary not found. Run 'cargo build' first!", vim.log.levels.ERROR) + return + end end + + vim.lsp.start(lsp_config) end, }) diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..f9ec853 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug protols via TCP", + "program": "${workspaceFolder}/target/debug/protols", + "args": [ + "--port", + "${config:protols.debugPort}" + ], + "cwd": "${workspaceFolder}", + "sourceLanguages": [ + "rust" + ], + "terminal": "console", + "presentation": { + "hidden": false, + "group": "lsp-debug", + "order": 1 + }, + "preLaunchTask": "Neovim (TCP Debug Mode)", + "expressions": "native" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0650d18 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "protols.debugPort": "7301" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..fde7a00 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,104 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "icon": { + "id": "package" + }, + "type": "cargo", + "command": "build", + "problemMatcher": [ + "$rustc" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "label": "Rust: cargo build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": true + } + }, + { + "icon": { + "id": "check-all" + }, + "type": "cargo", + "command": "clippy", + "problemMatcher": [ + "$rustc" + ], + "group": "none", + "label": "Rust: cargo clippy", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": true + } + }, + { + "icon": { + "id": "beaker" + }, + "type": "cargo", + "command": "test", + "problemMatcher": [ + "$rustc" + ], + "group": { + "kind": "test", + "isDefault": true + }, + "label": "Rust: cargo test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": true + } + }, + { + "label": "Neovim (TCP Debug Mode)", + "icon": { + "id": "debug-console" + }, + "type": "process", + "command": "nvim", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "." + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".", + "endsPattern": "." + } + }, + "options": { + "env": { + "LSP_DEBUG_PORT": "${config:protols.debugPort}" + } + }, + "group": "none", + "presentation": { + "reveal": "always", + "panel": "dedicated", + "group": "lsp-debug", + "focus": true, + "close": false, + "clear": true, + } + } + ] +} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 5df4c8a..6cf87c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "protols" -description = "Language server for proto3 files" +description = "Language server for Protocol Buffers files" version = "0.13.4" edition = "2024" license = "MIT" diff --git a/README.md b/README.md index 0daf0a4..c223273 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ - [For Neovim](#for-neovim) - [Setting Include Paths in Neovim](#setting-include-paths-in-neovim) - [Command Line Options](#command-line-options) + - [Examples](#examples) - [For Visual Studio Code](#for-visual-studio-code) - [Configuration](#%EF%B8%8Fconfiguration) - [Sample `protols.toml`](#sample-protolstoml) @@ -45,6 +46,9 @@ - [Packaging](#-packaging) - [Contributing](#-contributing) - [Setting Up Locally](#setting-up-locally) + - [Option 1: Using Dev Containers (Easiest)](#option-1-using-dev-containers-easiest) + - [Option 2: Manual Setup](#option-2-manual-setup) + - [Debugging](#-debugging) - [License](#-license) --- @@ -87,19 +91,62 @@ require'lspconfig'.protols.setup{ Protols supports various command line options to customize its behavior: -``` -protols [OPTIONS] +```text +Usage: protols [OPTIONS] Options: - -i, --include-paths Include paths for proto files, comma-separated - -V, --version Print version information - -h, --help Print help information + -i, --include-paths Include paths for proto files, comma-separated (can be used multiple times) + -h, --help Print help + -V, --version Print version + +Transport: + --stdio Use stdin/stdout for communication (default) + --socket Use TCP communication with a specific address and port. Examples: "192.168.1.10:5005" or "0.0.0.0:5005" + --port Use TCP communication on localhost with a specific port. Example: "5005" + --pipe Use Unix domain socket (Linux/macOS) or Named Pipe (Windows). Examples: "/tmp/protols.sock" or "protols-pipe" (Windows) ``` -For example, to specify include paths when starting the language server: +#### Examples + +##### Specify include paths + +You can provide include paths using a comma-separated list or by repeating the +flag: ```bash protols --include-paths=/path/to/protos,/another/path/to/protos +# or +protols -i /path/to/protos -i /another/path/to/protos +``` + +##### Communication via TCP +TCP transport is useful when the language server and the IDE run in different +environments. + +- **Localhost only**: Connect within the same machine. + ```bash + protols --port 7301 + ``` +- **Container/Docker mode**: Listen on all interfaces to allow access from the + host machine to the container. + ```bash + protols --socket 0.0.0.0:7301 + ``` +- **Specific interface**: Listen on a specific network IP. + ```bash + protols --socket 192.168.1.10:7301 + ``` + +##### Communication via Unix Domain Socket +On Linux or macOS, you can use a socket file for communication: +```bash +protols --pipe /tmp/protols.sock +``` + +##### Communication via Named Pipes (Windows) +On Windows, you can specify a pipe name. `protols` handles the `\\.\pipe\` prefix automatically: +```bash +protols --pipe protols-pipe ``` ### For Visual Studio Code @@ -265,6 +312,12 @@ testing. cargo test ``` +### 🐞 Debugging + +If you want to contribute or debug the server logic, please refer to the +[Debugging Guide](docs/debugging.md) for instructions on setting up a TCP-based +debug session with VS Code and Neovim. + --- ## 📄 License diff --git a/docs/debugging.md b/docs/debugging.md new file mode 100644 index 0000000..d67c083 --- /dev/null +++ b/docs/debugging.md @@ -0,0 +1,54 @@ +# Debugging protols + +Since `protols` is a language server, debugging it directly via standard I/O can +be challenging. Furthermore, attaching a debugger to a running process inside a +Dev Container is often restricted by security policies. + +The recommended way to debug is to start the server in TCP mode and connect to +it with your LSP client. + +## Debugging with VS Code and Neovim + +The project includes a pre-configured `launch.json` for VS Code that automates +the debugging setup. + +### 1. Start the Debug Session + +In VS Code, go to the **Run and Debug** view and select **"Debug protols via +TCP"**. + +This will: +- Build the project in debug mode. +- Start the server listening on a TCP port (default: `7301`). +- Automatically open a new terminal with **Neovim** inside the Dev Container. + +### 2. Connect Neovim + +The Neovim instance in the Dev Container is pre-configured to detect the +`LSP_DEBUG_PORT` environment variable. + +1. Wait for the message in the VS Code Debug Console: `LSP server listening on TCP: 127.0.0.1:7301`. +2. In the opened Neovim terminal, open any `.proto` file (e.g., `:e sample/simple.proto`). +3. Neovim will automatically connect to the debugged server instance via the specified port. + +### 3. Verify Connection +To ensure the debugger is working and the server is responding: +1. Set a breakpoint in the Rust code (e.g., in `src/parser/docsymbol.rs`). +2. In Neovim, trigger an LSP request, such as fetching document symbols: + ```vim + :lua vim.lsp.buf.document_symbol() + ``` +3. The debugger should hit your breakpoint in VS Code. + +## Manual Debugging + +If you prefer to run the components manually: + +1. **Start the server:** + ```bash + cargo run -- --port 7301 + ``` +2. **Start Neovim:** + ```bash + LSP_DEBUG_PORT=7301 nvim your_file.proto + ``` diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..85907e8 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,171 @@ +use clap::Parser; +use const_format::concatcp; + +use crate::FALLBACK_INCLUDE_PATH; + +const BUILD_INFO: &str = concatcp!( + "fallback include path: ", + match FALLBACK_INCLUDE_PATH { + Some(path) => path, + None => "not set", + } +); + +/// Command-line arguments for the protols language server. +/// +/// This structure defines the available configuration options, including +/// file discovery paths and various communication transports (Stdio, TCP, Pipes). +#[derive(Parser, Debug, Default)] +#[command( + author, + version = concatcp!( + env!("CARGO_PKG_VERSION"), + "\n", + BUILD_INFO + ), + about, + long_about = None +)] +pub struct Cli { + /// Include paths for proto files, comma-separated (can be used multiple times) + #[arg(short, long, value_delimiter = ',')] + pub include_paths: Option>, + + /// Use stdin/stdout for communication (default) + #[arg(long, group = "transport", help_heading = "Transport")] + pub stdio: bool, + + /// Use TCP communication with a specific address and port. + /// Examples: "192.168.1.10:5005" or "0.0.0.0:5005" + #[arg( + long, + value_name = "ADDR", + group = "transport", + help_heading = "Transport" + )] + pub socket: Option, + + /// Use TCP communication on localhost with a specific port. + /// Example: "5005" + #[arg( + long, + value_name = "PORT", + group = "transport", + help_heading = "Transport" + )] + pub port: Option, + + /// Use Unix domain socket (Linux/macOS) or Named Pipe (Windows). + /// Examples: "/tmp/protols.sock" or "protols-pipe" (Windows) + #[arg( + long, + value_name = "PATH", + group = "transport", + help_heading = "Transport" + )] + pub pipe: Option, +} + +impl Cli { + /// Returns a list of filesystem paths for proto file discovery. + /// + /// This method collects all values from the `--include-paths` flags (including + /// multiple occurrences and comma-separated values) and converts them into + /// a vector of [std::path::PathBuf]. Returns an empty vector if no paths are provided. + pub fn get_include_paths(&self) -> Vec { + self.include_paths + .as_ref() + .map(|ic| ic.iter().map(std::path::PathBuf::from).collect()) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_cli_parsing() { + // Test with no arguments + let args = vec!["protols"]; + let cli = Cli::try_parse_from(args).expect("Should parse empty args"); + assert!(cli.get_include_paths().is_empty()); + + // Test with include paths + let args = vec!["protols", "--include-paths=/path1,/path2"]; + let cli = Cli::try_parse_from(args).expect("Should parse long flag"); + let paths = cli.get_include_paths(); + assert_eq!(paths.len(), 2); + assert_eq!(paths[0].to_str().unwrap(), "/path1"); + assert_eq!(paths[1].to_str().unwrap(), "/path2"); + + // Test with short form + let args = vec!["protols", "-i", "/path1,/path2"]; + let cli = Cli::try_parse_from(args).expect("Should parse short flag"); + let paths = cli.get_include_paths(); + assert_eq!(paths.len(), 2); + assert_eq!(paths[0], std::path::PathBuf::from("/path1")); + assert_eq!(paths[1], std::path::PathBuf::from("/path2")); + + // Test include path multiple occurrences merging + let args = vec!["protols", "-i", "/path2", "-i", "/path1"]; + let cli = Cli::try_parse_from(args).unwrap(); + let paths = cli.get_include_paths(); + assert_eq!(paths.len(), 2); + assert_eq!(paths[0], std::path::PathBuf::from("/path2")); + assert_eq!(paths[1], std::path::PathBuf::from("/path1")); + + // Windows-style paths + let args = vec!["protols", "-i", r"C:\proto\include,D:\shared"]; + let cli = Cli::try_parse_from(args).unwrap(); + let paths = cli.get_include_paths(); + assert_eq!(paths.len(), 2); + assert_eq!(paths[0].to_str().unwrap(), r"C:\proto\include"); + assert_eq!(paths[1].to_str().unwrap(), r"D:\shared"); + } + + #[test] + fn test_get_include_paths_transformation() { + let args = vec!["protols", "-i", "rel/path,/abs/path"]; + let cli = Cli::parse_from(args); + let paths = cli.get_include_paths(); + + assert_eq!(paths.len(), 2); + assert!(paths[0].is_relative()); + assert!(paths[1].is_absolute()); + } + + #[test] + fn test_transport_conflict() { + let args = vec!["protols", "--port", "5005", "--socket", "172.16.0.15:5005"]; + + let result = Cli::try_parse_from(args); + + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn test_port_and_socket() { + let args = vec!["protols", "--port", "7301"]; + let cli = Cli::parse_from(args); + assert_eq!(cli.port, Some(7301)); + + let args = vec!["protols", "--socket", "192.168.1.20:7301"]; + let cli = Cli::parse_from(args); + assert_eq!(cli.socket.as_deref(), Some("192.168.1.20:7301")); + } + + #[test] + fn test_default_is_empty() { + let args = vec!["protols"]; + let cli = Cli::try_parse_from(args).unwrap(); + + assert!(!cli.stdio); + assert!(cli.port.is_none()); + assert!(cli.socket.is_none()); + assert!(cli.pipe.is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index 8215026..967ed17 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,10 +7,13 @@ use async_lsp::panic::CatchUnwindLayer; use async_lsp::server::LifecycleLayer; use async_lsp::tracing::TracingLayer; use clap::Parser; -use const_format::concatcp; +use cli::Cli; use server::{ProtoLanguageServer, TickEvent}; use tower::ServiceBuilder; +use crate::transport::create_transport; + +mod cli; mod config; mod context; mod docs; @@ -22,45 +25,21 @@ mod parser; mod protoc; mod server; mod state; +mod transport; mod utils; mod workspace; -/// Language server for proto3 files -#[derive(Parser, Debug)] -#[command( - author, - version = concatcp!( - env!("CARGO_PKG_VERSION"), - "\n", - BUILD_INFO - ), - about, - long_about = None, - ignore_errors(true) -)] -struct Cli { - /// Include paths for proto files - #[arg(short, long, value_delimiter = ',')] - include_paths: Option>, -} - const FALLBACK_INCLUDE_PATH: Option<&str> = option_env!("FALLBACK_INCLUDE_PATH"); -const BUILD_INFO: &str = concatcp!( - "fallback include path: ", - match FALLBACK_INCLUDE_PATH { - Some(path) => path, - None => "not set", - } -); #[tokio::main(flavor = "current_thread")] -async fn main() { +async fn main() -> Result<(), transport::TransportError> { let cli = Cli::parse(); let (tx, mut rx) = tokio::sync::mpsc::channel(100); let (reload_handle, _log_guard) = log::install(tx); tracing::info!("server version: {}", env!("CARGO_PKG_VERSION")); + tracing::info!("CLI include paths: {:?}", &cli.include_paths); let (server, _) = async_lsp::MainLoop::new_server(|client| { let mut log_client = client.clone(); @@ -71,12 +50,7 @@ async fn main() { } }); - tracing::info!("Using CLI options: {:?}", cli); - - let include_paths = cli - .include_paths - .map(|ic| ic.into_iter().map(std::path::PathBuf::from).collect()) - .unwrap_or_default(); + let include_paths = cli.get_include_paths(); let fallback_include_path = FALLBACK_INCLUDE_PATH.map(std::path::PathBuf::from); @@ -111,49 +85,9 @@ async fn main() { .service(router) }); - // Prefer truly asynchronous piped stdin/stdout without blocking tasks. - #[cfg(unix)] - let (stdin, stdout) = ( - async_lsp::stdio::PipeStdin::lock_tokio().unwrap(), - async_lsp::stdio::PipeStdout::lock_tokio().unwrap(), - ); - // Fallback to spawn blocking read/write otherwise. - #[cfg(not(unix))] - let (stdin, stdout) = ( - tokio_util::compat::TokioAsyncReadCompatExt::compat(tokio::io::stdin()), - tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(tokio::io::stdout()), - ); - - server.run_buffered(stdin, stdout).await.unwrap(); -} + let (input, output) = create_transport(&cli).await?; + + server.run_buffered(input, output).await?; -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cli_parsing() { - // Test with no arguments - let args = vec!["protols"]; - let cli = Cli::parse_from(args); - assert!(cli.include_paths.is_none()); - - // Test with include paths - let args = vec!["protols", "--include-paths=/path1,/path2"]; - let cli = Cli::parse_from(args); - assert!(cli.include_paths.is_some()); - let paths = cli.include_paths.unwrap(); - assert_eq!(paths.len(), 2); - assert_eq!(paths[0], "/path1"); - assert_eq!(paths[1], "/path2"); - - // Test with short form - let args = vec!["protols", "-i", "/path1,/path2"]; - let cli = Cli::parse_from(args); - assert!(cli.include_paths.is_some()); - let paths = cli.include_paths.unwrap(); - assert_eq!(paths.len(), 2); - assert_eq!(paths[0], "/path1"); - assert_eq!(paths[1], "/path2"); - } + Ok(()) } diff --git a/src/transport.rs b/src/transport.rs new file mode 100644 index 0000000..7f6c996 --- /dev/null +++ b/src/transport.rs @@ -0,0 +1,242 @@ +use crate::cli::Cli; +use futures::io::{AsyncRead, AsyncWrite}; +use std::error::Error; +use std::pin::Pin; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +pub type TransportError = Box; +pub type TransportResult = Result; + +pub type LspReader = Pin>; +pub type LspWriter = Pin>; + +/// Establishes the communication channel for the LSP server based on CLI arguments. +/// +/// This function acts as a factory that selects and initializes the appropriate +/// transport layer. It checks the following options in order of priority: +/// 1. **TCP Port**: Listens on `127.0.0.1` with a specific port. +/// 2. **TCP Socket**: Listens on a custom IP/Port string. +/// 3. **Pipe**: Creates a Unix Domain Socket (POSIX) or a Named Pipe (Windows). +/// 4. **Stdio**: Uses optimized standard input/output streams (Default). +/// +/// # Errors +/// +/// Returns a [TransportError] if: +/// * The specified TCP port or socket address is already in use. +/// * A Unix socket cannot be created (e.g., due to file permissions or path conflicts). +/// * Windows Named Pipe creation fails due to access rights or naming violations. +pub async fn create_transport(cli: &Cli) -> TransportResult<(LspReader, LspWriter)> { + if let Some(port) = cli.port { + let addr = format!("127.0.0.1:{}", port); + return create_tcp_transport(&addr).await; + } + + if let Some(addr) = &cli.socket { + return create_tcp_transport(addr).await; + } + + if let Some(path) = &cli.pipe { + return create_pipe_transport(path).await; + } + + create_stdio_transport().await +} + +async fn create_tcp_transport(address: &str) -> TransportResult<(LspReader, LspWriter)> { + let listener = tokio::net::TcpListener::bind(address) + .await + .inspect_err(|e| eprintln!("Error: Could not bind to {}: {}", address, e))?; + + eprintln!( + "LSP server listening on TCP: {}. Waiting for client...", + address + ); + + let (stream, _) = listener + .accept() + .await + .inspect_err(|e| eprintln!("Error: Failed to accept connection: {}", e))?; + + eprintln!("Client connected"); + tracing::info!("Using TCP: {}", address); + + let (reader, writer) = tokio::io::split(stream); + + Ok((Box::pin(reader.compat()), Box::pin(writer.compat_write()))) +} + +async fn create_pipe_transport(path: &str) -> TransportResult<(LspReader, LspWriter)> { + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt; + + if let Ok(metadata) = std::fs::metadata(path) { + if !metadata.file_type().is_socket() { + return Err(format!( + "Path '{}' exists and is not a socket. Refusing to overwrite.", + path + ) + .into()); + } + + // In Unix, we must remove the existing socket file before binding. + // See https://man7.org/linux/man-pages/man7/unix.7.html#NOTES + let _ = std::fs::remove_file(path); + } + + let listener = tokio::net::UnixListener::bind(path) + .inspect_err(|e| eprintln!("Failed to bind Unix domain socket {}: {}", path, e))?; + + eprintln!( + "Listening on Unix domain socket: {}. Waiting for client...", + path + ); + + let (stream, _) = listener + .accept() + .await + .inspect_err(|e| eprintln!("Error: Failed to accept connection: {}", e))?; + + eprintln!("Client connected"); + tracing::info!("Using Unix domain socket: {}", path); + + let (reader, writer) = tokio::io::split(stream); + Ok((Box::pin(reader.compat()), Box::pin(writer.compat_write()))) + } + + #[cfg(windows)] + { + let full_path = normalize_windows_pipe(path)?; + + use tokio::net::windows::named_pipe::ServerOptions; + + let server = ServerOptions::new() + .first_pipe_instance(true) + .create(&full_path) + .inspect_err(|e| eprintln!("Failed to create Named Pipe {}: {}", full_path, e))?; + + eprintln!("LSP server listening on Named Pipe: {}", full_path); + + server + .connect() + .await + .inspect_err(|e| eprintln!("Error: Failed to accept connection: {}", e))?; + + eprintln!("Client connected"); + tracing::info!("Using Windows named pipe: {}", path); + + let (reader, writer) = tokio::io::split(server); + Ok((Box::pin(reader.compat()), Box::pin(writer.compat_write()))) + } + + #[cfg(not(any(unix, windows)))] + Err("Pipes are not supported on this platform".into()) +} + +/// Normalizes a string into a valid Windows Named Pipe path format. +/// +/// If the input is a simple name, it prefixes it with `\\.\pipe\`. +/// It also validates that UNC paths contain the required `\pipe\` segment. +/// +/// # Errors +/// +/// Returns an error if the path contains a drive letter (indicating a file path) +/// or if a UNC path is malformed. +#[cfg(any(windows, test))] +fn normalize_windows_pipe(path: &str) -> TransportResult { + let full_path = match path { + local if local.starts_with(r"\\.\pipe\") => local.to_string(), + + unc if unc.starts_with(r"\\") => { + let mut components = unc.split('\\').skip(2); + let _server = components.next(); + let pipe_segment = components.next(); + + if pipe_segment == Some("pipe") { + unc.to_string() + } else { + return Err(format!( + "Invalid UNC pipe path: '{}'. Missing or misplaced '\\pipe\\'.", + unc + ) + .into()); + } + } + + file if file.contains(':') => { + return Err(format!("Named pipes cannot be files (like '{}').", file).into()); + } + + suffix => format!(r"\\.\pipe\{}", suffix), + }; + + Ok(full_path) +} + +async fn create_stdio_transport() -> TransportResult<(LspReader, LspWriter)> { + // Prefer truly asynchronous piped stdin/stdout without blocking tasks. + #[cfg(unix)] + { + let stdin = async_lsp::stdio::PipeStdin::lock_tokio() + .map_err(|e| format!("Failed to lock stdin: {}", e))?; + let stdout = async_lsp::stdio::PipeStdout::lock_tokio() + .map_err(|e| format!("Failed to lock stdout: {}", e))?; + + eprintln!("Using Stdio"); + tracing::info!("Using Stdio"); + + Ok((Box::pin(stdin), Box::pin(stdout))) + } + + // Fallback to spawn blocking read/write otherwise. + #[cfg(not(unix))] + { + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + + eprintln!("Using Stdio"); + tracing::info!("Using Stdio"); + + Ok((Box::pin(stdin.compat()), Box::pin(stdout.compat_write()))) + } +} + +#[cfg(unix)] +#[tokio::test] +async fn test_unix_socket_wont_delete_regular_file() { + use tempfile::NamedTempFile; + let file = NamedTempFile::new().unwrap(); + let path = file.path().to_str().unwrap(); + + let cli = Cli { + pipe: Some(path.to_string()), + ..Default::default() + }; + + let result = create_transport(&cli).await; + assert!(result.is_err()); + assert!(std::path::Path::new(path).exists()); +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_windows_pipe_normalization() { + assert_eq!( + normalize_windows_pipe(r"lsp\protols").unwrap(), + r"\\.\pipe\lsp\protols" + ); + assert_eq!( + normalize_windows_pipe(r"\\.\pipe\some\test").unwrap(), + r"\\.\pipe\some\test" + ); + + assert!(normalize_windows_pipe(r"C:\test.proto").is_err()); + + assert!(normalize_windows_pipe(r"\\server\share").is_err()); + + assert!(normalize_windows_pipe(r"\\server\share\pipe").is_err()); + } +}