feat(plugins): extension points, capability consent, async runtime and plugin API 1.0

Rework the plugin system per docs/plugin_plan.md (P0–P8):

- Extension-point registry (ide-extension) so new contribution types register
  without touching the kernel; languages, run detectors, providers, views and
  UI contributions go through it.
- Per-plugin capability consent with a risk-graded ledger; process
  capabilities are pinned to resolved binary paths and hashes.
- Async, actor-per-plugin Wasm runtime with lazy activation, cancellation,
  events, long-running tasks and idle reclamation.
- Plugin host moved into ide-host, shared by local and remote projects.
- Distribution: install/package tooling (openidea-plugin bin) and a static
  market index.
- WIT frozen as openidea:[email protected]; Rust SDK with mock host.
- Ten official validation plugins under plugins/official.
Цей коміт міститься в:
l0ng-ai committed 2026-11-04 02:41:46 +08:00
1 джерело 63801ba249
коміт fd1f465aeb
326 files changed
+35587 -2777

No files matched your search

+1
Переглянути файл
@@ -0,0 +1 @@
/target
+333
Переглянути файл
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-openidea-string-tools"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+21
Переглянути файл
@@ -0,0 +1,21 @@
[package]
name = "openidea-plugin-openidea-string-tools"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+171
Переглянути файл
@@ -0,0 +1,171 @@
id = "openidea.string-tools"
name = "String Tools"
version = "0.1.0"
description = "Case conversion (camelCase, snake_case, kebab-case…), encoding (Base64, URL, HTML, JSON), line sorting and more, on every selection at once."
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: 26 commands in four groups."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
# The commands are declared below, so Find Action and the keymap list them
# before the code is ever loaded; it loads on the first one run.
activation = ["on-command:*"]
[capabilities]
required = ["editor.read", "editor.write"]
[[contributes.commands]]
id = "camel-case"
title = "String Tools: To camelCase"
category = "String Tools › Case"
when = "editorFocus"
[[contributes.commands]]
id = "pascal-case"
title = "String Tools: To PascalCase"
category = "String Tools › Case"
when = "editorFocus"
[[contributes.commands]]
id = "snake-case"
title = "String Tools: To snake_case"
category = "String Tools › Case"
when = "editorFocus"
[[contributes.commands]]
id = "screaming-snake-case"
title = "String Tools: To SCREAMING_SNAKE_CASE"
category = "String Tools › Case"
when = "editorFocus"
[[contributes.commands]]
id = "kebab-case"
title = "String Tools: To kebab-case"
category = "String Tools › Case"
when = "editorFocus"
[[contributes.commands]]
id = "dot-case"
title = "String Tools: To dot.case"
category = "String Tools › Case"
when = "editorFocus"
[[contributes.commands]]
id = "title-case"
title = "String Tools: To Title Case"
category = "String Tools › Case"
when = "editorFocus"
[[contributes.commands]]
id = "upper-case"
title = "String Tools: To UPPER CASE"
category = "String Tools › Case"
when = "editorFocus"
[[contributes.commands]]
id = "lower-case"
title = "String Tools: To lower case"
category = "String Tools › Case"
when = "editorFocus"
[[contributes.commands]]
id = "toggle-case"
title = "String Tools: Toggle Case"
category = "String Tools › Case"
when = "editorFocus"
[[contributes.commands]]
id = "base64-encode"
title = "String Tools: Encode Base64"
category = "String Tools › Encode"
when = "editorFocus"
[[contributes.commands]]
id = "base64-decode"
title = "String Tools: Decode Base64"
category = "String Tools › Encode"
when = "editorFocus"
[[contributes.commands]]
id = "url-encode"
title = "String Tools: URL-Encode"
category = "String Tools › Encode"
when = "editorFocus"
[[contributes.commands]]
id = "url-decode"
title = "String Tools: URL-Decode"
category = "String Tools › Encode"
when = "editorFocus"
[[contributes.commands]]
id = "html-escape"
title = "String Tools: Escape HTML"
category = "String Tools › Encode"
when = "editorFocus"
[[contributes.commands]]
id = "html-unescape"
title = "String Tools: Unescape HTML"
category = "String Tools › Encode"
when = "editorFocus"
[[contributes.commands]]
id = "json-escape"
title = "String Tools: Escape as JSON String"
category = "String Tools › Encode"
when = "editorFocus"
[[contributes.commands]]
id = "sort-lines"
title = "String Tools: Sort Lines"
category = "String Tools › Lines"
when = "editorFocus"
[[contributes.commands]]
id = "sort-lines-desc"
title = "String Tools: Sort Lines Descending"
category = "String Tools › Lines"
when = "editorFocus"
[[contributes.commands]]
id = "reverse-lines"
title = "String Tools: Reverse Lines"
category = "String Tools › Lines"
when = "editorFocus"
[[contributes.commands]]
id = "unique-lines"
title = "String Tools: Remove Duplicate Lines"
category = "String Tools › Lines"
when = "editorFocus"
[[contributes.commands]]
id = "trim-lines"
title = "String Tools: Trim Lines"
category = "String Tools › Lines"
when = "editorFocus"
[[contributes.commands]]
id = "reverse"
title = "String Tools: Reverse Text"
category = "String Tools › Other"
when = "editorFocus"
[[contributes.commands]]
id = "swap-quotes"
title = "String Tools: Swap Quotes (' ↔ \")"
category = "String Tools › Other"
when = "editorFocus"
[[contributes.commands]]
id = "increment"
title = "String Tools: Increment Numbers"
category = "String Tools › Other"
when = "editorFocus"
[[contributes.commands]]
id = "decrement"
title = "String Tools: Decrement Numbers"
category = "String Tools › Other"
when = "editorFocus"
+460
Переглянути файл
@@ -0,0 +1,460 @@
//! String Tools for OpenIDEA: text transformations on every selection of the
//! focused editor, applied as one undoable change. An empty selection
//! (a cursor) works on the word under it; line commands on whole lines.
use openidea_plugin::text::{offset, position};
use openidea_plugin::{Error, Invocation, Plugin, Range, TextEdit, TextEditor, register_plugin};
#[derive(Default)]
pub struct StringTools;
impl Plugin for StringTools {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
let transform = transform(id).ok_or_else(|| format!("unknown command {id}"))?;
let editor = ctx.editor().ok_or("String Tools works on the focused editor")?;
apply(&editor, transform, lines_command(id)).map_err(|e| e.to_string())?;
Ok(None)
}
}
register_plugin!(StringTools);
type Transform = fn(&str) -> String;
fn lines_command(id: &str) -> bool {
matches!(id, "sort-lines" | "sort-lines-desc" | "reverse-lines" | "unique-lines" | "trim-lines")
}
fn transform(id: &str) -> Option<Transform> {
Some(match id {
"camel-case" => |s| join_words(s, Style::Camel),
"pascal-case" => |s| join_words(s, Style::Pascal),
"snake-case" => |s| join_words(s, Style::Snake),
"screaming-snake-case" => |s| join_words(s, Style::ScreamingSnake),
"kebab-case" => |s| join_words(s, Style::Kebab),
"dot-case" => |s| join_words(s, Style::Dot),
"title-case" => title_case,
"upper-case" => |s| s.to_uppercase(),
"lower-case" => |s| s.to_lowercase(),
"toggle-case" => toggle_case,
"base64-encode" => |s| base64_encode(s.as_bytes()),
"base64-decode" => {
|s| base64_decode(s).map_or_else(|| s.to_owned(), |b| String::from_utf8_lossy(&b).into_owned())
}
"url-encode" => url_encode,
"url-decode" => url_decode,
"html-escape" => html_escape,
"html-unescape" => html_unescape,
"json-escape" => json_escape,
"sort-lines" => |s| map_lines(s, |l| l.sort()),
"sort-lines-desc" => |s| map_lines(s, |l| l.sort_by(|a, b| b.cmp(a))),
"reverse-lines" => |s| map_lines(s, |l| l.reverse()),
"unique-lines" => |s| {
map_lines(s, |l| {
let mut seen = std::collections::HashSet::new();
l.retain(|x| seen.insert(x.to_owned()));
})
},
"trim-lines" => |s| s.split('\n').map(str::trim).collect::<Vec<_>>().join("\n"),
"reverse" => |s| s.chars().rev().collect(),
"swap-quotes" => |s| {
s.chars()
.map(|c| match c {
'"' => '\'',
'\'' => '"',
c => c,
})
.collect()
},
"increment" => |s| step_numbers(s, 1),
"decrement" => |s| step_numbers(s, -1),
_ => return None,
})
}
/// Replaces each selection with `f(text)`: an empty one expands to the word
/// under the cursor, or (`lines`) every selection to its whole lines.
/// Retried once if the user typed meanwhile.
fn apply(editor: &TextEditor, f: Transform, lines: bool) -> Result<u64, Error> {
let selections = editor.selections()?;
for _ in 0..2 {
let doc = editor.document()?;
let mut edits = Vec::with_capacity(selections.len());
for r in &selections {
let range = if lines { line_range(&doc.text, *r) } else { word_range(&doc.text, *r) };
let Some(old) = doc.text_in(range) else { continue };
let new = f(old);
if new != old {
edits.push(TextEdit { range, text: new });
}
}
// Overlapping selections (two cursors in one word) edit once.
edits.sort_by_key(|e| e.range);
edits.dedup_by(|b, a| b.range.start < a.range.end);
match editor.apply_edits(doc.version, &edits) {
Err(Error::Stale) => continue,
other => return other,
}
}
Err(Error::Stale)
}
fn word_range(text: &str, r: Range) -> Range {
if !r.is_empty() {
return r;
}
let Some(at) = offset(text, r.start) else { return r };
let word = |c: char| c.is_alphanumeric() || c == '_' || c == '-' || c == '.';
let start = text[..at].char_indices().rev().take_while(|(_, c)| word(*c)).last().map_or(at, |(i, _)| i);
let end = text[at..].char_indices().find(|(_, c)| !word(*c)).map_or(text.len(), |(i, _)| at + i);
Range::new(position(text, start), position(text, end))
}
fn line_range(text: &str, r: Range) -> Range {
let start = openidea_plugin::Position::new(r.start.line, 0);
let end_line = if r.end.character == 0 && r.end.line > r.start.line { r.end.line - 1 } else { r.end.line };
let line_end = text.split('\n').nth(end_line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
Range::new(start, openidea_plugin::Position::new(end_line, line_end))
}
#[derive(Clone, Copy)]
enum Style {
Camel,
Pascal,
Snake,
ScreamingSnake,
Kebab,
Dot,
}
/// Splits identifiers on separators and case changes: `fooBar_baz-qux` →
/// [foo, Bar, baz, qux]; `HTTPServer` → [HTTP, Server].
fn words(s: &str) -> Vec<String> {
let chars: Vec<char> = s.chars().collect();
let mut out = Vec::new();
let mut cur = String::new();
for (i, &c) in chars.iter().enumerate() {
if !c.is_alphanumeric() {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
continue;
}
let prev = i.checked_sub(1).map(|j| chars[j]);
let next = chars.get(i + 1).copied();
let boundary = c.is_uppercase()
&& prev.is_some_and(|p| {
p.is_lowercase() || p.is_ascii_digit() || (p.is_uppercase() && next.is_some_and(char::is_lowercase))
});
if boundary && !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
cur.push(c);
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn capitalize(w: &str) -> String {
let mut c = w.chars();
c.next().map(|f| f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect()).unwrap_or_default()
}
fn join_words(s: &str, style: Style) -> String {
// Keep surrounding whitespace (a whole-line selection).
let lead = &s[..s.len() - s.trim_start().len()];
let trail = &s[s.trim_end().len()..];
let w = words(s);
let body = match style {
Style::Camel => {
w.iter().enumerate().map(|(i, x)| if i == 0 { x.to_lowercase() } else { capitalize(x) }).collect()
}
Style::Pascal => w.iter().map(|x| capitalize(x)).collect(),
Style::Snake => w.iter().map(|x| x.to_lowercase()).collect::<Vec<_>>().join("_"),
Style::ScreamingSnake => w.iter().map(|x| x.to_uppercase()).collect::<Vec<_>>().join("_"),
Style::Kebab => w.iter().map(|x| x.to_lowercase()).collect::<Vec<_>>().join("-"),
Style::Dot => w.iter().map(|x| x.to_lowercase()).collect::<Vec<_>>().join("."),
};
if body.is_empty() { s.to_owned() } else { format!("{lead}{body}{trail}") }
}
fn title_case(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut start = true;
for c in s.chars() {
if start && c.is_alphabetic() {
out.extend(c.to_uppercase());
start = false;
} else {
out.extend(c.to_lowercase());
start = c.is_whitespace();
}
}
out
}
fn toggle_case(s: &str) -> String {
s.chars()
.flat_map(|c| -> Box<dyn Iterator<Item = char>> {
if c.is_uppercase() { Box::new(c.to_lowercase()) } else { Box::new(c.to_uppercase()) }
})
.collect()
}
fn map_lines(s: &str, f: impl FnOnce(&mut Vec<&str>)) -> String {
let mut lines: Vec<&str> = s.split('\n').collect();
f(&mut lines);
lines.join("\n")
}
/// Adds `by` to every integer in `s` (keeping zero padding).
fn step_numbers(s: &str, by: i64) -> String {
let mut out = String::with_capacity(s.len());
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i].is_ascii_digit() {
let start = i;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
let neg = start > 0 && chars[start - 1] == '-' && (start < 2 || !chars[start - 2].is_alphanumeric());
let digits: String = chars[start..i].iter().collect();
match digits.parse::<i64>() {
Ok(n) => {
let n = if neg { -n } else { n };
let next = n.saturating_add(by);
if neg {
out.pop(); // the minus sign
}
let width = digits.len();
let text = if next < 0 { format!("-{:0width$}", -next) } else { format!("{next:0width$}") };
out.push_str(&text);
}
Err(_) => out.push_str(&digits),
}
} else {
out.push(chars[i]);
i += 1;
}
}
out
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn base64_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i));
for i in 0..4 {
if i <= chunk.len() {
out.push(B64[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
fn base64_decode(s: &str) -> Option<Vec<u8>> {
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
let clean = clean.strip_suffix(b"==").or_else(|| clean.strip_suffix(b"=")).unwrap_or(&clean);
let mut out = Vec::with_capacity(clean.len() * 3 / 4);
for chunk in clean.chunks(4) {
if chunk.len() == 1 {
return None;
}
let mut n = 0u32;
for (i, b) in chunk.iter().enumerate() {
let v = B64.iter().position(|x| x == b).or_else(|| match b {
b'-' => Some(62),
b'_' => Some(63),
_ => None,
})?;
n |= (v as u32) << (18 - 6 * i);
}
for i in 0..chunk.len() - 1 {
out.push((n >> (16 - 8 * i)) as u8);
}
}
Some(out)
}
fn url_encode(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
_ => format!("%{b:02X}"),
})
.collect()
}
fn url_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let hex = |j: usize| bytes.get(j).and_then(|b| (*b as char).to_digit(16));
match bytes[i] {
b'%' if hex(i + 1).is_some() && hex(i + 2).is_some() => {
out.push((hex(i + 1).unwrap() * 16 + hex(i + 2).unwrap()) as u8);
i += 3;
}
b'+' => {
out.push(b' ');
i += 1;
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
c => out.push(c),
}
}
out
}
fn html_unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
rest = &rest[i..];
let Some(end) = rest[..rest.len().min(12)].find(';') else {
out.push('&');
rest = &rest[1..];
continue;
};
let entity = &rest[1..end];
let decoded = match entity {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some('\u{a0}'),
e if e.starts_with("#x") || e.starts_with("#X") => {
u32::from_str_radix(&e[2..], 16).ok().and_then(char::from_u32)
}
e if e.starts_with('#') => e[1..].parse().ok().and_then(char::from_u32),
_ => None,
};
match decoded {
Some(c) => {
out.push(c);
rest = &rest[end + 1..];
}
None => {
out.push('&');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::mock::MockHost;
#[test]
fn cases() {
let t = |id: &str, s: &str| transform(id).unwrap()(s);
assert_eq!(t("camel-case", "hello_world-again"), "helloWorldAgain");
assert_eq!(t("pascal-case", "HTTPServer error"), "HttpServerError");
assert_eq!(t("snake-case", "parseHTTPResponse2Fast"), "parse_http_response2_fast");
assert_eq!(t("screaming-snake-case", "maxValue"), "MAX_VALUE");
assert_eq!(t("kebab-case", " FooBar "), " foo-bar ");
assert_eq!(t("dot-case", "a_b"), "a.b");
assert_eq!(t("title-case", "the QUICK fox"), "The Quick Fox");
assert_eq!(t("toggle-case", "aBc"), "AbC");
}
#[test]
fn encodings_round_trip() {
for s in ["", "f", "fo", "foo", "héllo wörld ✓"] {
assert_eq!(base64_decode(&base64_encode(s.as_bytes())).unwrap(), s.as_bytes());
assert_eq!(url_decode(&url_encode(s)), s);
assert_eq!(html_unescape(&html_escape(s)), s);
}
assert_eq!(base64_encode(b"Man"), "TWFu");
assert_eq!(url_encode("a b&c"), "a%20b%26c");
assert_eq!(html_unescape("&lt;a&gt; &#x41;&#66; &bogus; &"), "<a> AB &bogus; &");
assert_eq!(json_escape("a\"b\n"), "a\\\"b\\n");
}
#[test]
fn lines_and_numbers() {
let t = |id: &str, s: &str| transform(id).unwrap()(s);
assert_eq!(t("sort-lines", "b\na\nc"), "a\nb\nc");
assert_eq!(t("unique-lines", "a\nb\na"), "a\nb");
assert_eq!(t("increment", "v1.09 and -1"), "v2.10 and 0");
assert_eq!(t("decrement", "x0"), "x-1");
}
#[test]
fn every_selection_and_cursor_in_one_undoable_change() {
let host = MockHost::install();
host.open_editor("/p/a.rs", "rust", "let fooBar = baz_qux;\n", &[(0, 6, 0, 6), (0, 13, 0, 20)]);
StringTools.run_command("snake-case", &host.invocation(), None).unwrap();
assert_eq!(host.editor_text(), "let foo_bar = baz_qux;\n");
assert_eq!(host.editor_version(), 2, "one change for both");
// The editor sends the (moved) selections with the next command.
host.open_editor("/p/a.rs", "rust", &host.editor_text(), &[(0, 4, 0, 11), (0, 14, 0, 21)]);
StringTools.run_command("upper-case", &host.invocation(), None).unwrap();
assert_eq!(host.editor_text(), "let FOO_BAR = BAZ_QUX;\n");
}
#[test]
fn line_commands_take_whole_lines() {
let host = MockHost::install();
host.open_editor("/p/a.txt", "text", "c\nb\na\nz", &[(0, 0, 3, 0)]);
StringTools.run_command("sort-lines", &host.invocation(), None).unwrap();
assert_eq!(host.editor_text(), "a\nb\nc\nz");
}
#[test]
fn degrades_without_write_access() {
let host = MockHost::install().with_capabilities(&["editor.read"]);
host.open_editor("/p/a.txt", "text", "abc", &[(0, 0, 0, 3)]);
let err = StringTools.run_command("upper-case", &host.invocation(), None).unwrap_err();
assert!(err.contains("editor.write"), "{err}");
assert_eq!(host.editor_text(), "abc");
}
}