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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/.artifacts/
/rust/target/
/scripts/rust-core/comment-lexer/target/
77 changes: 77 additions & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions rust/lithe-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ similar = { version = "3.2", default-features = false, features = ["std"] }
zip = { version = "2.4", default-features = false, features = ["deflate"] }
url = "2.5"
unicode-normalization = "0.1"
ignore = "=0.4.33"

[target.'cfg(unix)'.dev-dependencies]
lithe-remote = { path = "../lithe-remote", default-features = false, features = ["server"] }
62 changes: 61 additions & 1 deletion rust/lithe-core/src/debug/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1374,7 +1374,11 @@ impl DebugSession {
}
}
PendingRequest::Launch { operation_id } => {
self.transition(DebugSessionState::Running);
// Adapters may emit stopped or terminated before this acknowledgement.
// A late response completes the operation, but cannot resume or resurrect it.
if self.state == DebugSessionState::Launching {
self.transition(DebugSessionState::Running);
}
self.emit(DebugEventBody::OperationCompleted {
operation_id,
result: DebugOperationResult::Acknowledged {
Expand Down Expand Up @@ -3161,6 +3165,62 @@ mod tests {
serde_json::from_slice(&bytes[body_start..]).unwrap()
}

#[test]
fn delayed_launch_response_preserves_a_newer_stopped_or_terminal_event() {
let fixture: Value = serde_json::from_str(include_str!(
"../../../../shared/fixtures/debug/launch-event-order.json"
))
.unwrap();
for case in fixture["cases"].as_array().unwrap() {
let session_id = case["id"].as_str().unwrap();
let event = case["event"].clone();
let expected = case["state"].clone();
create_session(child_request(session_id, None)).unwrap();
struct Cleanup<'a>(&'a str);
impl Drop for Cleanup<'_> {
fn drop(&mut self) {
// log-exempt: Expected absence if the test already disposed this process-free session.
let _ = destroy_session(SessionRequest {
session_id: self.0.into(),
});
}
}
let _cleanup = Cleanup(session_id);
receive_messages(
session_id,
vec![response_message(1, "initialize", json!({}))],
);
let launching = launch(LaunchRequest {
session_id: session_id.into(),
operation_id: "launch".into(),
configuration: DebugLaunchConfiguration {
name: "native target".into(),
request: DebugRequestKind::Launch,
arguments: Map::new(),
stepping_filters: None,
},
})
.unwrap();
let frame = decode_frame(&launching.outbound_frames[0]);
let stopped = receive_messages(session_id, vec![event]);
assert_eq!(serde_json::to_value(stopped.state).unwrap(), expected);
// LLDB can report the first stop before acknowledging the launch request.
let acknowledged = receive_messages(
session_id,
vec![response_message(
frame["seq"].as_i64().unwrap(),
"launch",
json!({}),
)],
);
assert_eq!(
serde_json::to_value(acknowledged.state).unwrap(),
expected,
"a response must not resume or resurrect the target"
);
}
}

#[test]
fn dap_logging_keeps_catalog_identity_and_cancellation_private() {
let adapter = "lldb+private-adapter";
Expand Down
132 changes: 132 additions & 0 deletions rust/lithe-core/src/lsp/document_identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! Lexical workspace identities for diagnostics; the host owns native file I/O.

use crate::lsp::{
workspace_edit_plan::native_path, workspace_scope::WorkspaceScope, DocumentPathPlatform,
MAX_PATH_BYTES,
};
use crate::protocol::{CoreError, ErrorCode};
use serde::Deserialize;
use serde_json::{json, Value};
use url::Url;

#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Request {
workspace_root: String,
/// File URI, absolute native path, or workspace-relative document path.
input: String,
platform: DocumentPathPlatform,
}

/// Resolves one server-supplied identity to a native path and workspace-relative
/// display path. Non-file URIs, ambiguous drive-relative paths and outside targets
/// return a null document. The result grants no permission to follow symlinks.
pub(crate) fn resolve_command(payload: Value) -> Result<Value, CoreError> {
let request: Request = serde_json::from_value(payload).map_err(|error| {
CoreError::new(
ErrorCode::InvalidRequest,
"Invalid document identity request",
)
.with_details(error.to_string())
})?;
let platform = if request.platform.is_windows_path(&request.workspace_root) {
DocumentPathPlatform::Windows
} else {
DocumentPathPlatform::Posix
};
let scope = WorkspaceScope::new(&request.workspace_root, platform)?;
let windows = matches!(platform, DocumentPathPlatform::Windows);
let missing = || json!({"document": null});
if request.input.is_empty()
|| request.input.len() > MAX_PATH_BYTES
|| request.input.contains('\0')
{
return Ok(missing());
}
// URL parsing handles localhost, drive authorities and escaped components;
// reuse workspace-edit path decoding instead of maintaining another decoder.
let normalized;
let input = if request
.input
.get(..5)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("file:"))
{
let Ok(url) = Url::parse(&request.input) else {
return Ok(missing());
};
if (!windows && url.host_str().is_some())
|| !url.username().is_empty()
|| url.port().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Ok(missing());
}
normalized = url.to_string();
normalized.as_str()
} else {
&request.input
};
let Some(mut path) = native_path(input) else {
return Ok(missing());
};
if windows {
path = path.replace('\\', "/");
let bytes = path.as_bytes();
let drive = bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& bytes[2] == b'/';
if !drive && !path.starts_with("//") {
if path.starts_with('/') || path.contains(':') {
return Ok(missing());
}
path = format!(
"{}/{}",
request.workspace_root.trim_end_matches(['/', '\\']),
path
);
}
} else if !path.starts_with('/') {
path = format!("{}/{}", request.workspace_root.trim_end_matches('/'), path);
}
let Ok(uri) = platform.document_uri(&path) else {
return Ok(missing());
};
let Some(relative) = scope.relative_path(&path)? else {
return Ok(missing());
};
let Some(path) = native_path(&uri) else {
return Ok(missing());
};
let path = if windows {
path.replace('/', "\\")
} else {
path
};
Ok(json!({"document": {"path": path, "relativePath": relative}}))
}

