From eebee75f0b213f4404e54b1d4a3056be04a882c1 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Fri, 13 Nov 2026 02:34:09 -0800 Subject: [PATCH] feat(editor): add search, recovery and editor preference rules --- rust/contracts/rust-core-api.md | 62 ++++++ rust/fixtures/editor/ide-essentials-v1.json | 182 +++++++++++++++ rust/lithe-core/include/lithe_core.h | 2 + rust/lithe-core/src/editor/find.rs | 219 ++++++++++++++++++ rust/lithe-core/src/editor/mod.rs | 4 + rust/lithe-core/src/editor/preferences.rs | 56 +++++ rust/lithe-core/src/editor/recent.rs | 180 +++++++++++++++ rust/lithe-core/src/editor/recovery.rs | 80 +++++++ rust/lithe-core/src/keymap/mod.rs | 1 + rust/lithe-core/src/keymap/schemes.rs | 234 ++++++++++++++++++++ rust/lithe-core/src/protocol/command.rs | 15 ++ rust/lithe-core/src/runtime/dispatcher.rs | 31 ++- rust/lithe-core/src/runtime/ffi.rs | 88 ++++++++ 13 files changed, 1150 insertions(+), 4 deletions(-) create mode 100644 rust/fixtures/editor/ide-essentials-v1.json create mode 100644 rust/lithe-core/src/editor/find.rs create mode 100644 rust/lithe-core/src/editor/preferences.rs create mode 100644 rust/lithe-core/src/editor/recent.rs create mode 100644 rust/lithe-core/src/editor/recovery.rs create mode 100644 rust/lithe-core/src/keymap/schemes.rs diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index 190e5213..0d33607b 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -6253,3 +6253,65 @@ and deduplicated. No setting values cross windows: receivers read their own project layers. Native owners must recheck window/project lifetime on delivery. Invalid surface input returns `plugin.settings.invalidSurface`; owner mismatch returns `plugin.settings.ownerChanged`. Shared vectors: `rust/fixtures/plugins/settings-surfaces.json`. + + +### Editor essentials (desktop internal commands) + +`editor.find` executes through `lithe_core_execute_document_json(request, bytes, length)`. +The caller owns valid UTF-8 bytes for the synchronous call; Core borrows them only until +return, retains no pointer or text cache, and returns its normal owned response string +(free with `lithe_core_free_string`). A null buffer is valid only at length zero. +The ordinary JSON entry point rejects this command with `missing_document`. +Cancellation uses the operation ID registry, including cancellation checks between matches. +The host must run on a worker and guard the document snapshot and query generation before +applying ranges or a replacement. This ABI adds no public plugin host methods. + +The payload is `{query, caseSensitive?:false, wholeWord?:false, regex?:false, +range?:{start,end}, replacement?:string, replaceStart?:number, caret?:0, backwards?:false}`. +All offsets are original UTF-8 byte offsets, half-open and on rune boundaries. An omitted +range searches the whole snapshot. Regex uses Unicode case folding/word boundaries and +multiline anchors; regex replacement expands `$1`/`${name}` captures; literal replacement +preserves dollar signs. `replaceStart` selects only that match; omitted means replace all. +Results are `{matches:[{start,end}],edits:[{start,end,text}],selected:number|null,truncated}`. +Scoped requests also return `range`, adjusted for the planned replacement lengths so the +selection remains valid after applying a bulk edit. +Limits: 64 MiB source, 4096-byte query, 10,000 returned matches, 8 MiB total inserted text, +1 MiB compiled regex and 2 MiB DFA. A truncated search is useful for navigation but bulk +replacement fails atomically. Invalid requests return `invalid_request` with stable details: +`invalid_request`, `missing_document`, `invalid_range`, `invalid_pattern`, `query_too_large`, +`source_too_large`, `source_too_large_or_invalid`, `replacement_too_large`, `too_many_matches`. +Source, query and replacement are never included in errors or diagnostic logs. + +`editor.preferences.resolve` accepts optional `lineNumbers`, `whitespace`, `cursorStyle` +(`line`, `block`, `underline`), `cursorBlink` and `autoSaveDelayMilliseconds`. It returns +actual values and minimum/maximum delay. Defaults: true, false, line, true, 1500 ms; +unknown styles resolve to line and delay clamps to 250–60,000 ms. + +`editor.recent.reduce` accepts `entries`, optional `event:{kind,path,name,line,column}`, +`query` and `onlyEdited`. It returns bounded `entries` and filtered `results`. Paths are +opaque host-owned workspace-relative identities, position is zero-based UTF-16. Kinds are +opened/edited/removed. Logical sequence numbers produce deterministic MRU order independent +of clocks; edit recency survives subsequent opens. Up to 80 entries, 4096-byte query, +32,768-byte path, 1024-byte name. Native storage and rendering belong to the host. + +`editor.recovery.decide` accepts `{baseHash,diskHash?,scratch?,bytes?,saved?,discarded?}`. +It returns action `remove`, `tooLarge`, `restoreScratch`, `restore`, or `restoreConflict`, +plus `maxDocumentBytes` (64 MiB), `maxStorageBytes` (256 MiB), `maxDocuments` (64). +Hash equality is a native fact (including actual encoding bytes where applicable). +Missing or changed disk data retains a conflict; it never authorizes an overwrite. +The native adapter owns private atomic checkpoint files in application data, quota +accounting, write serialization, and explicit save/discard cleanup. Recovery is independent +of auto-save. A save/remove queued after a checkpoint must win over that checkpoint. + +`keymap.schemes.reduce` accepts `{state:{active,schemes:[{id,name,bindings}]},current?, +operation?,id?,name?,bindings?}` and returns `{state,bindings}` for the active scheme. +Empty state migrates current overrides into default. Operations: empty (resolve), update, +select, saveAs (clone active), rename and delete. Default cannot be renamed/deleted; +deleting the active custom scheme selects default. Up to 16 schemes; names trim to 1–64 +characters without controls and are case-insensitively unique; IDs are ASCII alphanumeric, +hyphen or underscore, up to 64 bytes. Bindings use existing explicit-modifier key syntax. +Failures use `invalid_request` plus stable details such as `invalid_name`, `duplicate_name`, +`too_many_schemes`, `missing_scheme`, `default_is_fixed` and `invalid_binding`. +No third-party keymap import or plugin protocol change is introduced. + +Executable vectors: `rust/fixtures/editor/ide-essentials-v1.json` (C ABI tests). diff --git a/rust/fixtures/editor/ide-essentials-v1.json b/rust/fixtures/editor/ide-essentials-v1.json new file mode 100644 index 00000000..723f4ddd --- /dev/null +++ b/rust/fixtures/editor/ide-essentials-v1.json @@ -0,0 +1,182 @@ +{ + "version": 1, + "cases": [ + { + "name": "unicode-original-byte-offsets", + "command": "editor.find", + "document": "😀 αβ αβ", + "payload": { + "query": "αβ" + }, + "expected": { + "matches": [ + { + "start": 5, + "end": 9 + }, + { + "start": 10, + "end": 14 + } + ], + "edits": [], + "selected": 0, + "truncated": false + } + }, + { + "name": "regex-capture-expansion", + "command": "editor.find", + "document": "one two", + "payload": { + "query": "(one)", + "regex": true, + "replacement": "${1}!" + }, + "expected": { + "matches": [ + { + "start": 0, + "end": 3 + } + ], + "edits": [ + { + "start": 0, + "end": 3, + "text": "one!" + } + ], + "selected": 0, + "truncated": false + } + }, + { + "name": "preferences-defaults", + "command": "editor.preferences.resolve", + "payload": {}, + "expected": { + "lineNumbers": true, + "whitespace": false, + "cursorStyle": "line", + "cursorBlink": true, + "autoSaveDelayMilliseconds": 1500, + "minimumAutoSaveDelayMilliseconds": 250, + "maximumAutoSaveDelayMilliseconds": 60000 + } + }, + { + "name": "changed-disk-keeps-conflict", + "command": "editor.recovery.decide", + "payload": { + "baseHash": "old", + "diskHash": "new", + "bytes": 20 + }, + "expected": { + "action": "restoreConflict", + "maxDocumentBytes": 67108864, + "maxStorageBytes": 268435456, + "maxDocuments": 64 + } + }, + { + "name": "recent-edit-records-position", + "command": "editor.recent.reduce", + "payload": { + "event": { + "kind": "edited", + "path": "src/a.txt", + "name": "a.txt", + "line": 2, + "column": 3 + } + }, + "expected": { + "entries": [ + { + "path": "src/a.txt", + "name": "a.txt", + "line": 2, + "column": 3, + "opened": 1, + "edited": 1 + } + ], + "results": [ + { + "path": "src/a.txt", + "name": "a.txt", + "line": 2, + "column": 3, + "opened": 1, + "edited": 1 + } + ] + } + }, + { + "name": "migrate-empty-keymap", + "command": "keymap.schemes.reduce", + "payload": { + "state": {} + }, + "expected": { + "state": { + "active": "default", + "schemes": [ + { + "id": "default", + "name": "", + "bindings": {} + } + ] + }, + "bindings": {} + } + }, + { + "name": "selection-shrinks-with-replacement", + "command": "editor.find", + "document": "x alpha alpha y", + "payload": { + "query": "alpha", + "range": { + "start": 2, + "end": 13 + }, + "replacement": "a" + }, + "expected": { + "matches": [ + { + "start": 2, + "end": 7 + }, + { + "start": 8, + "end": 13 + } + ], + "edits": [ + { + "start": 2, + "end": 7, + "text": "a" + }, + { + "start": 8, + "end": 13, + "text": "a" + } + ], + "selected": 0, + "truncated": false, + "range": { + "start": 2, + "end": 5 + } + } + } + ] +} diff --git a/rust/lithe-core/include/lithe_core.h b/rust/lithe-core/include/lithe_core.h index 03bf4229..fbcc8145 100644 --- a/rust/lithe-core/include/lithe_core.h +++ b/rust/lithe-core/include/lithe_core.h @@ -13,6 +13,8 @@ const char *lithe_core_version(void); int32_t lithe_core_is_plain_text(const uint8_t *bytes, size_t length); int32_t lithe_core_git_askpass(const char *prompt); char *lithe_core_execute_json(const char *request); +/* Immutable source borrowed only until return; response uses lithe_core_free_string. */ +char *lithe_core_execute_document_json(const char *request, const uint8_t *bytes, size_t length); char *lithe_core_execute_json_with_events(const char *request, void (*callback)(const char *, void *), void *context); char *lithe_core_lsp_provider_catalog_json(const char *workspace_root); int32_t lithe_core_cancel(const char *operation_id); diff --git a/rust/lithe-core/src/editor/find.rs b/rust/lithe-core/src/editor/find.rs new file mode 100644 index 00000000..06e9664a --- /dev/null +++ b/rust/lithe-core/src/editor/find.rs @@ -0,0 +1,219 @@ +//! Bounded, Unicode-aware matching and replacement plans over borrowed editor text. +//! +//! The host owns the immutable document snapshot. Core borrows it for one worker request, +//! keeps no text cache, and returns only byte ranges and requested replacements. + +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use regex::RegexBuilder; +use serde::Deserialize; +use serde_json::{json, Value}; + +const MAX_SOURCE_BYTES: usize = 64 * 1024 * 1024; +const MAX_QUERY_BYTES: usize = 4096; +const MAX_MATCHES: usize = 10_000; +const MAX_REPLACEMENT_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + query: String, + #[serde(default)] + case_sensitive: bool, + #[serde(default)] + whole_word: bool, + #[serde(default)] + regex: bool, + range: Option, + replacement: Option, + /// When absent, a replacement request plans every match atomically. + replace_start: Option, + #[serde(default)] + caret: usize, + #[serde(default)] + backwards: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Range { + start: usize, + end: usize, +} + +fn invalid(reason: &str) -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + "Editor search request was rejected", + ) + .with_details(reason) +} + +pub(crate) fn command(payload: Value, source: Option<&str>) -> Result { + let request: Request = + serde_json::from_value(payload).map_err(|_| invalid("invalid_request"))?; + let source = source.ok_or_else(|| invalid("missing_document"))?; + if source.len() > MAX_SOURCE_BYTES { + return Err(invalid("source_too_large")); + } + if request.query.len() > MAX_QUERY_BYTES { + return Err(invalid("query_too_large")); + } + let scoped = request.range.is_some(); + let (start, end) = request + .range + .map_or((0, source.len()), |r| (r.start, r.end)); + if start > end + || end > source.len() + || !source.is_char_boundary(start) + || !source.is_char_boundary(end) + { + return Err(invalid("invalid_range")); + } + if request.query.is_empty() { + return Ok(json!({"matches":[], "edits":[], "selected":null, "truncated":false})); + } + let mut pattern = if request.regex { + request.query.clone() + } else { + regex::escape(&request.query) + }; + if request.whole_word { + pattern = format!(r"\b(?:{pattern})\b"); + } + let matcher = RegexBuilder::new(&pattern) + .case_insensitive(!request.case_sensitive) + .multi_line(true) + .nest_limit(64) + .size_limit(1024 * 1024) + .dfa_size_limit(2 * 1024 * 1024) + .build() + .map_err(|_| invalid("invalid_pattern"))?; + let text = &source[start..end]; + let mut matches = Vec::new(); + let mut edits = Vec::new(); + let mut truncated = false; + let mut replacement_bytes = 0; + let mut removed_bytes = 0; + for captures in matcher.captures_iter(text) { + cancellation::check()?; + if matches.len() == MAX_MATCHES { + truncated = true; + break; + } + let m = captures + .get(0) + .expect("a successful capture has a whole match"); + let at = start + m.start(); + matches.push(json!({"start":at, "end":start + m.end()})); + if let Some(replacement) = &request.replacement { + if request.replace_start.is_some_and(|wanted| wanted != at) { + continue; + } + let mut inserted = String::new(); + if request.regex { + captures.expand(replacement, &mut inserted); + } else { + inserted.push_str(replacement); + } + replacement_bytes += inserted.len(); + removed_bytes += m.len(); + if replacement_bytes > MAX_REPLACEMENT_BYTES { + return Err(invalid("replacement_too_large")); + } + edits.push(json!({"start":at, "end":start + m.end(), "text":inserted})); + } + } + if truncated && request.replacement.is_some() && request.replace_start.is_none() { + return Err(invalid("too_many_matches")); + } + let selected = if request.backwards { + matches + .iter() + .rposition(|m| m["start"].as_u64().unwrap_or(0) < request.caret as u64) + .or_else(|| matches.len().checked_sub(1)) + } else { + matches + .iter() + .position(|m| m["start"].as_u64().unwrap_or(0) >= request.caret as u64) + .or_else(|| (!matches.is_empty()).then_some(0)) + }; + let mut result = + json!({"matches":matches, "edits":edits, "selected":selected, "truncated":truncated}); + if scoped { + result["range"] = json!({"start":start,"end":end-removed_bytes+replacement_bytes}); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unicode_case_folding_preserves_original_byte_offsets() { + let result = command(json!({"query":"k", "caret":2}), Some("😀Kk")).unwrap(); + assert_eq!( + result["matches"], + json!([{"start":4,"end":7},{"start":7,"end":8}]) + ); + assert_eq!(result["selected"], 0); + } + + #[test] + fn regex_captures_and_zero_width_matches_have_atomic_replacement_plans() { + let result = command( + json!({"query":"(?m)^([a-z]+)","regex":true,"replacement":"${1}!"}), + Some("a\nb"), + ) + .unwrap(); + assert_eq!( + result["edits"], + json!([{"start":0,"end":1,"text":"a!"},{"start":2,"end":3,"text":"b!"}]) + ); + let empty = command(json!({"query":"^|$", "regex":true}), Some("😀")).unwrap(); + assert_eq!( + empty["matches"], + json!([{"start":0,"end":0},{"start":4,"end":4}]) + ); + } + + #[test] + fn word_selection_scope_and_single_replace_do_not_touch_other_matches() { + let result = command(json!({"query":"cat","wholeWord":true,"range":{"start":4,"end":18},"replacement":"$1","replaceStart":15}), Some("cat scatter x cat! cat")).unwrap(); + assert_eq!(result["edits"], json!([])); + let result = command( + json!({"query":"cat","wholeWord":true,"replacement":"$1","replaceStart":14}), + Some("cat scatter x cat! cat"), + ) + .unwrap(); + assert_eq!(result["edits"], json!([{"start":14,"end":17,"text":"$1"}])); + } + + #[test] + fn invalid_regex_is_reported_without_echoing_document_or_pattern() { + let err = command(json!({"query":"private[", "regex":true}), Some("secret")).unwrap_err(); + assert_eq!(err.details.as_deref(), Some("invalid_pattern")); + assert!(!err.message.contains("private")); + assert!(command( + json!({"query":"a", "range":{"start":1,"end":4}}), + Some("😀a") + ) + .is_err()); + } + + #[test] + fn replace_all_refuses_truncation_instead_of_applying_a_partial_plan() { + let text = "a".repeat(MAX_MATCHES + 1); + assert_eq!( + command(json!({"query":"a"}), Some(&text)).unwrap()["truncated"], + true + ); + assert_eq!( + command(json!({"query":"a","replacement":"b"}), Some(&text)) + .unwrap_err() + .details + .as_deref(), + Some("too_many_matches") + ); + } +} diff --git a/rust/lithe-core/src/editor/mod.rs b/rust/lithe-core/src/editor/mod.rs index ffa421fc..ddb2259f 100644 --- a/rust/lithe-core/src/editor/mod.rs +++ b/rust/lithe-core/src/editor/mod.rs @@ -6,9 +6,13 @@ //! the same contract in a follow-up change; the contract and fixtures here //! are the canonical reference for that migration. +pub(crate) mod find; pub(crate) mod line_edit; pub(crate) mod navigation_history; pub(crate) mod navigation_input; +pub(crate) mod preferences; +pub(crate) mod recent; +pub(crate) mod recovery; pub(crate) mod text_edit_plan; pub(crate) use line_edit::{ diff --git a/rust/lithe-core/src/editor/preferences.rs b/rust/lithe-core/src/editor/preferences.rs new file mode 100644 index 00000000..828ad1ca --- /dev/null +++ b/rust/lithe-core/src/editor/preferences.rs @@ -0,0 +1,56 @@ +//! Defaults and bounded persisted options for native editor presentation. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Stored { + line_numbers: Option, + whitespace: Option, + cursor_style: Option, + cursor_blink: Option, + auto_save_delay_milliseconds: Option, +} + +pub(crate) fn resolve_command(payload: Value) -> Result { + let stored: Stored = serde_json::from_value(payload) + .map_err(|_| CoreError::new(ErrorCode::InvalidRequest, "Invalid editor preferences"))?; + let style = match stored.cursor_style.as_deref() { + Some("block") => "block", + Some("underline") => "underline", + _ => "line", + }; + Ok(json!({ + "lineNumbers":stored.line_numbers.unwrap_or(true), + "whitespace":stored.whitespace.unwrap_or(false), + "cursorStyle":style, + "cursorBlink":stored.cursor_blink.unwrap_or(true), + "autoSaveDelayMilliseconds":stored.auto_save_delay_milliseconds.unwrap_or(1500).clamp(250,60_000), + "minimumAutoSaveDelayMilliseconds":250, + "maximumAutoSaveDelayMilliseconds":60_000 + })) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn missing_and_old_preferences_get_the_existing_editor_defaults() { + let value = resolve_command(json!({})).unwrap(); + assert_eq!(value["lineNumbers"], true); + assert_eq!(value["whitespace"], false); + assert_eq!(value["cursorStyle"], "line"); + assert_eq!(value["cursorBlink"], true); + assert_eq!(value["autoSaveDelayMilliseconds"], 1500); + } + #[test] + fn corrupted_values_are_bounded_without_replacing_valid_choices() { + let value = resolve_command(json!({"lineNumbers":false,"cursorStyle":"unknown","cursorBlink":false,"autoSaveDelayMilliseconds":1})).unwrap(); + assert_eq!(value["lineNumbers"], false); + assert_eq!(value["cursorStyle"], "line"); + assert_eq!(value["cursorBlink"], false); + assert_eq!(value["autoSaveDelayMilliseconds"], 250); + } +} diff --git a/rust/lithe-core/src/editor/recent.rs b/rust/lithe-core/src/editor/recent.rs new file mode 100644 index 00000000..552298cd --- /dev/null +++ b/rust/lithe-core/src/editor/recent.rs @@ -0,0 +1,180 @@ +//! Bounded recent-file and recent-edit history, independent of native storage and widgets. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +const MAX_ENTRIES: usize = 80; + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Entry { + path: String, + name: String, + #[serde(default)] + line: u32, + #[serde(default)] + column: u32, + /// Logical recency sequence supplied by the previous reduction, never wall time. + #[serde(default)] + opened: u64, + #[serde(default)] + edited: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Event { + kind: String, + path: String, + #[serde(default)] + name: String, + #[serde(default)] + line: u32, + #[serde(default)] + column: u32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + #[serde(default)] + entries: Vec, + event: Option, + #[serde(default)] + query: String, + #[serde(default)] + only_edited: bool, +} + +fn valid_path(path: &str) -> bool { + !path.is_empty() && path.len() <= 32_768 && !path.contains(['\0', '\r', '\n']) +} + +pub(crate) fn reduce_command(payload: Value) -> Result { + let mut request: Request = serde_json::from_value(payload) + .map_err(|_| CoreError::new(ErrorCode::InvalidRequest, "Invalid recent-file request"))?; + if request.entries.len() > MAX_ENTRIES || request.query.len() > 4096 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Recent-file request exceeds its limit", + )); + } + request + .entries + .retain(|e| valid_path(&e.path) && e.name.len() <= 1024); + if let Some(event) = request.event { + if !valid_path(&event.path) || event.name.len() > 1024 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid recent-file identity", + )); + } + let previous = request + .entries + .iter() + .find(|e| e.path == event.path) + .cloned(); + let sequence = request + .entries + .iter() + .map(|e| e.opened.max(e.edited)) + .max() + .unwrap_or(0) + .saturating_add(1); + request.entries.retain(|e| e.path != event.path); + match event.kind.as_str() { + "removed" => {} + "opened" | "edited" => { + request.entries.insert( + 0, + Entry { + path: event.path, + name: event.name, + line: event.line, + column: event.column, + opened: sequence, + edited: if event.kind == "edited" { + sequence + } else { + previous.map_or(0, |e| e.edited) + }, + }, + ); + } + _ => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unknown recent-file event", + )) + } + } + } + request + .entries + .sort_by(|a, b| b.opened.cmp(&a.opened).then(a.path.cmp(&b.path))); + let mut seen = std::collections::BTreeSet::new(); + request + .entries + .retain(|entry| seen.insert(entry.path.clone())); + request.entries.truncate(MAX_ENTRIES); + let words: Vec<_> = request + .query + .split_whitespace() + .map(str::to_lowercase) + .collect(); + let mut results: Vec<_> = request + .entries + .iter() + .filter(|entry| { + if request.only_edited && entry.edited == 0 { + return false; + } + let text = format!("{} {}", entry.name, entry.path).to_lowercase(); + words.iter().all(|word| text.contains(word)) + }) + .cloned() + .collect(); + if request.only_edited { + results.sort_by(|a, b| b.edited.cmp(&a.edited).then(a.path.cmp(&b.path))); + } + Ok(json!({"entries":request.entries,"results":results})) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn opening_another_file_does_not_reorder_the_recent_edits() { + let mut state = json!([]); + for (kind, path) in [("edited", "a"), ("edited", "b"), ("opened", "a")] { + state = reduce_command( + json!({"entries":state,"event":{"kind":kind,"path":path,"name":path}}), + ) + .unwrap()["entries"] + .clone(); + } + let result = reduce_command(json!({"entries":state,"onlyEdited":true})).unwrap(); + assert_eq!(result["entries"][0]["path"], "a"); + assert_eq!(result["results"][0]["path"], "b"); + assert_eq!(result["entries"].as_array().unwrap().len(), 2); + } + #[test] + fn query_and_removal_keep_history_bounded_and_empty_results_valid() { + let state = json!([{"path":"src/Alpha.go","name":"Alpha.go","opened":1}]); + assert_eq!( + reduce_command(json!({"entries":state,"query":"SRC alpha"})).unwrap()["results"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + reduce_command( + json!({"entries":state,"event":{"kind":"removed","path":"src/Alpha.go"}}) + ) + .unwrap()["entries"], + json!([]) + ); + } +} diff --git a/rust/lithe-core/src/editor/recovery.rs b/rust/lithe-core/src/editor/recovery.rs new file mode 100644 index 00000000..30c6d246 --- /dev/null +++ b/rust/lithe-core/src/editor/recovery.rs @@ -0,0 +1,80 @@ +//! Recovery decisions and storage budgets; native adapters own atomic file I/O. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + /// Checkpointed content and the disk version it was edited against. + base_hash: String, + disk_hash: Option, + #[serde(default)] + scratch: bool, + #[serde(default)] + bytes: u64, + #[serde(default)] + saved: bool, + #[serde(default)] + discarded: bool, +} + +pub(crate) fn decide_command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload) + .map_err(|_| CoreError::new(ErrorCode::InvalidRequest, "Invalid recovery decision"))?; + if request.base_hash.len() > 128 || request.disk_hash.as_ref().is_some_and(|h| h.len() > 128) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid recovery identity", + )); + } + let action = if request.saved || request.discarded { + "remove" + } else if request.bytes > 64 * 1024 * 1024 { + "tooLarge" + } else if request.scratch { + "restoreScratch" + } else if request.disk_hash.as_deref() == Some(request.base_hash.as_str()) { + "restore" + } else { + // A changed or deleted source never gets silently overwritten by a checkpoint. + "restoreConflict" + }; + Ok( + json!({"action":action,"maxDocumentBytes":64*1024*1024,"maxStorageBytes":256*1024*1024,"maxDocuments":64}), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn changed_or_deleted_disk_files_restore_as_conflicts() { + for disk in [json!(null), json!("new")] { + assert_eq!( + decide_command(json!({"baseHash":"old","diskHash":disk})).unwrap()["action"], + "restoreConflict" + ); + } + assert_eq!( + decide_command(json!({"baseHash":"old","diskHash":"old"})).unwrap()["action"], + "restore" + ); + } + #[test] + fn scratch_and_saved_checkpoints_have_different_lifecycles() { + assert_eq!( + decide_command(json!({"baseHash":"","scratch":true})).unwrap()["action"], + "restoreScratch" + ); + assert_eq!( + decide_command(json!({"baseHash":"","scratch":true,"saved":true})).unwrap()["action"], + "remove" + ); + assert_eq!( + decide_command(json!({"baseHash":"","bytes":64*1024*1024+1})).unwrap()["action"], + "tooLarge" + ); + } +} diff --git a/rust/lithe-core/src/keymap/mod.rs b/rust/lithe-core/src/keymap/mod.rs index e9eec500..8ba3136b 100644 --- a/rust/lithe-core/src/keymap/mod.rs +++ b/rust/lithe-core/src/keymap/mod.rs @@ -1,6 +1,7 @@ //! Bounded context parsing and Core-owned truth tables for synchronous key dispatch. mod bindings; +pub(crate) mod schemes; pub use bindings::{analyze_conflicts, AnalyzeConflictsRequest}; diff --git a/rust/lithe-core/src/keymap/schemes.rs b/rust/lithe-core/src/keymap/schemes.rs new file mode 100644 index 00000000..8e752495 --- /dev/null +++ b/rust/lithe-core/src/keymap/schemes.rs @@ -0,0 +1,234 @@ +//! Named shortcut schemes with independent overrides and an undeletable default. + +use super::bindings::{parse_binding, BindingPlatform}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +type Bindings = BTreeMap>; + +#[derive(Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Scheme { + id: String, + /// An empty default name is localized by the host; user names are never translated. + #[serde(default)] + name: String, + #[serde(default)] + bindings: Bindings, +} + +#[derive(Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct State { + #[serde(default)] + active: String, + #[serde(default)] + schemes: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + #[serde(default)] + state: State, + /// Existing single-scheme overrides migrate only when no schemes have been stored. + #[serde(default)] + current: Bindings, + #[serde(default)] + operation: String, + #[serde(default)] + id: String, + #[serde(default)] + name: String, + #[serde(default)] + bindings: Bindings, +} + +fn rejected(reason: &str) -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + "Shortcut scheme operation was rejected", + ) + .with_details(reason) +} + +fn valid_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +fn validate_bindings(bindings: &Bindings) -> Result<(), CoreError> { + if bindings.len() > 4096 { + return Err(rejected("too_many_bindings")); + } + for (command, keys) in bindings { + if command.is_empty() || command.len() > 128 || keys.len() > 8 { + return Err(rejected("invalid_binding")); + } + for key in keys { + // The native catalog already uses explicit meta/ctrl names. Platform aliases + // have no role in a portable saved scheme; parsing still uses the shared grammar. + if parse_binding(key, BindingPlatform::Macos).is_err() { + return Err(rejected("invalid_binding")); + } + } + } + Ok(()) +} + +pub(crate) fn reduce_command(payload: Value) -> Result { + if payload.to_string().len() > 1024 * 1024 { + return Err(rejected("too_large")); + } + let mut request: Request = + serde_json::from_value(payload).map_err(|_| rejected("invalid_request"))?; + let state = &mut request.state; + if state.schemes.is_empty() { + validate_bindings(&request.current)?; + state.schemes.push(Scheme { + id: "default".into(), + name: String::new(), + bindings: request.current, + }); + state.active = "default".into(); + } + if state.schemes.len() > 16 { + return Err(rejected("too_many_schemes")); + } + let mut ids = BTreeSet::new(); + for scheme in &state.schemes { + if !valid_id(&scheme.id) || !ids.insert(scheme.id.clone()) || scheme.name.len() > 256 { + return Err(rejected("invalid_scheme")); + } + validate_bindings(&scheme.bindings)?; + } + if !ids.contains("default") { + return Err(rejected("missing_default")); + } + if !ids.contains(&state.active) { + state.active = "default".into(); + } + let name = request.name.trim(); + if matches!(request.operation.as_str(), "saveAs" | "rename") { + if name.is_empty() || name.chars().count() > 64 || name.chars().any(char::is_control) { + return Err(rejected("invalid_name")); + } + if state + .schemes + .iter() + .any(|s| s.id != request.id && s.name.to_lowercase() == name.to_lowercase()) + { + return Err(rejected("duplicate_name")); + } + } + match request.operation.as_str() { + "" => {} + "update" => { + validate_bindings(&request.bindings)?; + state + .schemes + .iter_mut() + .find(|s| s.id == state.active) + .expect("active scheme was validated") + .bindings = request.bindings; + } + "select" => { + if !ids.contains(&request.id) { + return Err(rejected("missing_scheme")); + } + state.active = request.id; + } + "saveAs" => { + if state.schemes.len() == 16 { + return Err(rejected("too_many_schemes")); + } + if !valid_id(&request.id) || ids.contains(&request.id) { + return Err(rejected("invalid_id")); + } + let mut saved = state + .schemes + .iter() + .find(|s| s.id == state.active) + .expect("active scheme was validated") + .clone(); + saved.id = request.id.clone(); + saved.name = name.to_string(); + state.schemes.push(saved); + state.active = request.id; + } + "rename" => { + if request.id == "default" { + return Err(rejected("default_is_fixed")); + } + state + .schemes + .iter_mut() + .find(|s| s.id == request.id) + .ok_or_else(|| rejected("missing_scheme"))? + .name = name.to_string(); + } + "delete" => { + if request.id == "default" { + return Err(rejected("default_is_fixed")); + } + if !ids.contains(&request.id) { + return Err(rejected("missing_scheme")); + } + state.schemes.retain(|s| s.id != request.id); + if state.active == request.id { + state.active = "default".into(); + } + } + _ => return Err(rejected("unknown_operation")), + } + let effective = &state + .schemes + .iter() + .find(|s| s.id == state.active) + .expect("active scheme still exists") + .bindings; + Ok(json!({"state":state,"bindings":effective})) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn migration_copy_switch_and_delete_preserve_independent_overrides() { + let default = reduce_command(json!({"current":{"search.files":["meta+f"]}})).unwrap(); + let copied = reduce_command( + json!({"state":default["state"],"operation":"saveAs","id":"mine","name":"My keys"}), + ) + .unwrap(); + let edited = reduce_command(json!({"state":copied["state"],"operation":"update","bindings":{"search.files":["meta+g"]}})).unwrap(); + let switched = + reduce_command(json!({"state":edited["state"],"operation":"select","id":"default"})) + .unwrap(); + assert_eq!(switched["bindings"]["search.files"], json!(["meta+f"])); + let deleted = + reduce_command(json!({"state":edited["state"],"operation":"delete","id":"mine"})) + .unwrap(); + assert_eq!(deleted["state"]["active"], "default"); + assert_eq!(deleted["bindings"], default["bindings"]); + } + #[test] + fn invalid_names_duplicate_names_and_default_deletion_are_rejected() { + assert!(reduce_command(json!({"operation":"saveAs","id":"a","name":" \n"})).is_err()); + assert!(reduce_command(json!({"operation":"delete","id":"default"})).is_err()); + let state = reduce_command(json!({"operation":"saveAs","id":"a","name":"Mine"})).unwrap() + ["state"] + .clone(); + assert_eq!( + reduce_command(json!({"state":state,"operation":"saveAs","id":"b","name":"mine"})) + .unwrap_err() + .details + .as_deref(), + Some("duplicate_name") + ); + } +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index ace433bd..b79780eb 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -475,6 +475,16 @@ pub enum CoreCommand { /// Applies a deterministic line-level editing transform /// (`editor.lineEdit`). EditorLineEdit, + /// Finds text or plans bounded replacements in a borrowed document (`editor.find`). + EditorFind, + /// Resolves persisted editor presentation preferences (`editor.preferences.resolve`). + EditorPreferencesResolve, + /// Reduces and queries bounded recent-file history (`editor.recent.reduce`). + EditorRecentReduce, + /// Decides how a native checkpoint should recover (`editor.recovery.decide`). + EditorRecoveryDecide, + /// Reduces named shortcut schemes (`keymap.schemes.reduce`). + KeymapSchemesReduce, /// Validates and orders a plugin document edit batch /// (`editor.planTextEdits`). EditorPlanTextEdits, @@ -1035,6 +1045,11 @@ impl CoreCommand { "lsp.classifyNavigationTarget" => Some(Self::LspClassifyNavigationTarget), "lsp.declarationOrUsages" => Some(Self::LspDeclarationOrUsages), "editor.lineEdit" => Some(Self::EditorLineEdit), + "editor.find" => Some(Self::EditorFind), + "editor.preferences.resolve" => Some(Self::EditorPreferencesResolve), + "editor.recent.reduce" => Some(Self::EditorRecentReduce), + "editor.recovery.decide" => Some(Self::EditorRecoveryDecide), + "keymap.schemes.reduce" => Some(Self::KeymapSchemesReduce), "editor.planTextEdits" => Some(Self::EditorPlanTextEdits), "editor.parseNavigationInput" => Some(Self::EditorParseNavigationInput), "editor.navigationHistory.reduce" => Some(Self::EditorNavigationHistoryReduce), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 2a798810..857434df 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -37,8 +37,13 @@ use crate::protocol::{CoreError, ErrorCode}; use serde_json::json; pub fn execute_json(request: &str) -> String { + execute_document_json(request, None) +} + +/// Borrows an immutable document for one request; no text is retained after dispatch. +pub(crate) fn execute_document_json(request: &str, document: Option<&str>) -> String { crate::diagnostics::helper_failures::install(); - let response = execute(request); + let response = execute(request, document); serde_json::to_string(&response).unwrap_or_else(|_| { serde_json::to_string(&CoreResponse::failure( None, @@ -123,7 +128,7 @@ fn log_v2_result( } } -fn execute(request: &str) -> CoreResponse { +fn execute(request: &str, document: Option<&str>) -> CoreResponse { let parsed: CoreRequest = match serde_json::from_str(request) { Ok(request) => request, Err(error) => { @@ -136,12 +141,12 @@ fn execute(request: &str) -> CoreResponse { }; // The trace spans every scope below and records once, after the final response. let trace = crate::diagnostics::request_trace::Request::begin(&parsed); - let response = execute_parsed(parsed); + let response = execute_parsed(parsed, document); trace.finish(&response); response } -fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { +fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreResponse { let id = parsed.id.clone(); let response_id = id.clone(); let operation_id = parsed.operation_id.clone().or_else(|| id.clone()); @@ -1957,6 +1962,24 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { parsed.payload, crate::lsp::declaration_or_usages::decide_command, ), + CoreCommand::EditorFind => match crate::editor::find::command(parsed.payload, document) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + CoreCommand::EditorPreferencesResolve => plugin_response( + id, + parsed.payload, + crate::editor::preferences::resolve_command, + ), + CoreCommand::EditorRecentReduce => { + plugin_response(id, parsed.payload, crate::editor::recent::reduce_command) + } + CoreCommand::EditorRecoveryDecide => { + plugin_response(id, parsed.payload, crate::editor::recovery::decide_command) + } + CoreCommand::KeymapSchemesReduce => { + plugin_response(id, parsed.payload, crate::keymap::schemes::reduce_command) + } CoreCommand::EditorPlanTextEdits => plugin_response( id, parsed.payload, diff --git a/rust/lithe-core/src/runtime/ffi.rs b/rust/lithe-core/src/runtime/ffi.rs index e2f19aa8..16441f68 100644 --- a/rust/lithe-core/src/runtime/ffi.rs +++ b/rust/lithe-core/src/runtime/ffi.rs @@ -54,6 +54,47 @@ pub unsafe extern "C" fn lithe_core_execute_json(request: *const c_char) -> *mut response_pointer(&crate::execute_json(&request)) } +/// Executes a request over borrowed UTF-8 text without a JSON copy of the source. +/// Uses the ordinary dispatcher, request trace, cancellation and response envelope. +/// Core never retains the source pointer. The caller releases the returned string +/// exactly once using [`lithe_core_free_string`]. +/// +/// # Safety +/// `request` follows [`lithe_core_execute_json`]. `bytes` points to `length` immutable, +/// readable bytes until this synchronous call returns, or is null for an empty input. +#[no_mangle] +pub unsafe extern "C" fn lithe_core_execute_document_json( + request: *const c_char, + bytes: *const u8, + length: usize, +) -> *mut c_char { + if request.is_null() { + // SAFETY: The normal entry point explicitly accepts null requests. + return lithe_core_execute_json(request); + } + let source = if length == 0 { + Ok("") + } else if bytes.is_null() || length > 64 * 1024 * 1024 { + Err(()) + } else { + // SAFETY: The caller guarantees this non-null slice remains readable and immutable. + std::str::from_utf8(std::slice::from_raw_parts(bytes, length)).map_err(|_| ()) + }; + match source { + Ok(text) => { + // SAFETY: The request is a readable NUL-terminated string for this call. + let request = CStr::from_ptr(request).to_string_lossy(); + response_pointer(&super::dispatcher::execute_document_json( + &request, + Some(text), + )) + } + Err(()) => response_pointer( + r#"{"id":null,"ok":false,"error":{"code":"invalid_request","message":"Invalid editor source","details":"source_too_large_or_invalid"}}"#, + ), + } +} + /// Loads the merged language-provider catalog for an optional workspace root. /// /// The returned string is owned by the caller and must be released exactly @@ -234,3 +275,50 @@ mod text_content_tests { assert_eq!(response["error"]["code"], "invalid_request"); } } + +#[cfg(test)] +mod editor_essentials_tests { + use super::*; + use serde_json::{json, Value}; + #[test] + fn borrowed_document_abi_matches_shared_vectors() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../fixtures/editor/ide-essentials-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let request = CString::new( + json!({"command":case["command"],"payload":case["payload"]}).to_string(), + ) + .unwrap(); + let document = case["document"].as_str().unwrap_or(""); + let pointer = unsafe { + lithe_core_execute_document_json( + request.as_ptr(), + document.as_ptr(), + document.len(), + ) + }; + let response: Value = + unsafe { serde_json::from_str(CStr::from_ptr(pointer).to_str().unwrap()).unwrap() }; + unsafe { lithe_core_free_string(pointer) }; + assert_eq!( + response["data"], case["expected"], + "{}: {}", + case["name"], response + ); + } + } + #[test] + fn borrowed_document_rejects_invalid_pointer_and_utf8_without_replacement() { + let request = CString::new(r#"{"command":"editor.find","payload":{"query":"a"}}"#).unwrap(); + let invalid = [0xff]; + for (data, len) in [(std::ptr::null(), 1), (invalid.as_ptr(), 1)] { + let pointer = unsafe { lithe_core_execute_document_json(request.as_ptr(), data, len) }; + let response: Value = + unsafe { serde_json::from_str(CStr::from_ptr(pointer).to_str().unwrap()).unwrap() }; + unsafe { lithe_core_free_string(pointer) }; + assert_eq!(response["error"]["code"], "invalid_request"); + } + } +}