use crate::command::{Command, Suggestion};
use crate::file_explorer::FileExplorerDecoration;
use crate::hooks::{HookCallback, HookRegistry};
use crate::menu::{Menu, MenuItem};
use crate::overlay::{OverlayHandle, OverlayNamespace};
use crate::text_property::{TextProperty, TextPropertyEntry};
use crate::BufferId;
use crate::SplitId;
use crate::TerminalId;
use lsp_types;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::ops::Range;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use ts_rs::TS;
pub struct CommandRegistry {
commands: std::sync::RwLock<Vec<Command>>,
}
impl CommandRegistry {
pub fn new() -> Self {
Self {
commands: std::sync::RwLock::new(Vec::new()),
}
}
pub fn register(&self, command: Command) {
let mut commands = self.commands.write().unwrap();
commands.retain(|c| c.name != command.name);
commands.push(command);
}
pub fn unregister(&self, name: &str) {
let mut commands = self.commands.write().unwrap();
commands.retain(|c| c.name != name);
}
}
impl Default for CommandRegistry {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct JsCallbackId(pub u64);
impl JsCallbackId {
pub fn new(id: u64) -> Self {
Self(id)
}
pub fn as_u64(self) -> u64 {
self.0
}
}
impl From<u64> for JsCallbackId {
fn from(id: u64) -> Self {
Self(id)
}
}
impl From<JsCallbackId> for u64 {
fn from(id: JsCallbackId) -> u64 {
id.0
}
}
impl std::fmt::Display for JsCallbackId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct TerminalResult {
#[ts(type = "number")]
pub buffer_id: u64,
#[ts(type = "number")]
pub terminal_id: u64,
#[ts(type = "number | null")]
pub split_id: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct VirtualBufferResult {
#[ts(type = "number")]
pub buffer_id: u64,
#[ts(type = "number | null")]
pub split_id: Option<u64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct AnimationRect {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub enum PluginAnimationEdge {
Top,
Bottom,
Left,
Right,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[ts(export)]
pub enum PluginAnimationKind {
#[serde(rename_all = "camelCase")]
SlideIn {
from: PluginAnimationEdge,
duration_ms: u32,
delay_ms: u32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct BufferGroupResult {
#[ts(type = "number")]
pub group_id: u64,
#[ts(type = "Record<string, number>")]
pub panels: HashMap<String, u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum PluginResponse {
VirtualBufferCreated {
request_id: u64,
buffer_id: BufferId,
split_id: Option<SplitId>,
},
TerminalCreated {
request_id: u64,
buffer_id: BufferId,
terminal_id: TerminalId,
split_id: Option<SplitId>,
},
LspRequest {
request_id: u64,
#[ts(type = "any")]
result: Result<JsonValue, String>,
},
HighlightsComputed {
request_id: u64,
spans: Vec<TsHighlightSpan>,
},
BufferText {
request_id: u64,
text: Result<String, String>,
},
LineStartPosition {
request_id: u64,
position: Option<usize>,
},
LineEndPosition {
request_id: u64,
position: Option<usize>,
},
BufferLineCount {
request_id: u64,
count: Option<usize>,
},
CompositeBufferCreated {
request_id: u64,
buffer_id: BufferId,
},
SplitByLabel {
request_id: u64,
split_id: Option<SplitId>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum PluginAsyncMessage {
ProcessOutput {
process_id: u64,
stdout: String,
stderr: String,
exit_code: i32,
},
DelayComplete {
callback_id: u64,
},
ProcessStdout { process_id: u64, data: String },
ProcessStderr { process_id: u64, data: String },
ProcessExit {
process_id: u64,
callback_id: u64,
exit_code: i32,
},
LspResponse {
language: String,
request_id: u64,
#[ts(type = "any")]
result: Result<JsonValue, String>,
},
PluginResponse(crate::api::PluginResponse),
GrepStreamingProgress {
search_id: u64,
matches_json: String,
},
GrepStreamingComplete {
search_id: u64,
callback_id: u64,
total_matches: usize,
truncated: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct CursorInfo {
pub position: usize,
#[cfg_attr(
feature = "plugins",
ts(type = "{ start: number; end: number } | null")
)]
pub selection: Option<Range<usize>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct ActionSpec {
pub action: String,
#[serde(default = "default_action_count")]
pub count: u32,
}
fn default_action_count() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct BufferInfo {
#[ts(type = "number")]
pub id: BufferId,
#[serde(serialize_with = "serialize_path")]
#[ts(type = "string")]
pub path: Option<PathBuf>,
pub modified: bool,
pub length: usize,
pub is_virtual: bool,
pub view_mode: String,
pub is_composing_in_any_split: bool,
pub compose_width: Option<u16>,
pub language: String,
#[serde(default)]
pub is_preview: bool,
#[serde(default)]
#[ts(type = "number[]")]
pub splits: Vec<SplitId>,
}
fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(
&path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
)
}
fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
for range in ranges {
seq.serialize_element(&(range.start, range.end))?;
}
seq.end()
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct BufferSavedDiff {
pub equal: bool,
#[serde(serialize_with = "serialize_ranges_as_tuples")]
#[ts(type = "Array<[number, number]>")]
pub byte_ranges: Vec<Range<usize>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct ViewportInfo {
pub top_byte: usize,
pub top_line: Option<usize>,
pub left_column: usize,
pub width: u16,
pub height: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct SplitSnapshot {
pub split_id: usize,
pub buffer_id: BufferId,
pub viewport: ViewportInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct KeyEventPayload {
pub key: String,
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
pub meta: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct LayoutHints {
#[ts(optional)]
pub compose_width: Option<u16>,
#[ts(optional)]
pub column_guides: Option<Vec<u16>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(untagged)]
#[ts(export)]
pub enum OverlayColorSpec {
#[ts(type = "[number, number, number]")]
Rgb(u8, u8, u8),
ThemeKey(String),
}
impl OverlayColorSpec {
pub fn rgb(r: u8, g: u8, b: u8) -> Self {
Self::Rgb(r, g, b)
}
pub fn theme_key(key: impl Into<String>) -> Self {
Self::ThemeKey(key.into())
}
pub fn as_rgb(&self) -> Option<(u8, u8, u8)> {
match self {
Self::Rgb(r, g, b) => Some((*r, *g, *b)),
Self::ThemeKey(_) => None,
}
}
pub fn as_theme_key(&self) -> Option<&str> {
match self {
Self::ThemeKey(key) => Some(key),
Self::Rgb(_, _, _) => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
#[derive(Default)]
pub struct OverlayOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<OverlayColorSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<OverlayColorSpec>,
#[serde(default)]
pub underline: bool,
#[serde(default)]
pub bold: bool,
#[serde(default)]
pub italic: bool,
#[serde(default)]
pub strikethrough: bool,
#[serde(default)]
pub extend_to_line_end: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsCompositeLayoutConfig")]
pub struct CompositeLayoutConfig {
#[serde(rename = "type")]
#[ts(rename = "type")]
pub layout_type: String,
#[serde(default)]
#[ts(optional)]
pub ratios: Option<Vec<f32>>,
#[serde(default = "default_true", rename = "showSeparator")]
#[ts(rename = "showSeparator")]
pub show_separator: bool,
#[serde(default)]
#[ts(optional)]
pub spacing: Option<u16>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsCompositeSourceConfig")]
pub struct CompositeSourceConfig {
#[serde(rename = "bufferId")]
#[ts(rename = "bufferId")]
pub buffer_id: usize,
pub label: String,
#[serde(default)]
pub editable: bool,
#[serde(default)]
pub style: Option<CompositePaneStyle>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsCompositePaneStyle")]
pub struct CompositePaneStyle {
#[serde(default, rename = "addBg")]
#[ts(optional, rename = "addBg", type = "[number, number, number]")]
pub add_bg: Option<[u8; 3]>,
#[serde(default, rename = "removeBg")]
#[ts(optional, rename = "removeBg", type = "[number, number, number]")]
pub remove_bg: Option<[u8; 3]>,
#[serde(default, rename = "modifyBg")]
#[ts(optional, rename = "modifyBg", type = "[number, number, number]")]
pub modify_bg: Option<[u8; 3]>,
#[serde(default, rename = "gutterStyle")]
#[ts(optional, rename = "gutterStyle")]
pub gutter_style: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsCompositeHunk")]
pub struct CompositeHunk {
#[serde(rename = "oldStart")]
#[ts(rename = "oldStart")]
pub old_start: usize,
#[serde(rename = "oldCount")]
#[ts(rename = "oldCount")]
pub old_count: usize,
#[serde(rename = "newStart")]
#[ts(rename = "newStart")]
pub new_start: usize,
#[serde(rename = "newCount")]
#[ts(rename = "newCount")]
pub new_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsCreateCompositeBufferOptions")]
pub struct CreateCompositeBufferOptions {
#[serde(default)]
pub name: String,
#[serde(default)]
pub mode: String,
pub layout: CompositeLayoutConfig,
pub sources: Vec<CompositeSourceConfig>,
#[serde(default)]
pub hunks: Option<Vec<CompositeHunk>>,
#[serde(default, rename = "initialFocusHunk")]
#[ts(optional, rename = "initialFocusHunk")]
pub initial_focus_hunk: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum ViewTokenWireKind {
Text(String),
Newline,
Space,
Break,
BinaryByte(u8),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct ViewTokenStyle {
#[serde(default)]
#[ts(type = "[number, number, number] | null")]
pub fg: Option<(u8, u8, u8)>,
#[serde(default)]
#[ts(type = "[number, number, number] | null")]
pub bg: Option<(u8, u8, u8)>,
#[serde(default)]
pub bold: bool,
#[serde(default)]
pub italic: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct ViewTokenWire {
#[ts(type = "number | null")]
pub source_offset: Option<usize>,
pub kind: ViewTokenWireKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub style: Option<ViewTokenStyle>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ViewTransformPayload {
pub range: Range<usize>,
pub tokens: Vec<ViewTokenWire>,
pub layout_hints: Option<LayoutHints>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct EditorStateSnapshot {
pub active_buffer_id: BufferId,
pub active_split_id: usize,
pub buffers: HashMap<BufferId, BufferInfo>,
pub buffer_saved_diffs: HashMap<BufferId, BufferSavedDiff>,
pub primary_cursor: Option<CursorInfo>,
pub all_cursors: Vec<CursorInfo>,
pub viewport: Option<ViewportInfo>,
#[serde(default)]
pub splits: Vec<SplitSnapshot>,
pub buffer_cursor_positions: HashMap<BufferId, usize>,
pub buffer_text_properties: HashMap<BufferId, Vec<TextProperty>>,
pub selected_text: Option<String>,
pub clipboard: String,
pub working_dir: PathBuf,
#[serde(default)]
pub authority_label: String,
#[serde(skip)]
#[ts(type = "any")]
pub diagnostics: Arc<HashMap<String, Vec<lsp_types::Diagnostic>>>,
#[serde(skip)]
#[ts(type = "any")]
pub folding_ranges: Arc<HashMap<String, Vec<lsp_types::FoldingRange>>>,
#[serde(skip)]
#[ts(type = "any")]
pub config: Arc<serde_json::Value>,
#[serde(skip)]
#[ts(type = "any")]
pub user_config: Arc<serde_json::Value>,
#[ts(type = "GrammarInfo[]")]
pub available_grammars: Vec<GrammarInfoSnapshot>,
pub editor_mode: Option<String>,
#[ts(type = "any")]
pub plugin_view_states: HashMap<BufferId, HashMap<String, serde_json::Value>>,
#[serde(skip)]
#[ts(skip)]
pub plugin_view_states_split: usize,
#[serde(skip)]
#[ts(skip)]
pub keybinding_labels: HashMap<String, String>,
#[ts(type = "any")]
pub plugin_global_states: HashMap<String, HashMap<String, serde_json::Value>>,
}
impl EditorStateSnapshot {
pub fn new() -> Self {
Self {
active_buffer_id: BufferId(0),
active_split_id: 0,
buffers: HashMap::new(),
buffer_saved_diffs: HashMap::new(),
primary_cursor: None,
all_cursors: Vec::new(),
viewport: None,
splits: Vec::new(),
buffer_cursor_positions: HashMap::new(),
buffer_text_properties: HashMap::new(),
selected_text: None,
clipboard: String::new(),
working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
authority_label: String::new(),
diagnostics: Arc::new(HashMap::new()),
folding_ranges: Arc::new(HashMap::new()),
config: Arc::new(serde_json::Value::Null),
user_config: Arc::new(serde_json::Value::Null),
available_grammars: Vec::new(),
editor_mode: None,
plugin_view_states: HashMap::new(),
plugin_view_states_split: 0,
keybinding_labels: HashMap::new(),
plugin_global_states: HashMap::new(),
}
}
}
impl Default for EditorStateSnapshot {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct GrammarInfoSnapshot {
pub name: String,
pub source: String,
pub file_extensions: Vec<String>,
pub short_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum MenuPosition {
Top,
Bottom,
Before(String),
After(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum PluginCommand {
InsertText {
buffer_id: BufferId,
position: usize,
text: String,
},
DeleteRange {
buffer_id: BufferId,
range: Range<usize>,
},
AddOverlay {
buffer_id: BufferId,
namespace: Option<OverlayNamespace>,
range: Range<usize>,
options: OverlayOptions,
},
RemoveOverlay {
buffer_id: BufferId,
handle: OverlayHandle,
},
SetStatus { message: String },
ApplyTheme { theme_name: String },
OverrideThemeColors { overrides: HashMap<String, [u8; 3]> },
ReloadConfig,
SetSetting {
plugin_name: String,
path: String,
#[ts(type = "unknown")]
value: JsonValue,
},
RegisterCommand { command: Command },
UnregisterCommand { name: String },
OpenFileInBackground { path: PathBuf },
InsertAtCursor { text: String },
SpawnProcess {
command: String,
args: Vec<String>,
cwd: Option<String>,
callback_id: JsCallbackId,
},
Delay {
callback_id: JsCallbackId,
duration_ms: u64,
},
SpawnBackgroundProcess {
process_id: u64,
command: String,
args: Vec<String>,
cwd: Option<String>,
callback_id: JsCallbackId,
},
KillBackgroundProcess { process_id: u64 },
SpawnProcessWait {
process_id: u64,
callback_id: JsCallbackId,
},
SetLayoutHints {
buffer_id: BufferId,
split_id: Option<SplitId>,
range: Range<usize>,
hints: LayoutHints,
},
SetLineNumbers { buffer_id: BufferId, enabled: bool },
SetViewMode { buffer_id: BufferId, mode: String },
SetLineWrap {
buffer_id: BufferId,
split_id: Option<SplitId>,
enabled: bool,
},
SubmitViewTransform {
buffer_id: BufferId,
split_id: Option<SplitId>,
payload: ViewTransformPayload,
},
ClearViewTransform {
buffer_id: BufferId,
split_id: Option<SplitId>,
},
SetViewState {
buffer_id: BufferId,
key: String,
#[ts(type = "any")]
value: Option<serde_json::Value>,
},
SetGlobalState {
plugin_name: String,
key: String,
#[ts(type = "any")]
value: Option<serde_json::Value>,
},
ClearAllOverlays { buffer_id: BufferId },
ClearNamespace {
buffer_id: BufferId,
namespace: OverlayNamespace,
},
ClearOverlaysInRange {
buffer_id: BufferId,
start: usize,
end: usize,
},
AddVirtualText {
buffer_id: BufferId,
virtual_text_id: String,
position: usize,
text: String,
color: (u8, u8, u8),
use_bg: bool, before: bool, },
AddVirtualTextStyled {
buffer_id: BufferId,
virtual_text_id: String,
position: usize,
text: String,
fg: Option<OverlayColorSpec>,
bg: Option<OverlayColorSpec>,
bold: bool,
italic: bool,
before: bool,
},
RemoveVirtualText {
buffer_id: BufferId,
virtual_text_id: String,
},
RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
ClearVirtualTexts { buffer_id: BufferId },
AddVirtualLine {
buffer_id: BufferId,
position: usize,
text: String,
fg_color: Option<OverlayColorSpec>,
bg_color: Option<OverlayColorSpec>,
above: bool,
namespace: String,
priority: i32,
},
ClearVirtualTextNamespace {
buffer_id: BufferId,
namespace: String,
},
AddConceal {
buffer_id: BufferId,
namespace: OverlayNamespace,
start: usize,
end: usize,
replacement: Option<String>,
},
ClearConcealNamespace {
buffer_id: BufferId,
namespace: OverlayNamespace,
},
ClearConcealsInRange {
buffer_id: BufferId,
start: usize,
end: usize,
},
AddFold {
buffer_id: BufferId,
start: usize,
end: usize,
placeholder: Option<String>,
},
ClearFolds { buffer_id: BufferId },
AddSoftBreak {
buffer_id: BufferId,
namespace: OverlayNamespace,
position: usize,
indent: u16,
},
ClearSoftBreakNamespace {
buffer_id: BufferId,
namespace: OverlayNamespace,
},
ClearSoftBreaksInRange {
buffer_id: BufferId,
start: usize,
end: usize,
},
RefreshLines { buffer_id: BufferId },
RefreshAllLines,
HookCompleted { hook_name: String },
SetLineIndicator {
buffer_id: BufferId,
line: usize,
namespace: String,
symbol: String,
color: (u8, u8, u8),
priority: i32,
},
SetLineIndicators {
buffer_id: BufferId,
lines: Vec<usize>,
namespace: String,
symbol: String,
color: (u8, u8, u8),
priority: i32,
},
ClearLineIndicators {
buffer_id: BufferId,
namespace: String,
},
SetFileExplorerDecorations {
namespace: String,
decorations: Vec<FileExplorerDecoration>,
},
ClearFileExplorerDecorations {
namespace: String,
},
OpenFileAtLocation {
path: PathBuf,
line: Option<usize>, column: Option<usize>, },
OpenFileInSplit {
split_id: usize,
path: PathBuf,
line: Option<usize>, column: Option<usize>, },
StartPrompt {
label: String,
prompt_type: String, },
StartPromptWithInitial {
label: String,
prompt_type: String,
initial_value: String,
},
StartPromptAsync {
label: String,
initial_value: String,
callback_id: JsCallbackId,
},
AwaitNextKey { callback_id: JsCallbackId },
SetKeyCaptureActive { active: bool },
SetPromptSuggestions { suggestions: Vec<Suggestion> },
SetPromptInputSync { sync: bool },
AddMenuItem {
menu_label: String,
item: MenuItem,
position: MenuPosition,
},
AddMenu { menu: Menu, position: MenuPosition },
RemoveMenuItem {
menu_label: String,
item_label: String,
},
RemoveMenu { menu_label: String },
CreateVirtualBuffer {
name: String,
mode: String,
read_only: bool,
},
CreateVirtualBufferWithContent {
name: String,
mode: String,
read_only: bool,
entries: Vec<TextPropertyEntry>,
show_line_numbers: bool,
show_cursors: bool,
editing_disabled: bool,
hidden_from_tabs: bool,
request_id: Option<u64>,
},
CreateVirtualBufferInSplit {
name: String,
mode: String,
read_only: bool,
entries: Vec<TextPropertyEntry>,
ratio: f32,
direction: Option<String>,
panel_id: Option<String>,
show_line_numbers: bool,
show_cursors: bool,
editing_disabled: bool,
line_wrap: Option<bool>,
before: bool,
request_id: Option<u64>,
},
SetVirtualBufferContent {
buffer_id: BufferId,
entries: Vec<TextPropertyEntry>,
},
GetTextPropertiesAtCursor { buffer_id: BufferId },
CreateBufferGroup {
name: String,
mode: String,
layout_json: String,
request_id: Option<u64>,
},
SetPanelContent {
group_id: usize,
panel_name: String,
entries: Vec<TextPropertyEntry>,
},
CloseBufferGroup { group_id: usize },
FocusPanel { group_id: usize, panel_name: String },
DefineMode {
name: String,
bindings: Vec<(String, String)>, read_only: bool,
allow_text_input: bool,
inherit_normal_bindings: bool,
plugin_name: Option<String>,
},
ShowBuffer { buffer_id: BufferId },
StartAnimationArea {
id: u64,
rect: AnimationRect,
kind: PluginAnimationKind,
},
StartAnimationVirtualBuffer {
id: u64,
buffer_id: BufferId,
kind: PluginAnimationKind,
},
CancelAnimation { id: u64 },
CreateVirtualBufferInExistingSplit {
name: String,
mode: String,
read_only: bool,
entries: Vec<TextPropertyEntry>,
split_id: SplitId,
show_line_numbers: bool,
show_cursors: bool,
editing_disabled: bool,
line_wrap: Option<bool>,
request_id: Option<u64>,
},
CloseBuffer { buffer_id: BufferId },
CreateCompositeBuffer {
name: String,
mode: String,
layout: CompositeLayoutConfig,
sources: Vec<CompositeSourceConfig>,
hunks: Option<Vec<CompositeHunk>>,
initial_focus_hunk: Option<usize>,
request_id: Option<u64>,
},
UpdateCompositeAlignment {
buffer_id: BufferId,
hunks: Vec<CompositeHunk>,
},
CloseCompositeBuffer { buffer_id: BufferId },
FlushLayout,
CompositeNextHunk { buffer_id: BufferId },
CompositePrevHunk { buffer_id: BufferId },
FocusSplit { split_id: SplitId },
SetSplitBuffer {
split_id: SplitId,
buffer_id: BufferId,
},
SetSplitScroll { split_id: SplitId, top_byte: usize },
RequestHighlights {
buffer_id: BufferId,
range: Range<usize>,
request_id: u64,
},
CloseSplit { split_id: SplitId },
SetSplitRatio {
split_id: SplitId,
ratio: f32,
},
SetSplitLabel { split_id: SplitId, label: String },
ClearSplitLabel { split_id: SplitId },
GetSplitByLabel { label: String, request_id: u64 },
DistributeSplitsEvenly {
split_ids: Vec<SplitId>,
},
SetBufferCursor {
buffer_id: BufferId,
position: usize,
},
SetBufferShowCursors { buffer_id: BufferId, show: bool },
SendLspRequest {
language: String,
method: String,
#[ts(type = "any")]
params: Option<JsonValue>,
request_id: u64,
},
SetClipboard { text: String },
DeleteSelection,
SetContext {
name: String,
active: bool,
},
SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
ExecuteAction {
action_name: String,
},
ExecuteActions {
actions: Vec<ActionSpec>,
},
GetBufferText {
buffer_id: BufferId,
start: usize,
end: usize,
request_id: u64,
},
GetLineStartPosition {
buffer_id: BufferId,
line: u32,
request_id: u64,
},
GetLineEndPosition {
buffer_id: BufferId,
line: u32,
request_id: u64,
},
GetBufferLineCount {
buffer_id: BufferId,
request_id: u64,
},
ScrollToLineCenter {
split_id: SplitId,
buffer_id: BufferId,
line: usize,
},
ScrollBufferToLine {
buffer_id: BufferId,
line: usize,
},
SetEditorMode {
mode: Option<String>,
},
ShowActionPopup {
popup_id: String,
title: String,
message: String,
actions: Vec<ActionPopupAction>,
},
DisableLspForLanguage {
language: String,
},
RestartLspForLanguage {
language: String,
},
SetLspRootUri {
language: String,
uri: String,
},
CreateScrollSyncGroup {
group_id: u32,
left_split: SplitId,
right_split: SplitId,
},
SetScrollSyncAnchors {
group_id: u32,
anchors: Vec<(usize, usize)>,
},
RemoveScrollSyncGroup {
group_id: u32,
},
SaveBufferToPath {
buffer_id: BufferId,
path: PathBuf,
},
LoadPlugin {
path: PathBuf,
callback_id: JsCallbackId,
},
UnloadPlugin {
name: String,
callback_id: JsCallbackId,
},
ReloadPlugin {
name: String,
callback_id: JsCallbackId,
},
ListPlugins {
callback_id: JsCallbackId,
},
ReloadThemes { apply_theme: Option<String> },
RegisterGrammar {
language: String,
grammar_path: String,
extensions: Vec<String>,
},
RegisterLanguageConfig {
language: String,
config: LanguagePackConfig,
},
RegisterLspServer {
language: String,
config: LspServerPackConfig,
},
ReloadGrammars { callback_id: JsCallbackId },
CreateTerminal {
cwd: Option<String>,
direction: Option<String>,
ratio: Option<f32>,
focus: Option<bool>,
persistent: bool,
request_id: u64,
},
SendTerminalInput {
terminal_id: TerminalId,
data: String,
},
CloseTerminal {
terminal_id: TerminalId,
},
GrepProject {
pattern: String,
fixed_string: bool,
case_sensitive: bool,
max_results: usize,
whole_words: bool,
callback_id: JsCallbackId,
},
GrepProjectStreaming {
pattern: String,
fixed_string: bool,
case_sensitive: bool,
max_results: usize,
whole_words: bool,
search_id: u64,
callback_id: JsCallbackId,
},
ReplaceInBuffer {
file_path: PathBuf,
matches: Vec<(usize, usize)>,
replacement: String,
callback_id: JsCallbackId,
},
SetAuthority {
#[ts(type = "unknown")]
payload: JsonValue,
},
ClearAuthority,
SetRemoteIndicatorState {
#[ts(type = "unknown")]
state: JsonValue,
},
ClearRemoteIndicatorState,
SpawnHostProcess {
command: String,
args: Vec<String>,
cwd: Option<String>,
callback_id: JsCallbackId,
},
KillHostProcess { process_id: u64 },
}
impl PluginCommand {
pub fn debug_variant_name(&self) -> String {
let dbg = format!("{:?}", self);
dbg.split([' ', '{', '(']).next().unwrap_or("?").to_string()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct LanguagePackConfig {
#[serde(default)]
pub comment_prefix: Option<String>,
#[serde(default)]
pub block_comment_start: Option<String>,
#[serde(default)]
pub block_comment_end: Option<String>,
#[serde(default)]
pub use_tabs: Option<bool>,
#[serde(default)]
pub tab_size: Option<usize>,
#[serde(default)]
pub auto_indent: Option<bool>,
#[serde(default)]
pub show_whitespace_tabs: Option<bool>,
#[serde(default)]
pub formatter: Option<FormatterPackConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct FormatterPackConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct ProcessLimitsPackConfig {
#[serde(default)]
pub max_memory_percent: Option<u32>,
#[serde(default)]
pub max_cpu_percent: Option<u32>,
#[serde(default)]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct LspServerPackConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub auto_start: Option<bool>,
#[serde(default)]
#[ts(type = "Record<string, unknown> | null")]
pub initialization_options: Option<JsonValue>,
#[serde(default)]
pub process_limits: Option<ProcessLimitsPackConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export)]
pub enum HunkStatus {
Pending,
Staged,
Discarded,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ReviewHunk {
pub id: String,
pub file: String,
pub context_header: String,
pub status: HunkStatus,
pub base_range: Option<(usize, usize)>,
pub modified_range: Option<(usize, usize)>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsActionPopupAction")]
pub struct ActionPopupAction {
pub id: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct ActionPopupOptions {
pub id: String,
pub title: String,
pub message: String,
pub actions: Vec<ActionPopupAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct TsHighlightSpan {
pub start: u32,
pub end: u32,
#[ts(type = "[number, number, number]")]
pub color: (u8, u8, u8),
pub bold: bool,
pub italic: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct SpawnResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct BackgroundProcessResult {
#[ts(type = "number")]
pub process_id: u64,
pub exit_code: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct GrepMatch {
pub file: String,
#[ts(type = "number")]
pub buffer_id: usize,
#[ts(type = "number")]
pub byte_offset: usize,
#[ts(type = "number")]
pub length: usize,
#[ts(type = "number")]
pub line: usize,
#[ts(type = "number")]
pub column: usize,
pub context: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct ReplaceResult {
#[ts(type = "number")]
pub replacements: usize,
#[ts(type = "number")]
pub buffer_id: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
#[ts(export, rename = "TextPropertyEntry", rename_all = "camelCase")]
pub struct JsTextPropertyEntry {
pub text: String,
#[serde(default)]
#[ts(optional, type = "Record<string, unknown>")]
pub properties: Option<HashMap<String, JsonValue>>,
#[serde(default)]
#[ts(optional, type = "Partial<OverlayOptions>")]
pub style: Option<OverlayOptions>,
#[serde(default)]
#[ts(optional)]
pub inline_overlays: Option<Vec<crate::text_property::InlineOverlay>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct DirEntry {
pub name: String,
pub is_file: bool,
pub is_dir: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct JsPosition {
pub line: u32,
pub character: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct JsRange {
pub start: JsPosition,
pub end: JsPosition,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct JsDiagnostic {
pub uri: String,
pub message: String,
pub severity: Option<u8>,
pub range: JsRange,
#[ts(optional)]
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct CreateVirtualBufferOptions {
pub name: String,
#[serde(default)]
#[ts(optional)]
pub mode: Option<String>,
#[serde(default, rename = "readOnly")]
#[ts(optional, rename = "readOnly")]
pub read_only: Option<bool>,
#[serde(default, rename = "showLineNumbers")]
#[ts(optional, rename = "showLineNumbers")]
pub show_line_numbers: Option<bool>,
#[serde(default, rename = "showCursors")]
#[ts(optional, rename = "showCursors")]
pub show_cursors: Option<bool>,
#[serde(default, rename = "editingDisabled")]
#[ts(optional, rename = "editingDisabled")]
pub editing_disabled: Option<bool>,
#[serde(default, rename = "hiddenFromTabs")]
#[ts(optional, rename = "hiddenFromTabs")]
pub hidden_from_tabs: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub entries: Option<Vec<JsTextPropertyEntry>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct CreateVirtualBufferInSplitOptions {
pub name: String,
#[serde(default)]
#[ts(optional)]
pub mode: Option<String>,
#[serde(default, rename = "readOnly")]
#[ts(optional, rename = "readOnly")]
pub read_only: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub ratio: Option<f32>,
#[serde(default)]
#[ts(optional)]
pub direction: Option<String>,
#[serde(default, rename = "panelId")]
#[ts(optional, rename = "panelId")]
pub panel_id: Option<String>,
#[serde(default, rename = "showLineNumbers")]
#[ts(optional, rename = "showLineNumbers")]
pub show_line_numbers: Option<bool>,
#[serde(default, rename = "showCursors")]
#[ts(optional, rename = "showCursors")]
pub show_cursors: Option<bool>,
#[serde(default, rename = "editingDisabled")]
#[ts(optional, rename = "editingDisabled")]
pub editing_disabled: Option<bool>,
#[serde(default, rename = "lineWrap")]
#[ts(optional, rename = "lineWrap")]
pub line_wrap: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub before: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub entries: Option<Vec<JsTextPropertyEntry>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct CreateVirtualBufferInExistingSplitOptions {
pub name: String,
#[serde(rename = "splitId")]
#[ts(rename = "splitId")]
pub split_id: usize,
#[serde(default)]
#[ts(optional)]
pub mode: Option<String>,
#[serde(default, rename = "readOnly")]
#[ts(optional, rename = "readOnly")]
pub read_only: Option<bool>,
#[serde(default, rename = "showLineNumbers")]
#[ts(optional, rename = "showLineNumbers")]
pub show_line_numbers: Option<bool>,
#[serde(default, rename = "showCursors")]
#[ts(optional, rename = "showCursors")]
pub show_cursors: Option<bool>,
#[serde(default, rename = "editingDisabled")]
#[ts(optional, rename = "editingDisabled")]
pub editing_disabled: Option<bool>,
#[serde(default, rename = "lineWrap")]
#[ts(optional, rename = "lineWrap")]
pub line_wrap: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub entries: Option<Vec<JsTextPropertyEntry>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct CreateTerminalOptions {
#[serde(default)]
#[ts(optional)]
pub cwd: Option<String>,
#[serde(default)]
#[ts(optional)]
pub direction: Option<String>,
#[serde(default)]
#[ts(optional)]
pub ratio: Option<f32>,
#[serde(default)]
#[ts(optional)]
pub focus: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub persistent: Option<bool>,
}
#[derive(Debug, Clone, Serialize, TS)]
#[ts(export, type = "Array<Record<string, unknown>>")]
pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
#[cfg(feature = "plugins")]
mod fromjs_impls {
use super::*;
use rquickjs::{Ctx, FromJs, Value};
macro_rules! impl_from_js_via_serde {
($($T:ty),+ $(,)?) => {
$(
impl<'js> FromJs<'js> for $T {
fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
from: "object",
to: stringify!($T),
message: Some(e.to_string()),
})
}
}
)+
};
}
impl_from_js_via_serde!(
JsTextPropertyEntry,
CreateVirtualBufferOptions,
CreateVirtualBufferInSplitOptions,
CreateVirtualBufferInExistingSplitOptions,
ActionSpec,
ActionPopupAction,
ActionPopupOptions,
ViewTokenWire,
ViewTokenStyle,
LayoutHints,
CompositeHunk,
LanguagePackConfig,
LspServerPackConfig,
ProcessLimitsPackConfig,
CreateTerminalOptions,
);
impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
rquickjs_serde::to_value(ctx.clone(), &self.0)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
}
impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
let json: serde_json::Value =
rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
from: "object",
to: "CreateCompositeBufferOptions (json)",
message: Some(e.to_string()),
})?;
serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
from: "json",
to: "CreateCompositeBufferOptions",
message: Some(e.to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use rquickjs::{Context, Runtime};
fn with_js<R>(f: impl for<'js> FnOnce(Ctx<'js>) -> R) -> R {
let rt = Runtime::new().expect("create rquickjs runtime");
let ctx = Context::full(&rt).expect("create rquickjs context");
ctx.with(f)
}
fn eval_as<T>(src: &str) -> T
where
for<'js> T: rquickjs::FromJs<'js>,
{
with_js(|ctx| {
let value: Value = ctx
.eval::<Value, _>(src.as_bytes())
.expect("eval JS source");
T::from_js(&ctx, value).expect("from_js decode")
})
}
#[test]
fn js_text_property_entry_decodes_text_and_properties() {
let got: JsTextPropertyEntry =
eval_as("({text: 'hello', properties: {file: '/x.rs'}})");
assert_eq!(got.text, "hello");
let props = got.properties.expect("properties present");
assert_eq!(props.get("file").and_then(|v| v.as_str()), Some("/x.rs"));
}
#[test]
fn create_virtual_buffer_options_decodes_name() {
let got: CreateVirtualBufferOptions = eval_as("({name: 'logs', readOnly: true})");
assert_eq!(got.name, "logs");
assert_eq!(got.read_only, Some(true));
}
#[test]
fn create_virtual_buffer_in_split_options_decodes_ratio() {
let got: CreateVirtualBufferInSplitOptions =
eval_as("({name: 'diag', ratio: 0.25, direction: 'horizontal'})");
assert_eq!(got.name, "diag");
assert!(matches!(got.ratio, Some(r) if (r - 0.25).abs() < 1e-6));
assert_eq!(got.direction.as_deref(), Some("horizontal"));
}
#[test]
fn create_virtual_buffer_in_existing_split_options_decodes_splitid() {
let got: CreateVirtualBufferInExistingSplitOptions =
eval_as("({name: 'n', splitId: 7})");
assert_eq!(got.name, "n");
assert_eq!(got.split_id, 7);
}
#[test]
fn create_terminal_options_decodes_cwd_and_focus() {
let got: CreateTerminalOptions =
eval_as("({cwd: '/tmp', direction: 'vertical', focus: false})");
assert_eq!(got.cwd.as_deref(), Some("/tmp"));
assert_eq!(got.direction.as_deref(), Some("vertical"));
assert_eq!(got.focus, Some(false));
}
#[test]
fn action_spec_decodes_action_and_count() {
let got: ActionSpec = eval_as("({action: 'move_word_right', count: 5})");
assert_eq!(got.action, "move_word_right");
assert_eq!(got.count, 5);
}
#[test]
fn action_popup_action_decodes_id_and_label() {
let got: ActionPopupAction = eval_as("({id: 'ok', label: 'OK'})");
assert_eq!(got.id, "ok");
assert_eq!(got.label, "OK");
}
#[test]
fn action_popup_options_decodes_actions_list() {
let got: ActionPopupOptions = eval_as(
"({id: 'p', title: 't', message: 'm', \
actions: [{id: 'ok', label: 'OK'}]})",
);
assert_eq!(got.id, "p");
assert_eq!(got.title, "t");
assert_eq!(got.message, "m");
assert_eq!(got.actions.len(), 1);
assert_eq!(got.actions[0].id, "ok");
}
#[test]
fn view_token_wire_decodes_offset_and_kind() {
let got: ViewTokenWire = eval_as("({source_offset: 42, kind: 'Newline'})");
assert_eq!(got.source_offset, Some(42));
assert!(matches!(got.kind, ViewTokenWireKind::Newline));
}
#[test]
fn view_token_style_decodes_boolean_flags() {
let got: ViewTokenStyle = eval_as("({bold: true, italic: true})");
assert!(got.bold);
assert!(got.italic);
assert!(got.fg.is_none());
}
#[test]
fn layout_hints_decodes_compose_width() {
let got: LayoutHints = eval_as("({composeWidth: 120})");
assert_eq!(got.compose_width, Some(120));
assert!(got.column_guides.is_none());
}
#[test]
fn create_composite_buffer_options_decodes_name_and_sources() {
let got: CreateCompositeBufferOptions = eval_as(
"({name: 'diff', mode: 'm', \
layout: {type: 'side-by-side', ratios: [0.5, 0.5], showSeparator: true}, \
sources: [{bufferId: 3, label: 'OLD'}]})",
);
assert_eq!(got.name, "diff");
assert_eq!(got.layout.layout_type, "side-by-side");
assert_eq!(got.sources.len(), 1);
assert_eq!(got.sources[0].buffer_id, 3);
assert_eq!(got.sources[0].label, "OLD");
}
#[test]
fn composite_hunk_decodes_all_fields() {
let got: CompositeHunk =
eval_as("({oldStart: 1, oldCount: 2, newStart: 3, newCount: 4})");
assert_eq!(got.old_start, 1);
assert_eq!(got.old_count, 2);
assert_eq!(got.new_start, 3);
assert_eq!(got.new_count, 4);
}
#[test]
fn language_pack_config_decodes_comment_prefix_and_tab_size() {
let got: LanguagePackConfig =
eval_as("({commentPrefix: '//', tabSize: 7, useTabs: true})");
assert_eq!(got.comment_prefix.as_deref(), Some("//"));
assert_eq!(got.tab_size, Some(7));
assert_eq!(got.use_tabs, Some(true));
}
#[test]
fn lsp_server_pack_config_decodes_command_and_args() {
let got: LspServerPackConfig =
eval_as("({command: 'rust-analyzer', args: ['--log'], autoStart: true})");
assert_eq!(got.command, "rust-analyzer");
assert_eq!(got.args, vec!["--log".to_string()]);
assert_eq!(got.auto_start, Some(true));
}
#[test]
fn process_limits_pack_config_decodes_percentages() {
let got: ProcessLimitsPackConfig =
eval_as("({maxMemoryPercent: 75, maxCpuPercent: 50, enabled: true})");
assert_eq!(got.max_memory_percent, Some(75));
assert_eq!(got.max_cpu_percent, Some(50));
assert_eq!(got.enabled, Some(true));
}
#[test]
fn text_properties_at_cursor_into_js_preserves_length() {
use rquickjs::IntoJs;
with_js(|ctx| {
let mut entry = std::collections::HashMap::new();
entry.insert("k".to_string(), serde_json::json!("v"));
let payload = TextPropertiesAtCursor(vec![entry.clone(), entry]);
let v = payload.into_js(&ctx).expect("into_js");
let arr = v.as_array().expect("expected JS array");
assert_eq!(arr.len(), 2);
});
}
}
}
pub struct PluginApi {
hooks: Arc<RwLock<HookRegistry>>,
commands: Arc<RwLock<CommandRegistry>>,
command_sender: std::sync::mpsc::Sender<PluginCommand>,
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
}
impl PluginApi {
pub fn new(
hooks: Arc<RwLock<HookRegistry>>,
commands: Arc<RwLock<CommandRegistry>>,
command_sender: std::sync::mpsc::Sender<PluginCommand>,
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
) -> Self {
Self {
hooks,
commands,
command_sender,
state_snapshot,
}
}
pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
let mut hooks = self.hooks.write().unwrap();
hooks.add_hook(hook_name, callback);
}
pub fn unregister_hooks(&self, hook_name: &str) {
let mut hooks = self.hooks.write().unwrap();
hooks.remove_hooks(hook_name);
}
pub fn register_command(&self, command: Command) {
let commands = self.commands.read().unwrap();
commands.register(command);
}
pub fn unregister_command(&self, name: &str) {
let commands = self.commands.read().unwrap();
commands.unregister(name);
}
pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
self.command_sender
.send(command)
.map_err(|e| format!("Failed to send command: {}", e))
}
pub fn insert_text(
&self,
buffer_id: BufferId,
position: usize,
text: String,
) -> Result<(), String> {
self.send_command(PluginCommand::InsertText {
buffer_id,
position,
text,
})
}
pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
self.send_command(PluginCommand::DeleteRange { buffer_id, range })
}
pub fn add_overlay(
&self,
buffer_id: BufferId,
namespace: Option<String>,
range: Range<usize>,
options: OverlayOptions,
) -> Result<(), String> {
self.send_command(PluginCommand::AddOverlay {
buffer_id,
namespace: namespace.map(OverlayNamespace::from_string),
range,
options,
})
}
pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
self.send_command(PluginCommand::RemoveOverlay {
buffer_id,
handle: OverlayHandle::from_string(handle),
})
}
pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
self.send_command(PluginCommand::ClearNamespace {
buffer_id,
namespace: OverlayNamespace::from_string(namespace),
})
}
pub fn clear_overlays_in_range(
&self,
buffer_id: BufferId,
start: usize,
end: usize,
) -> Result<(), String> {
self.send_command(PluginCommand::ClearOverlaysInRange {
buffer_id,
start,
end,
})
}
pub fn set_status(&self, message: String) -> Result<(), String> {
self.send_command(PluginCommand::SetStatus { message })
}
pub fn open_file_at_location(
&self,
path: PathBuf,
line: Option<usize>,
column: Option<usize>,
) -> Result<(), String> {
self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
}
pub fn open_file_in_split(
&self,
split_id: usize,
path: PathBuf,
line: Option<usize>,
column: Option<usize>,
) -> Result<(), String> {
self.send_command(PluginCommand::OpenFileInSplit {
split_id,
path,
line,
column,
})
}
pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
self.send_command(PluginCommand::StartPrompt { label, prompt_type })
}
pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
}
pub fn set_prompt_input_sync(&self, sync: bool) -> Result<(), String> {
self.send_command(PluginCommand::SetPromptInputSync { sync })
}
pub fn add_menu_item(
&self,
menu_label: String,
item: MenuItem,
position: MenuPosition,
) -> Result<(), String> {
self.send_command(PluginCommand::AddMenuItem {
menu_label,
item,
position,
})
}
pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
self.send_command(PluginCommand::AddMenu { menu, position })
}
pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
self.send_command(PluginCommand::RemoveMenuItem {
menu_label,
item_label,
})
}
pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
self.send_command(PluginCommand::RemoveMenu { menu_label })
}
pub fn create_virtual_buffer(
&self,
name: String,
mode: String,
read_only: bool,
) -> Result<(), String> {
self.send_command(PluginCommand::CreateVirtualBuffer {
name,
mode,
read_only,
})
}
pub fn create_virtual_buffer_with_content(
&self,
name: String,
mode: String,
read_only: bool,
entries: Vec<TextPropertyEntry>,
) -> Result<(), String> {
self.send_command(PluginCommand::CreateVirtualBufferWithContent {
name,
mode,
read_only,
entries,
show_line_numbers: true,
show_cursors: true,
editing_disabled: false,
hidden_from_tabs: false,
request_id: None,
})
}
pub fn set_virtual_buffer_content(
&self,
buffer_id: BufferId,
entries: Vec<TextPropertyEntry>,
) -> Result<(), String> {
self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
}
pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
}
pub fn define_mode(
&self,
name: String,
bindings: Vec<(String, String)>,
read_only: bool,
allow_text_input: bool,
) -> Result<(), String> {
self.send_command(PluginCommand::DefineMode {
name,
bindings,
read_only,
allow_text_input,
inherit_normal_bindings: false,
plugin_name: None,
})
}
pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
self.send_command(PluginCommand::ShowBuffer { buffer_id })
}
pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
self.send_command(PluginCommand::SetSplitScroll {
split_id: SplitId(split_id),
top_byte,
})
}
pub fn get_highlights(
&self,
buffer_id: BufferId,
range: Range<usize>,
request_id: u64,
) -> Result<(), String> {
self.send_command(PluginCommand::RequestHighlights {
buffer_id,
range,
request_id,
})
}
pub fn get_active_buffer_id(&self) -> BufferId {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.active_buffer_id
}
pub fn get_active_split_id(&self) -> usize {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.active_split_id
}
pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.buffers.get(&buffer_id).cloned()
}
pub fn list_buffers(&self) -> Vec<BufferInfo> {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.buffers.values().cloned().collect()
}
pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.primary_cursor.clone()
}
pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.all_cursors.clone()
}
pub fn get_viewport(&self) -> Option<ViewportInfo> {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.viewport.clone()
}
pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
Arc::clone(&self.state_snapshot)
}
}
impl Clone for PluginApi {
fn clone(&self) -> Self {
Self {
hooks: Arc::clone(&self.hooks),
commands: Arc::clone(&self.commands),
command_sender: self.command_sender.clone(),
state_snapshot: Arc::clone(&self.state_snapshot),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, rename_all = "camelCase")]
pub struct TsCompletionCandidate {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub insert_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default)]
pub score: i64,
#[serde(default)]
pub is_snippet: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_data: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct TsCompletionContext {
pub prefix: String,
pub cursor_byte: usize,
pub word_start_byte: usize,
pub buffer_len: usize,
pub is_large_file: bool,
pub text_around_cursor: String,
pub cursor_offset_in_text: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub language_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, rename_all = "camelCase")]
pub struct TsCompletionProviderRegistration {
pub id: String,
pub display_name: String,
#[serde(default = "default_plugin_provider_priority")]
pub priority: u32,
#[serde(default)]
pub language_ids: Vec<String>,
}
fn default_plugin_provider_priority() -> u32 {
50
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_api_creation() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let _clone = api.clone();
}
#[test]
fn test_register_hook() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
api.register_hook("test-hook", Box::new(|_| true));
let hook_registry = hooks.read().unwrap();
assert_eq!(hook_registry.hook_count("test-hook"), 1);
}
#[test]
fn test_send_command() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let result = api.insert_text(BufferId(1), 0, "test".to_string());
assert!(result.is_ok());
let received = rx.try_recv();
assert!(received.is_ok());
match received.unwrap() {
PluginCommand::InsertText {
buffer_id,
position,
text,
} => {
assert_eq!(buffer_id.0, 1);
assert_eq!(position, 0);
assert_eq!(text, "test");
}
_ => panic!("Wrong command type"),
}
}
#[test]
fn test_add_overlay_command() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let result = api.add_overlay(
BufferId(1),
Some("test-overlay".to_string()),
0..10,
OverlayOptions {
fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
bg: None,
underline: true,
bold: false,
italic: false,
strikethrough: false,
extend_to_line_end: false,
url: None,
},
);
assert!(result.is_ok());
let received = rx.try_recv().unwrap();
match received {
PluginCommand::AddOverlay {
buffer_id,
namespace,
range,
options,
} => {
assert_eq!(buffer_id.0, 1);
assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
assert_eq!(range, 0..10);
assert!(matches!(
options.fg,
Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
));
assert!(options.bg.is_none());
assert!(options.underline);
assert!(!options.bold);
assert!(!options.italic);
assert!(!options.extend_to_line_end);
}
_ => panic!("Wrong command type"),
}
}
#[test]
fn test_set_status_command() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let result = api.set_status("Test status".to_string());
assert!(result.is_ok());
let received = rx.try_recv().unwrap();
match received {
PluginCommand::SetStatus { message } => {
assert_eq!(message, "Test status");
}
_ => panic!("Wrong command type"),
}
}
#[test]
fn test_get_active_buffer_id() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut snapshot = state_snapshot.write().unwrap();
snapshot.active_buffer_id = BufferId(5);
}
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let active_id = api.get_active_buffer_id();
assert_eq!(active_id.0, 5);
}
#[test]
fn test_get_buffer_info() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut snapshot = state_snapshot.write().unwrap();
let buffer_info = BufferInfo {
id: BufferId(1),
path: Some(std::path::PathBuf::from("/test/file.txt")),
modified: true,
length: 100,
is_virtual: false,
view_mode: "source".to_string(),
is_composing_in_any_split: false,
compose_width: None,
language: "text".to_string(),
is_preview: false,
splits: Vec::new(),
};
snapshot.buffers.insert(BufferId(1), buffer_info);
}
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let info = api.get_buffer_info(BufferId(1));
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.id.0, 1);
assert_eq!(
info.path.as_ref().unwrap().to_str().unwrap(),
"/test/file.txt"
);
assert!(info.modified);
assert_eq!(info.length, 100);
let no_info = api.get_buffer_info(BufferId(999));
assert!(no_info.is_none());
}
#[test]
fn test_list_buffers() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut snapshot = state_snapshot.write().unwrap();
snapshot.buffers.insert(
BufferId(1),
BufferInfo {
id: BufferId(1),
path: Some(std::path::PathBuf::from("/file1.txt")),
modified: false,
length: 50,
is_virtual: false,
view_mode: "source".to_string(),
is_composing_in_any_split: false,
compose_width: None,
language: "text".to_string(),
is_preview: false,
splits: Vec::new(),
},
);
snapshot.buffers.insert(
BufferId(2),
BufferInfo {
id: BufferId(2),
path: Some(std::path::PathBuf::from("/file2.txt")),
modified: true,
length: 100,
is_virtual: false,
view_mode: "source".to_string(),
is_composing_in_any_split: false,
compose_width: None,
language: "text".to_string(),
is_preview: false,
splits: Vec::new(),
},
);
snapshot.buffers.insert(
BufferId(3),
BufferInfo {
id: BufferId(3),
path: None,
modified: false,
length: 0,
is_virtual: true,
view_mode: "source".to_string(),
is_composing_in_any_split: false,
compose_width: None,
language: "text".to_string(),
is_preview: false,
splits: Vec::new(),
},
);
}
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let buffers = api.list_buffers();
assert_eq!(buffers.len(), 3);
assert!(buffers.iter().any(|b| b.id.0 == 1));
assert!(buffers.iter().any(|b| b.id.0 == 2));
assert!(buffers.iter().any(|b| b.id.0 == 3));
}
#[test]
fn test_get_primary_cursor() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut snapshot = state_snapshot.write().unwrap();
snapshot.primary_cursor = Some(CursorInfo {
position: 42,
selection: Some(10..42),
});
}
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let cursor = api.get_primary_cursor();
assert!(cursor.is_some());
let cursor = cursor.unwrap();
assert_eq!(cursor.position, 42);
assert_eq!(cursor.selection, Some(10..42));
}
#[test]
fn test_get_all_cursors() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut snapshot = state_snapshot.write().unwrap();
snapshot.all_cursors = vec![
CursorInfo {
position: 10,
selection: None,
},
CursorInfo {
position: 20,
selection: Some(15..20),
},
CursorInfo {
position: 30,
selection: Some(25..30),
},
];
}
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let cursors = api.get_all_cursors();
assert_eq!(cursors.len(), 3);
assert_eq!(cursors[0].position, 10);
assert_eq!(cursors[0].selection, None);
assert_eq!(cursors[1].position, 20);
assert_eq!(cursors[1].selection, Some(15..20));
assert_eq!(cursors[2].position, 30);
assert_eq!(cursors[2].selection, Some(25..30));
}
#[test]
fn test_get_viewport() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut snapshot = state_snapshot.write().unwrap();
snapshot.viewport = Some(ViewportInfo {
top_byte: 100,
top_line: Some(5),
left_column: 5,
width: 80,
height: 24,
});
}
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let viewport = api.get_viewport();
assert!(viewport.is_some());
let viewport = viewport.unwrap();
assert_eq!(viewport.top_byte, 100);
assert_eq!(viewport.left_column, 5);
assert_eq!(viewport.width, 80);
assert_eq!(viewport.height, 24);
}
#[test]
fn test_composite_buffer_options_rejects_unknown_fields() {
let valid_json = r#"{
"name": "test",
"mode": "diff",
"layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
"sources": [{"bufferId": 1, "label": "old"}]
}"#;
let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
assert!(
result.is_ok(),
"Valid JSON should parse: {:?}",
result.err()
);
let invalid_json = r#"{
"name": "test",
"mode": "diff",
"layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
"sources": [{"buffer_id": 1, "label": "old"}]
}"#;
let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
assert!(
result.is_err(),
"JSON with unknown field should fail to parse"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("unknown field") || err.contains("buffer_id"),
"Error should mention unknown field: {}",
err
);
}
#[test]
fn test_composite_hunk_rejects_unknown_fields() {
let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
assert!(
result.is_ok(),
"Valid JSON should parse: {:?}",
result.err()
);
let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
assert!(
result.is_err(),
"JSON with unknown field should fail to parse"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("unknown field") || err.contains("old_start"),
"Error should mention unknown field: {}",
err
);
}
#[test]
fn test_plugin_response_line_end_position() {
let response = PluginResponse::LineEndPosition {
request_id: 42,
position: Some(100),
};
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("LineEndPosition"));
assert!(json.contains("42"));
assert!(json.contains("100"));
let response_none = PluginResponse::LineEndPosition {
request_id: 1,
position: None,
};
let json_none = serde_json::to_string(&response_none).unwrap();
assert!(json_none.contains("null"));
}
#[test]
fn test_plugin_response_buffer_line_count() {
let response = PluginResponse::BufferLineCount {
request_id: 99,
count: Some(500),
};
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("BufferLineCount"));
assert!(json.contains("99"));
assert!(json.contains("500"));
}
#[test]
fn test_plugin_command_get_line_end_position() {
let command = PluginCommand::GetLineEndPosition {
buffer_id: BufferId(1),
line: 10,
request_id: 123,
};
let json = serde_json::to_string(&command).unwrap();
assert!(json.contains("GetLineEndPosition"));
assert!(json.contains("10"));
}
#[test]
fn test_plugin_command_get_buffer_line_count() {
let command = PluginCommand::GetBufferLineCount {
buffer_id: BufferId(0),
request_id: 456,
};
let json = serde_json::to_string(&command).unwrap();
assert!(json.contains("GetBufferLineCount"));
assert!(json.contains("456"));
}
#[test]
fn test_plugin_command_scroll_to_line_center() {
let command = PluginCommand::ScrollToLineCenter {
split_id: SplitId(1),
buffer_id: BufferId(2),
line: 50,
};
let json = serde_json::to_string(&command).unwrap();
assert!(json.contains("ScrollToLineCenter"));
assert!(json.contains("50"));
}
#[test]
fn js_callback_id_conversions_and_display() {
for raw in [0u64, 1, 42, u64::MAX] {
let id = JsCallbackId::new(raw);
assert_eq!(id.as_u64(), raw);
assert_eq!(u64::from(id), raw);
assert_eq!(JsCallbackId::from(raw), id);
assert_eq!(id.to_string(), raw.to_string());
}
}
#[test]
fn serde_defaults_fire_when_fields_are_omitted() {
let spec: ActionSpec = serde_json::from_str(r#"{"action": "move_left"}"#).unwrap();
assert_eq!(spec.count, 1);
let spec: ActionSpec =
serde_json::from_str(r#"{"action": "move_left", "count": 5}"#).unwrap();
assert_eq!(spec.count, 5);
let layout: CompositeLayoutConfig =
serde_json::from_str(r#"{"type": "side-by-side"}"#).unwrap();
assert!(layout.show_separator);
let layout: CompositeLayoutConfig =
serde_json::from_str(r#"{"type": "side-by-side", "showSeparator": false}"#).unwrap();
assert!(!layout.show_separator);
let reg: TsCompletionProviderRegistration =
serde_json::from_str(r#"{"id": "p", "displayName": "P"}"#).unwrap();
assert_eq!(reg.priority, 50);
let reg: TsCompletionProviderRegistration =
serde_json::from_str(r#"{"id": "p", "displayName": "P", "priority": 3}"#).unwrap();
assert_eq!(reg.priority, 3);
}
fn mk_cmd(name: &str) -> Command {
Command {
name: name.to_string(),
description: String::new(),
action_name: String::new(),
plugin_name: String::new(),
custom_contexts: Vec::new(),
}
}
#[test]
fn command_registry_register_and_unregister_semantics() {
let r = CommandRegistry::new();
r.register(mk_cmd("a"));
r.register(mk_cmd("b"));
assert_eq!(r.commands.read().unwrap().len(), 2);
r.register(mk_cmd("a"));
let names: Vec<String> = r
.commands
.read()
.unwrap()
.iter()
.map(|c| c.name.clone())
.collect();
assert_eq!(names, vec!["b".to_string(), "a".to_string()]);
r.unregister("a");
let names: Vec<String> = r
.commands
.read()
.unwrap()
.iter()
.map(|c| c.name.clone())
.collect();
assert_eq!(names, vec!["b".to_string()]);
r.unregister("nope");
assert_eq!(r.commands.read().unwrap().len(), 1);
}
#[test]
fn overlay_color_spec_accessors_are_variant_specific() {
let rgb = OverlayColorSpec::rgb(12, 34, 56);
assert_eq!(rgb.as_rgb(), Some((12, 34, 56)));
assert_eq!(rgb.as_theme_key(), None);
let tk = OverlayColorSpec::theme_key("ui.status_bar_bg");
assert_eq!(tk.as_rgb(), None);
assert_eq!(tk.as_theme_key(), Some("ui.status_bar_bg"));
}
#[test]
fn plugin_command_debug_variant_name_returns_real_variant() {
let c = PluginCommand::SetStatus {
message: "hi".into(),
};
assert_eq!(c.debug_variant_name(), "SetStatus");
let c2 = PluginCommand::InsertText {
buffer_id: BufferId(1),
position: 0,
text: String::new(),
};
assert_eq!(c2.debug_variant_name(), "InsertText");
}
fn mk_api() -> (
PluginApi,
std::sync::mpsc::Receiver<PluginCommand>,
Arc<RwLock<HookRegistry>>,
Arc<RwLock<CommandRegistry>>,
Arc<RwLock<EditorStateSnapshot>>,
) {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, rx) = std::sync::mpsc::channel();
let snap = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let api = PluginApi::new(hooks.clone(), commands.clone(), tx, snap.clone());
(api, rx, hooks, commands, snap)
}
#[test]
fn plugin_api_unregister_hooks_clears_registry() {
let (api, _rx, hooks, _cmds, _snap) = mk_api();
api.register_hook("h", Box::new(|_| true));
assert_eq!(hooks.read().unwrap().hook_count("h"), 1);
api.unregister_hooks("h");
assert_eq!(hooks.read().unwrap().hook_count("h"), 0);
}
#[test]
fn plugin_api_register_and_unregister_command_write_through() {
let (api, _rx, _hooks, cmds, _snap) = mk_api();
api.register_command(mk_cmd("x"));
assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 1);
api.unregister_command("x");
assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 0);
}
macro_rules! assert_dispatches {
($call:expr, $pattern:pat $(if $guard:expr)?) => {{
let (api, rx, _h, _c, _s) = mk_api();
let _ = $call(&api);
match rx.try_recv().expect("no command sent") {
$pattern $(if $guard)? => {}
other => panic!("unexpected command variant: {:?}", other),
}
}};
}
#[test]
fn plugin_api_send_command_methods_dispatch_correctly() {
assert_dispatches!(
|a: &PluginApi| a.delete_range(BufferId(7), 3..9),
PluginCommand::DeleteRange { buffer_id, range }
if buffer_id == BufferId(7) && range == (3..9)
);
assert_dispatches!(
|a: &PluginApi| a.remove_overlay(BufferId(2), "h-1".into()),
PluginCommand::RemoveOverlay { buffer_id, handle }
if buffer_id == BufferId(2) && handle.as_str() == "h-1"
);
assert_dispatches!(
|a: &PluginApi| a.clear_namespace(BufferId(3), "diag".into()),
PluginCommand::ClearNamespace { buffer_id, namespace }
if buffer_id == BufferId(3) && namespace.as_str() == "diag"
);
assert_dispatches!(
|a: &PluginApi| a.clear_overlays_in_range(BufferId(4), 10, 20),
PluginCommand::ClearOverlaysInRange { buffer_id, start, end }
if buffer_id == BufferId(4) && start == 10 && end == 20
);
assert_dispatches!(
|a: &PluginApi| a.open_file_at_location(
PathBuf::from("/tmp/x.rs"), Some(4), Some(8)
),
PluginCommand::OpenFileAtLocation { path, line, column }
if path == PathBuf::from("/tmp/x.rs")
&& line == Some(4)
&& column == Some(8)
);
assert_dispatches!(
|a: &PluginApi| a.open_file_in_split(
2, PathBuf::from("/tmp/y.rs"), Some(5), None
),
PluginCommand::OpenFileInSplit { split_id, path, line, column }
if split_id == 2
&& path == PathBuf::from("/tmp/y.rs")
&& line == Some(5)
&& column.is_none()
);
assert_dispatches!(
|a: &PluginApi| a.start_prompt("label".into(), "cmd".into()),
PluginCommand::StartPrompt { label, prompt_type }
if label == "label" && prompt_type == "cmd"
);
assert_dispatches!(
|a: &PluginApi| a.set_prompt_suggestions(vec![
Suggestion::new("one".into()),
Suggestion::new("two".into()),
]),
PluginCommand::SetPromptSuggestions { suggestions }
if suggestions.len() == 2
&& suggestions[0].text == "one"
&& suggestions[1].text == "two"
);
assert_dispatches!(
|a: &PluginApi| a.set_prompt_input_sync(true),
PluginCommand::SetPromptInputSync { sync } if sync
);
assert_dispatches!(
|a: &PluginApi| a.set_prompt_input_sync(false),
PluginCommand::SetPromptInputSync { sync } if !sync
);
assert_dispatches!(
|a: &PluginApi| a.add_menu_item(
"File".into(),
MenuItem::Label { info: "info".into() },
MenuPosition::Bottom,
),
PluginCommand::AddMenuItem { menu_label, item, position }
if menu_label == "File"
&& matches!(item, MenuItem::Label { ref info } if info == "info")
&& matches!(position, MenuPosition::Bottom)
);
assert_dispatches!(
|a: &PluginApi| a.add_menu(
Menu {
id: None,
label: "Help".into(),
items: vec![],
when: None,
},
MenuPosition::After("Edit".into()),
),
PluginCommand::AddMenu { menu, position }
if menu.label == "Help"
&& matches!(position, MenuPosition::After(ref s) if s == "Edit")
);
assert_dispatches!(
|a: &PluginApi| a.remove_menu_item("File".into(), "Open".into()),
PluginCommand::RemoveMenuItem { menu_label, item_label }
if menu_label == "File" && item_label == "Open"
);
assert_dispatches!(
|a: &PluginApi| a.remove_menu("File".into()),
PluginCommand::RemoveMenu { menu_label } if menu_label == "File"
);
assert_dispatches!(
|a: &PluginApi| a.create_virtual_buffer("buf".into(), "mode".into(), true),
PluginCommand::CreateVirtualBuffer { name, mode, read_only }
if name == "buf" && mode == "mode" && read_only
);
assert_dispatches!(
|a: &PluginApi| a.create_virtual_buffer_with_content(
"n".into(), "m".into(), false, vec![]
),
PluginCommand::CreateVirtualBufferWithContent {
name, mode, read_only, show_line_numbers, show_cursors,
editing_disabled, hidden_from_tabs, request_id, ..
}
if name == "n" && mode == "m" && !read_only
&& show_line_numbers && show_cursors
&& !editing_disabled && !hidden_from_tabs
&& request_id.is_none()
);
assert_dispatches!(
|a: &PluginApi| a.set_virtual_buffer_content(BufferId(9), vec![]),
PluginCommand::SetVirtualBufferContent { buffer_id, entries }
if buffer_id == BufferId(9) && entries.is_empty()
);
assert_dispatches!(
|a: &PluginApi| a.get_text_properties_at_cursor(BufferId(11)),
PluginCommand::GetTextPropertiesAtCursor { buffer_id }
if buffer_id == BufferId(11)
);
assert_dispatches!(
|a: &PluginApi| a.define_mode(
"m".into(),
vec![("j".into(), "move_down".into())],
true,
false,
),
PluginCommand::DefineMode {
name, bindings, read_only, allow_text_input, inherit_normal_bindings, plugin_name
}
if name == "m"
&& bindings.len() == 1
&& bindings[0].0 == "j"
&& bindings[0].1 == "move_down"
&& read_only
&& !allow_text_input
&& !inherit_normal_bindings
&& plugin_name.is_none()
);
assert_dispatches!(
|a: &PluginApi| a.show_buffer(BufferId(77)),
PluginCommand::ShowBuffer { buffer_id } if buffer_id == BufferId(77)
);
assert_dispatches!(
|a: &PluginApi| a.set_split_scroll(5, 128),
PluginCommand::SetSplitScroll { split_id, top_byte }
if split_id == SplitId(5) && top_byte == 128
);
assert_dispatches!(
|a: &PluginApi| a.get_highlights(BufferId(1), 0..10, 7),
PluginCommand::RequestHighlights { buffer_id, range, request_id }
if buffer_id == BufferId(1) && range == (0..10) && request_id == 7
);
}
#[test]
fn plugin_api_get_active_split_id_reads_snapshot() {
let (api, _rx, _h, _c, snap) = mk_api();
snap.write().unwrap().active_split_id = 42;
assert_eq!(api.get_active_split_id(), 42);
}
#[test]
fn plugin_api_state_snapshot_handle_shares_underlying_arc() {
let (api, _rx, _h, _c, snap) = mk_api();
snap.write().unwrap().active_buffer_id = BufferId(42);
let h = api.state_snapshot_handle();
assert_eq!(h.read().unwrap().active_buffer_id, BufferId(42));
assert!(Arc::ptr_eq(&h, &snap));
}
#[test]
fn plugin_command_kill_host_process_serde_round_trip() {
let cmd = PluginCommand::KillHostProcess { process_id: 1234 };
let json = serde_json::to_value(&cmd).unwrap();
assert_eq!(json["KillHostProcess"]["process_id"], 1234);
let decoded: PluginCommand = serde_json::from_value(json).unwrap();
match decoded {
PluginCommand::KillHostProcess { process_id } => assert_eq!(process_id, 1234),
other => panic!("expected KillHostProcess, got {:?}", other),
}
}
}