#[cfg(test)]
mod tests {
use serde_json::{json, Value};

#[test]
fn matches_shared_fixture() {
let fixture: Value = serde_json::from_str(include_str!(
"../../../../shared/fixtures/lsp/document-identity-v1.json"
))
.unwrap();
for case in fixture["cases"].as_array().unwrap() {
let response: Value = serde_json::from_str(&crate::execute_json(
&json!({"command":"lsp.documentIdentity", "payload":case["request"]}).to_string(),
))
.unwrap();
if let Some(code) = case.get("error") {
assert_eq!(&response["error"]["code"], code, "{case}");
} else {
assert_eq!(response["data"], case["expected"], "{case}: {response}");
}
}
}
}
13 changes: 6 additions & 7 deletions rust/lithe-core/src/lsp/interface/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1740,21 +1740,20 @@ fn parse_lsp_position_value(value: &Value) -> Option<Value> {
}))
}

/// Decodes a file URI using native drive/UNC syntax; opaque inputs retain
/// their percent-decoded spelling for existing non-file consumers.
pub(crate) fn file_path_from_uri(uri: &str) -> String {
file_path_for_uri(uri).unwrap_or_else(|| percent_decode(uri))
}

fn file_path_for_uri(uri: &str) -> Option<String> {
let (scheme, remainder) = uri.split_once(':')?;
let (scheme, _) = uri.split_once(':')?;
if !scheme.eq_ignore_ascii_case("file") {
return None;
}
let path = if remainder.starts_with("///") {
&remainder[2..]
} else {
remainder
};
Some(percent_decode(path))
// Workspace edits already decode percent escapes, UNC authorities and the
// leading slash of file:///C:/... without depending on the build host OS.
crate::lsp::workspace_edit_plan::native_path(uri)
}

/// Decodes `%XX` escapes in a URI component, keeping the input when the
Expand Down
6 changes: 3 additions & 3 deletions rust/lithe-core/src/lsp/interface/document_uri.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ use serde::Deserialize;
use serde_json::{json, Value};
use url::Url;

// A Windows extended-length path can contain 32,767 UTF-16 units; its UTF-8
// representation may use more bytes, so do not reject valid non-ASCII paths.
const MAX_PATH_BYTES: usize = 4 * 32_767;
/// Largest UTF-8 spelling of a Windows extended-length path (32,767 UTF-16
/// units); shared by native path and server document identity validation.
pub(crate) const MAX_PATH_BYTES: usize = 4 * 32_767;

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
Expand Down
3 changes: 3 additions & 0 deletions rust/lithe-core/src/lsp/interface/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ static ENGINE: OnceLock<LspEngine> = OnceLock::new();
#[path = "engine_function_relay.rs"]
mod relay_hub;
use relay_hub::RelayHub;
#[path = "engine_function_provider.rs"]
mod function_provider;
pub use function_provider::function_provider_command;

#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
Expand Down
Loading