дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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.
Цей коміт міститься в:
1 джерело
63801ba249
коміт
fd1f465aeb
326 files changed
+35587
-2777
No files matched your search
@@ -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"
|
||||
@@ -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"
|
||||
Бінарний файл не відображається.
@@ -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"
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
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("<a> AB &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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача