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
104 changes: 9 additions & 95 deletions src/lsp/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use tokio::sync::watch;

use crate::lsp::language;
use crate::lsp::rpc::{RpcClient, RpcError};
use crate::lsp::uri::{path_to_file_uri_string, uri_to_path};

#[derive(Debug, Default)]
struct FileState {
Expand Down Expand Up @@ -112,15 +113,15 @@ impl LspClient {
/// `textDocument/didChange` thereafter (with a bumped version).
/// Returns the version sent.
///
/// Uses synchronous `std::fs::canonicalize` + `std::fs::read_to_string`.
/// Acceptable for now since callers invoke this once per write boundary,
/// not on a hot loop. Phase 4 should switch to `tokio::fs` if hot paths
/// emerge (e.g. touching many files in parallel on agent startup).
/// File I/O goes through `tokio::fs` so the orchestrator can fan
/// `touch_file` out across multiple clients without blocking the runtime
/// thread.
pub async fn notify_open(&self, path: &Path) -> Result<i32, LspError> {
let abs = path
.canonicalize()
.or_else(|_| Ok::<_, std::io::Error>(path.to_path_buf()))?;
let text = std::fs::read_to_string(&abs)?;
let abs = match tokio::fs::canonicalize(path).await {
Ok(p) => p,
Err(_) => path.to_path_buf(),
};
let text = tokio::fs::read_to_string(&abs).await?;
let uri = path_to_file_uri_string(&abs);

let is_first_open;
Expand Down Expand Up @@ -254,70 +255,6 @@ impl LspClient {
}
}

fn uri_to_path(uri: &str) -> Option<PathBuf> {
// Strip `file://` and percent-decode the body. We're intentionally
// permissive: the spec allows file:///path on unix and file://host/path
// on remote (which we don't support). Anything else returns None.
let trimmed = uri
.strip_prefix("file://")
.or_else(|| uri.strip_prefix("file:"))?;
let decoded = percent_decode(trimmed);
Some(PathBuf::from(decoded))
}

fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = hex_value(bytes[i + 1]);
let lo = hex_value(bytes[i + 2]);
if let (Some(h), Some(l)) = (hi, lo) {
out.push(h * 16 + l);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}

fn hex_value(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}

fn path_to_file_uri_string(path: &Path) -> String {
let s = path.to_string_lossy();
let encoded = percent_encode_path(&s);
if s.starts_with('/') {
format!("file://{encoded}")
} else {
format!("file:///{encoded}")
}
}

fn percent_encode_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for byte in path.bytes() {
let safe =
byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'.' | b'_' | b'~' | b':');
if safe {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}

fn dedupe<I: IntoIterator<Item = Diagnostic>>(items: I) -> Vec<Diagnostic> {
let mut seen: HashSet<String> = HashSet::new();
let mut out = Vec::new();
Expand Down Expand Up @@ -669,27 +606,4 @@ mod tests {
.await;
assert!(matches!(res, Err(LspError::DiagnosticsTimeout { .. })));
}

// URI ↔ path round-trips, exercising percent-encoding.
#[test]
fn uri_to_path_roundtrips_safe_paths() {
let p = PathBuf::from("/tmp/proj/main.rs");
let uri = path_to_file_uri_string(&p);
let decoded = uri_to_path(&uri).unwrap();
assert_eq!(decoded, p);
}

#[test]
fn uri_to_path_decodes_percent_encoded_special_chars() {
let p = PathBuf::from("/tmp/proj #1/main.rs");
let uri = path_to_file_uri_string(&p);
assert!(uri.contains("%23"));
let decoded = uri_to_path(&uri).unwrap();
assert_eq!(decoded, p);
}

#[test]
fn uri_to_path_returns_none_for_non_file_uri() {
assert!(uri_to_path("https://example.com").is_none());
}
}
69 changes: 3 additions & 66 deletions src/lsp/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ use lsp_types::{
ClientCapabilities, DiagnosticClientCapabilities, DidChangeWatchedFilesClientCapabilities,
GeneralClientCapabilities, InitializeParams, InitializeResult,
PublishDiagnosticsClientCapabilities, TextDocumentClientCapabilities,
TextDocumentSyncClientCapabilities, Uri, WindowClientCapabilities, WorkspaceClientCapabilities,
TextDocumentSyncClientCapabilities, WindowClientCapabilities, WorkspaceClientCapabilities,
WorkspaceFolder,
};

use crate::lsp::rpc::{RpcClient, RpcError};
use crate::lsp::uri::path_to_file_uri;

/// Time we'll wait for the server to answer `initialize`. Matches opencode's
/// 45s ceiling — rust-analyzer in particular can take a moment when first
Expand All @@ -40,7 +41,7 @@ pub async fn initialize(
process_id: Option<u32>,
initialization_options: serde_json::Value,
) -> Result<InitializeResult, RpcError> {
let root_uri = path_to_file_uri(root)?;
let root_uri = path_to_file_uri(root).map_err(RpcError::Io)?;

let params = InitializeParams {
process_id,
Expand Down Expand Up @@ -70,46 +71,6 @@ pub async fn initialize(
Ok(result)
}

fn path_to_file_uri(path: &Path) -> Result<Uri, RpcError> {
// `file://` URI from a filesystem path. We don't try to handle Windows
// drive letters specially — dirge isn't tested on Windows.
let canonical = path.to_string_lossy();
let encoded = percent_encode_path(&canonical);
let uri_str = if canonical.starts_with('/') {
format!("file://{encoded}")
} else {
format!("file:///{encoded}")
};
uri_str.parse::<Uri>().map_err(|e| {
RpcError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid path for file URI: {e}"),
))
})
}

/// Percent-encode characters that would otherwise be interpreted as URI
/// structural delimiters. Slashes are preserved (path separators). Conforms
/// to RFC 3986's `unreserved` set + `/` for path segments. Pure ASCII only —
/// non-ASCII bytes pass through and the Uri parser does its own escaping or
/// rejection.
fn percent_encode_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for byte in path.bytes() {
let safe =
byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'.' | b'_' | b'~' | b':');
if safe {
out.push(byte as char);
} else if byte < 0x80 {
out.push_str(&format!("%{byte:02X}"));
} else {
// Non-ASCII — emit as UTF-8 percent-encoded bytes.
out.push_str(&format!("%{byte:02X}"));
}
}
out
}

/// The client capabilities we advertise. Conservative and stable — matches
/// what opencode sends. Phase 3's per-file work depends on these being
/// honoured by the server:
Expand Down Expand Up @@ -370,30 +331,6 @@ mod tests {
);
}

// Regression: paths containing URI-significant characters must be
// percent-encoded. A `#` would otherwise terminate the path early and
// produce a fragment.
#[test]
fn path_to_file_uri_percent_encodes_special_chars() {
let p = Path::new("/tmp/proj #1/src/main.rs");
let uri = path_to_file_uri(p).unwrap();
let s = uri.as_str();
assert!(s.starts_with("file:///"), "got: {s}");
assert!(s.contains("%23"), "must encode '#' as %23, got: {s}");
assert!(s.contains("%20"), "must encode space as %20, got: {s}");
}

#[test]
fn path_to_file_uri_preserves_slashes_and_safe_chars() {
let p = Path::new("/tmp/proj_v1.0-rc/main.rs");
let uri = path_to_file_uri(p).unwrap();
let s = uri.as_str();
assert!(
s.starts_with("file:///tmp/proj_v1.0-rc/main.rs"),
"got: {s}"
);
}

// The `initialized` notification must follow the InitializeResult — some
// servers stall until they see it.
#[tokio::test]
Expand Down
Loading
Loading