Файли
openidea/crates/ide-host/src/plugins/ui_points.rs
T
Arnaud Ritti 0bee9b7aab feat(plugins): allow custom gutter marker icons (#2044)
Closes #1951

Gutter markers only recognised a short alias list and the runtime truncated icon references at 32 bytes, so ordinary icon names and package paths were dropped. Markers now accept any built-in icon name, and API 1.3 `contributes.markerIcons` declares package SVGs (with an optional dark variant) that are size-limited, sanitised and rendered through the icon registry. The shared SVG sanitiser now also rejects URL strings in any CSS function (e.g. image-set), not only url(). Verified on macOS in light and dark themes.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 03:12:01 +08:00

826 рядки
31 KiB
Rust
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
//! Declarative UI contributions: menus, key
//! bindings, status bar items and settings (L0, shown before any code runs),
//! and the views and custom editors whose content plugin code renders (L2).
//! Everything is validated here; the client only projects the JSON.
//!
//! ```toml
//! [[contributes.menus]]
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context, commit.message
//! command = "check"
//! when = "editorLang == java"
//! # view = "<view id>" # required for (only allowed on) viewItem.context
//!
//! [[contributes.keymaps]]
//! command = "check"
//! key = "Ctrl+Alt+C" # `mac` for a different binding on macOS (`Meta+Alt+C`)
//!
//! [[contributes.statusBar]]
//! id = "branch"
//! text = "…" # code replaces it with `set-status-item("branch", …)`
//! command = "show"
//!
//! [contributes.settings]
//! schema = "settings.schema.json" # {"properties": {"key": {"type": "boolean", "default": true, …}}}
//!
//! [[contributes.views]]
//! id = "jobs"
//! title = "Jobs"
//! icon = "server"
//! location = "right" # left, right, bottom, services (nodes of the Services tool window)
//!
//! [[contributes.customEditors]]
//! id = "table"
//! title = "Table"
//! patterns = ["*.csv", "*.tsv"]
//! priority = "default" # or "option": only through Open With
//! layout = "tab" # or "split" / "preview": beside the text editor
//! web = "web/index.html" # a web page from the package instead of rendered content
//!
//! [[contributes.fileIcons]]
//! id = "drawing"
//! patterns = ["*.excalidraw"]
//! icon = "icons/drawing.svg"
//! iconDark = "icons/drawing_dark.svg"
//!
//! [[contributes.commitChecks]]
//! id = "conventional"
//! title = "Conventional Commits" # Commit Options › Before Commit; `commit-checker.check` runs it
//!
//! [[contributes.fileTemplates]]
//! id = "drawing"
//! name = "Excalidraw Drawing"
//! extension = "excalidraw"
//! path = "templates/drawing.excalidraw"
//! ```
use ide_extension::{LOCALES_DIR, Package, UiDecl, decode, validate_item_id, validate_when};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
/// A view whose tree shows in the Services tool window; the plugin's
/// `detail-provider` renders the selected node's pane.
pub const SERVICES_LOCATION: &str = "services";
pub const MENUS: &str = "menus";
pub const KEYMAPS: &str = "keymaps";
pub const STATUS_BAR: &str = "statusBar";
pub const SETTINGS: &str = "settings";
pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
pub const MARKER_ICONS: &str = "markerIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
pub const COMMIT_CHECKS: &str = "commitChecks";
const VIEW_ITEM_MENU: &str = "viewItem.context";
/// The toolbar above the commit message (Commit tool window and dialog).
const COMMIT_MESSAGE_MENU: &str = "commit.message";
const MENU_IDS: [&str; 6] =
["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU];
const MAX_TITLE: usize = 256;
const MAX_SETTINGS: usize = 200;
const MAX_PATTERNS: usize = 16;
/// Command ids the package declares.
fn commands(pkg: &Package) -> BTreeSet<String> {
pkg.manifest
.declarations(super::COMMANDS)
.iter()
.filter_map(|d| d.get("id").and_then(|v| v.as_str()).map(str::to_owned))
.collect()
}
fn declared_view(pkg: &Package, id: &str) -> Option<toml::Value> {
pkg.manifest.declarations(VIEWS).into_iter().find(|d| d.get("id").and_then(|v| v.as_str()) == Some(id))
}
fn declared_command(pkg: &Package, point: &str, command: &str) -> Result<(), String> {
if commands(pkg).contains(command) {
Ok(())
} else {
Err(format!("contributes.{point}: command {command:?} is not declared under [[contributes.commands]]"))
}
}
fn title(point: &str, t: &str) -> Result<(), String> {
if t.trim().is_empty() || t.len() > MAX_TITLE {
return Err(format!("contributes.{point}: titles are 1–{MAX_TITLE} bytes"));
}
Ok(())
}
fn json<T: Serialize>(v: &T) -> Result<Value, String> {
serde_json::to_value(v).map_err(|e| e.to_string())
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct MenuDecl {
menu: String,
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
view: Option<String>,
/// Groups the plugin's entries of one context menu that name the same
/// submenu under it.
#[serde(default, skip_serializing_if = "Option::is_none")]
submenu: Option<String>,
}
pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: MenuDecl = decode(MENUS, decl)?;
if !MENU_IDS.contains(&d.menu.as_str()) {
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
}
declared_command(pkg, MENUS, &d.command)?;
if d.menu == COMMIT_MESSAGE_MENU {
pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?;
}
if let Some(w) = &d.when {
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
}
if let Some(submenu) = &d.submenu {
if !d.menu.ends_with(".context") {
return Err(format!("contributes.menus: `submenu` is only for context menus, not {:?}", d.menu));
}
title(MENUS, submenu)?;
pkg.manifest.needs_api("contributes.menus: submenu", 3)?;
}
let key = match (d.menu == VIEW_ITEM_MENU, &d.view) {
(true, None) => return Err(format!("contributes.menus: {VIEW_ITEM_MENU} needs `view`")),
(true, Some(v)) => {
pkg.manifest.needs_api(&format!("contributes.menus: {VIEW_ITEM_MENU}"), 2)?;
let Some(view) = declared_view(pkg, v) else {
return Err(format!("contributes.menus: view {v:?} is not declared under [[contributes.views]]"));
};
if view.get("web").is_some() {
return Err(format!(
"contributes.menus: view {v:?} is a web view (viewItem.context needs a tree or list the plugin renders)"
));
}
format!("{VIEW_ITEM_MENU}/{v}/{}", d.command)
}
(false, Some(_)) => return Err(format!("contributes.menus: `view` is only for {VIEW_ITEM_MENU}")),
(false, None) => format!("{}/{}", d.menu, d.command),
};
Ok(UiDecl::new(key, d.command.clone(), json(&d)?))
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct KeymapDecl {
command: String,
key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
mac: Option<String>,
}
const MODIFIERS: [&str; 4] = ["Meta", "Ctrl", "Alt", "Shift"];
const NAMED_KEYS: [&str; 22] = [
"Enter",
"Escape",
"Tab",
"Space",
"Backspace",
"Delete",
"Insert",
"Home",
"End",
"PageUp",
"PageDown",
"Up",
"Down",
"Left",
"Right",
"Minus",
"Equal",
"Comma",
"Period",
"Slash",
"Semicolon",
"Quote",
];
/// `Ctrl+Alt+C`, `Meta+K Meta+C` (a chord): at least one modifier per
/// stroke but for function keys, so plugins can't take plain typing.
pub fn validate_binding(binding: &str) -> Result<(), String> {
let strokes: Vec<&str> = binding.split(' ').collect();
if strokes.is_empty() || strokes.len() > 2 {
return Err(format!("{binding:?}: one stroke, or two for a chord"));
}
for stroke in strokes {
let parts: Vec<&str> = stroke.split('+').collect();
let (key, mods) = parts.split_last().ok_or_else(|| format!("{binding:?}: empty stroke"))?;
let mut seen = BTreeSet::new();
for m in mods {
if !MODIFIERS.contains(m) || !seen.insert(*m) {
return Err(format!("{binding:?}: unknown or repeated modifier {m:?}"));
}
}
let function = key.strip_prefix('F').and_then(|n| n.parse::<u8>().ok()).is_some_and(|n| (1..=24).contains(&n));
let plain = key.len() == 1 && key.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit());
if !(function || plain || NAMED_KEYS.contains(key)) {
return Err(format!("{binding:?}: unknown key {key:?}"));
}
if !function && seen.iter().all(|m| *m == "Shift") {
return Err(format!("{binding:?}: needs Ctrl, Alt or Meta (plain keys are for typing)"));
}
}
Ok(())
}
pub(super) fn keymap(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: KeymapDecl = decode(KEYMAPS, decl)?;
declared_command(pkg, KEYMAPS, &d.command)?;
validate_binding(&d.key).map_err(|e| format!("contributes.keymaps: {e}"))?;
if let Some(m) = &d.mac {
validate_binding(m).map_err(|e| format!("contributes.keymaps: {e}"))?;
}
Ok(UiDecl::new(d.command.clone(), d.key.clone(), json(&d)?))
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct StatusDecl {
id: String,
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
tooltip: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(default = "right")]
align: String,
#[serde(default)]
priority: u32,
}
fn right() -> String {
"right".into()
}
pub(super) fn status_item(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: StatusDecl = decode(STATUS_BAR, decl)?;
validate_item_id(&d.id, "status bar item id")?;
if d.text.is_empty() || d.text.len() > 128 || d.tooltip.as_ref().is_some_and(|t| t.len() > 1024) {
return Err(format!("status bar item {:?}: `text` 1–128 bytes, `tooltip` up to 1 KiB", d.id));
}
if d.align != "left" && d.align != "right" {
return Err(format!("status bar item {:?}: `align` is left or right", d.id));
}
if d.priority > 1000 {
return Err(format!("status bar item {:?}: `priority` is 0–1000", d.id));
}
if let Some(c) = &d.command {
declared_command(pkg, STATUS_BAR, c)?;
}
Ok(UiDecl::new(d.id.clone(), d.text.clone(), json(&d)?))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SettingsDecl {
#[serde(default)]
title: Option<String>,
schema: PathBuf,
}
#[derive(Debug, Deserialize)]
struct SchemaFile {
properties: serde_json::Map<String, Value>,
}
/// One setting of a plugin.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SettingSchema {
pub key: String,
/// `boolean`, `string`, `number` or `integer`.
#[serde(rename = "type")]
pub kind: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub default: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<String>>,
#[serde(default)]
pub multiline: bool,
}
impl SettingSchema {
/// Whether `v` is a valid value of this setting.
pub fn accepts(&self, v: &Value) -> bool {
match self.kind.as_str() {
"boolean" => v.is_boolean(),
"integer" => v.is_i64() || v.is_u64(),
"number" => v.is_number(),
_ => v
.as_str()
.is_some_and(|s| s.len() <= 64 << 10 && self.options.as_ref().is_none_or(|o| o.iter().any(|x| x == s))),
}
}
}
fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
if key.is_empty() || key.len() > 64 || !key.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) {
return Err(format!("setting {key:?}: keys are 1–64 of [A-Za-z0-9._-]"));
}
let o = raw.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
let kind = o.get("type").and_then(Value::as_str).unwrap_or("");
let default = match kind {
"boolean" => o.get("default").cloned().unwrap_or(Value::Bool(false)),
"string" => o.get("default").cloned().unwrap_or(Value::String(String::new())),
"number" | "integer" => o.get("default").cloned().unwrap_or(Value::from(0)),
_ => return Err(format!("setting {key:?}: `type` is boolean, string, number or integer")),
};
let options = match o.get("enum") {
None => None,
Some(Value::Array(a)) if kind == "string" && !a.is_empty() && a.len() <= 100 => Some(
a.iter()
.map(|v| v.as_str().map(str::to_owned).ok_or_else(|| format!("setting {key:?}: `enum` lists strings")))
.collect::<Result<Vec<_>, _>>()?,
),
Some(_) => return Err(format!("setting {key:?}: `enum` is 1–100 strings of a string setting")),
};
let text = |name: &str, max: usize| -> Result<Option<String>, String> {
match o.get(name) {
None => Ok(None),
Some(Value::String(s)) if s.len() <= max => Ok(Some(s.clone())),
Some(_) => Err(format!("setting {key:?}: `{name}` is a string up to {max} bytes")),
}
};
let s = SettingSchema {
key: key.to_owned(),
kind: kind.to_owned(),
title: text("title", MAX_TITLE)?.unwrap_or_else(|| key.to_owned()),
description: text("description", 4096)?,
default,
options,
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
};
if !s.accepts(&s.default) {
return Err(format!("setting {key:?}: the default does not match its type"));
}
Ok(s)
}
/// The validated settings of a `[contributes.settings]` schema file.
pub fn parse_settings_schema(text: &str) -> Result<Vec<SettingSchema>, String> {
let file: SchemaFile = serde_json::from_str(text).map_err(|e| e.to_string())?;
if file.properties.len() > MAX_SETTINGS {
return Err(format!("more than {MAX_SETTINGS} settings"));
}
file.properties.iter().map(|(k, v)| setting(k, v)).collect()
}
pub(super) fn settings(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: SettingsDecl = decode(SETTINGS, decl)?;
let text = pkg.read_text(&d.schema)?;
let properties = parse_settings_schema(&text).map_err(|e| format!("{}: {e}", d.schema.display()))?;
let title = d.title.unwrap_or_else(|| pkg.manifest.name.clone());
title_ok(&title)?;
let mut decl =
UiDecl::new("settings", title.clone(), serde_json::json!({ "title": title, "properties": properties }));
decl.files.push(d.schema);
Ok(decl)
}
fn title_ok(t: &str) -> Result<(), String> {
title(SETTINGS, t)
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct ViewDecl {
id: String,
title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
icon: Option<String>,
#[serde(default = "right")]
location: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<String>,
/// A web page of the package (`web/index.html`) instead of content the code renders.
#[serde(default, skip_serializing_if = "Option::is_none")]
web: Option<String>,
}
/// The files of a `web` entry, or a check that the code renders the view.
fn web_or_code(pkg: &Package, web: Option<&str>, what: &str) -> Result<Vec<PathBuf>, String> {
match web {
Some(entry) => super::web::web_files(pkg, entry, what),
None if pkg.manifest.wasm.is_none() => {
Err(format!("{what}: give a web page (`web`) or the code that renders it (`wasm`)"))
}
None => Ok(Vec::new()),
}
}
pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: ViewDecl = decode(VIEWS, decl)?;
validate_item_id(&d.id, "view id")?;
title(VIEWS, &d.title)?;
if let Some(group) = &d.group {
pkg.manifest.needs_api("contributes.views.group", 3)?;
validate_item_id(group, "view group")?;
}
if let Some(when) = &d.when {
pkg.manifest.needs_api("contributes.views.when", 3)?;
validate_when(when).map_err(|e| format!("contributes.views: {e}"))?;
}
let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?;
if !["left", "right", "bottom", SERVICES_LOCATION].contains(&d.location.as_str()) {
return Err(format!("view {:?}: `location` is left, right, bottom or services", d.id));
}
if d.location == SERVICES_LOCATION {
pkg.manifest.needs_api(&format!("view {:?}: `location = \"services\"`", d.id), 3)?;
if d.web.is_some() {
return Err(format!("view {:?}: a services view is rendered by the plugin's code (no `web`)", d.id));
}
}
if d.icon.as_ref().is_some_and(|i| i.len() > 64) {
return Err(format!("view {:?}: icon names are up to 64 bytes", d.id));
}
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
out.files = files;
if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) {
let path = std::path::PathBuf::from(icon);
let svg = super::icons::read_icon(pkg, &path, super::icons::MAX_SVG)?;
out.assets.push((view_icon_asset(&d.id), svg));
out.files.push(path);
}
Ok(out)
}
pub fn view_icon_asset(id: &str) -> String {
format!("viewIcon/{id}")
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CustomEditorDecl {
id: String,
title: String,
patterns: Vec<String>,
#[serde(default = "default_priority")]
priority: String,
/// `tab`: the file opens in this editor's own tab. `split` / `preview`:
/// the file opens in the text editor with this editor beside it (Editor /
/// Editor and Preview / Preview); the value is the layout files start in.
#[serde(default = "default_layout")]
layout: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
web: Option<String>,
}
fn default_priority() -> String {
"default".into()
}
fn default_layout() -> String {
"tab".into()
}
/// File name globs (`*.csv`, `{a,b}.json`): no directories.
fn file_patterns(patterns: &[String], what: &str) -> Result<(), String> {
if patterns.is_empty() || patterns.len() > MAX_PATTERNS {
return Err(format!("{what}: 1–{MAX_PATTERNS} file name `patterns`"));
}
for p in patterns {
if p.is_empty() || p.len() > 128 || p.contains(['/', '\\']) || globset::Glob::new(p).is_err() {
return Err(format!("{what}: invalid file name pattern {p:?}"));
}
}
Ok(())
}
pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: CustomEditorDecl = decode(CUSTOM_EDITORS, decl)?;
validate_item_id(&d.id, "custom editor id")?;
title(CUSTOM_EDITORS, &d.title)?;
let what = format!("custom editor {:?}", d.id);
let files = web_or_code(pkg, d.web.as_deref(), &what)?;
file_patterns(&d.patterns, &what)?;
if d.priority != "default" && d.priority != "option" {
return Err(format!("{what}: `priority` is default or option"));
}
if !["tab", "split", "preview"].contains(&d.layout.as_str()) {
return Err(format!("{what}: `layout` is tab, split or preview"));
}
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
out.files = files;
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileIconDecl {
id: String,
patterns: Vec<String>,
icon: PathBuf,
#[serde(default)]
icon_dark: Option<PathBuf>,
}
/// Asset id of a file icon's SVG (`dark`: its dark-theme variant).
pub fn file_icon_asset(id: &str, dark: bool) -> String {
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
}
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
/// whatever icon theme is in use (the user's own file type icons still win).
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
validate_item_id(&d.id, "file icon id")?;
let what = format!("file icon {:?}", d.id);
file_patterns(&d.patterns, &what)?;
let mut files = Vec::new();
let mut assets = Vec::new();
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
let Some(path) = path else { continue };
let svg = super::icons::read_icon(pkg, path, super::icons::MAX_SVG)?;
assets.push((file_icon_asset(&d.id, dark), svg));
files.push(path.clone());
}
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
out.files = files;
out.assets = assets;
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MarkerIconDecl {
id: String,
icon: PathBuf,
#[serde(default)]
icon_dark: Option<PathBuf>,
}
pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
pkg.manifest.needs_api("contributes.markerIcons", 3)?;
let d: MarkerIconDecl = decode(MARKER_ICONS, decl)?;
validate_item_id(&d.id, "marker icon id")?;
if d.icon.to_string_lossy().len() > 256 {
return Err("marker icon paths are up to 256 bytes".into());
}
let mut out = UiDecl::new(d.id.clone(), d.id.clone(), serde_json::json!({ "id": d.id, "icon": d.icon }));
for (path, variant) in [(Some(&d.icon), "light"), (d.icon_dark.as_ref(), "dark")] {
let Some(path) = path else { continue };
let svg = super::icons::read_icon(pkg, path, super::icons::MAX_SVG)?;
out.assets.push((format!("markerIcon/{}/{variant}", d.id), svg));
out.files.push(path.clone());
}
// A changed SVG must reproject even when its declared path stays the same.
let bytes = serde_json::to_vec(&out.assets).map_err(|e| e.to_string())?;
out.json["revision"] = Value::String(blake3::hash(&bytes).to_hex().to_string());
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileTemplateDecl {
id: String,
name: String,
/// Without the dot.
#[serde(default)]
extension: String,
file_name: Option<String>,
variables: Option<Vec<FileTemplateVariable>>,
/// The new file's content (`$NAME$`, `$END$` and the other template variables work).
path: PathBuf,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct FileTemplateVariable {
name: String,
prompt: String,
}
const MAX_TEMPLATE: usize = 256 << 10;
/// `[[contributes.fileTemplates]]`: an entry of the New menu.
pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileTemplateDecl = decode(FILE_TEMPLATES, decl)?;
validate_item_id(&d.id, "file template id")?;
title(FILE_TEMPLATES, &d.name)?;
let what = format!("file template {:?}", d.id);
if d.file_name.is_some() || d.variables.is_some() {
pkg.manifest.needs_api("contributes.fileTemplates: fileName / variables", 3)?;
}
if let Some(name) = &d.file_name {
let stem = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
if name.is_empty()
|| name.len() > 255
|| name.trim() != name
|| name.ends_with('.')
|| matches!(name.as_str(), "." | "..")
|| name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c))
|| matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
&& matches!(stem.as_bytes().get(3), Some(b'1'..=b'9'))
&& stem.len() == 4)
{
return Err(format!("{what}: `fileName` must be a safe single file name"));
}
}
let mut names = std::collections::HashSet::new();
let variables = d.variables.unwrap_or_default();
if variables.len() > 32 {
return Err(format!("{what}: at most 32 variables are allowed"));
}
for variable in &variables {
let name = &variable.name;
if name.is_empty()
|| name.len() > 64
|| !name
.bytes()
.enumerate()
.all(|(i, c)| c.is_ascii_alphabetic() || c == b'_' || i > 0 && c.is_ascii_digit())
|| matches!(
name.as_str(),
"NAME"
| "FILE_NAME"
| "PACKAGE"
| "PACKAGE_NAME"
| "PROJECT_NAME"
| "USER"
| "DATE"
| "TIME"
| "YEAR"
| "END"
)
|| !names.insert(name)
{
return Err(format!("{what}: invalid, reserved or duplicate variable name {name:?}"));
}
title("file template variable prompt", &variable.prompt)?;
}
let ext = d.extension.trim_start_matches('.');
if (ext.is_empty() && d.file_name.is_none())
|| ext.len() > 32
|| !ext.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
{
return Err(format!("{what}: `extension` is a file extension like \"md\" ({:?})", d.extension));
}
let body = pkg.read_text(&d.path)?;
if body.len() > MAX_TEMPLATE {
return Err(format!("{what}: {} is larger than {} KiB", d.path.display(), MAX_TEMPLATE >> 10));
}
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body, "fileName": d.file_name, "variables": variables });
let mut out = UiDecl::new(d.id.clone(), d.name.clone(), json);
out.files = vec![d.path.clone()];
Ok(out)
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CommitCheckDecl {
id: String,
title: String,
}
/// `[[contributes.commitChecks]]`: a before-commit check run by the plugin's
/// `commit-checker` export, listed in Commit Options.
pub(super) fn commit_check(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: CommitCheckDecl = decode(COMMIT_CHECKS, decl)?;
pkg.manifest.needs_api(&format!("contributes.{COMMIT_CHECKS}"), 3)?;
validate_item_id(&d.id, "commit check id")?;
title(COMMIT_CHECKS, &d.title)?;
if pkg.manifest.wasm.is_none() {
return Err(format!("contributes.{COMMIT_CHECKS}: needs `wasm` (the checks run in the plugin's code)"));
}
if !pkg.manifest.capabilities.all().any(|c| *c == ide_extension::Capability::VcsRead) {
return Err(format!("contributes.{COMMIT_CHECKS}: needs the `vcs.read` capability (checks read the commit)"));
}
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
}
const MAX_CATALOG_ENTRIES: usize = 20_000;
pub fn locale_asset(tag: &str) -> String {
format!("locale/{tag}")
}
fn locale_tag_ok(tag: &str) -> bool {
let mut parts = tag.split('-');
let language = parts.next().unwrap_or_default();
(2..=3).contains(&language.len())
&& language.chars().all(|c| c.is_ascii_alphabetic())
&& parts.all(|p| (1..=8).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphanumeric()))
}
/// `locales/<tag>.json`: English source text → translation, for the plugin's own strings.
pub(super) fn locales(pkg: &Package, _decl: toml::Value) -> Result<UiDecl, String> {
let dir = Path::new(LOCALES_DIR);
let mut names: Vec<String> = std::fs::read_dir(pkg.dir.join(dir))
.map_err(|e| format!("{LOCALES_DIR}: {e}"))?
.filter_map(|entry| entry.ok()?.file_name().into_string().ok())
.filter(|name| name.ends_with(".json"))
.collect();
names.sort();
let mut tags = Vec::new();
let mut files = Vec::new();
let mut assets = Vec::new();
for name in names {
let tag = name.trim_end_matches(".json");
if !locale_tag_ok(tag) {
return Err(format!("{LOCALES_DIR}/{name}: the file name must be a language tag such as fr or zh-CN"));
}
let file = dir.join(&name);
let text = pkg.read_text(&file)?;
let catalog: BTreeMap<String, Value> = serde_json::from_str(&text)
.map_err(|e| format!("{}: not a JSON object of strings ({e})", file.display()))?;
if catalog.len() > MAX_CATALOG_ENTRIES {
return Err(format!("{}: more than {MAX_CATALOG_ENTRIES} entries", file.display()));
}
if let Some((key, _)) = catalog.iter().find(|(_, v)| !v.is_string()) {
return Err(format!("{}: the value of {key:?} is not a string", file.display()));
}
assets.push((locale_asset(tag), text));
tags.push(tag.to_owned());
files.push(file);
}
let mut out = UiDecl::new("locales", tags.join(", "), serde_json::json!({ "tags": tags }));
out.files = files;
out.assets = assets;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bindings_need_a_modifier() {
for ok in ["Ctrl+Alt+C", "Meta+Shift+K", "F5", "Shift+F6", "Meta+K Meta+C", "Alt+Enter"] {
assert!(validate_binding(ok).is_ok(), "{ok}");
}
for bad in ["C", "Shift+C", "Hyper+C", "Ctrl+Ctrl+C", "Ctrl+é", "Ctrl+A B C", ""] {
assert!(validate_binding(bad).is_err(), "{bad}");
}
}
#[test]
fn settings_schemas_are_typed() {
let s = parse_settings_schema(
r#"{"properties": {
"context": {"type": "string", "title": "Context", "default": ""},
"watch": {"type": "boolean", "default": true},
"mode": {"type": "string", "enum": ["a", "b"], "default": "b"},
"limit": {"type": "integer", "default": 100}
}}"#,
)
.unwrap();
assert_eq!(s.len(), 4);
assert!(s.iter().find(|x| x.key == "mode").unwrap().accepts(&Value::from("a")));
assert!(!s.iter().find(|x| x.key == "mode").unwrap().accepts(&Value::from("c")));
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "string", "default": 3}}}"#).is_err());
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err());
}
fn package_with_catalogs(files: &[(&str, &str)]) -> (tempfile::TempDir, Package) {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(LOCALES_DIR)).unwrap();
for (name, text) in files {
std::fs::write(dir.path().join(LOCALES_DIR).join(name), text).unwrap();
}
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"";
let pkg = Package::new(ide_extension::Manifest::parse(src).unwrap(), dir.path().to_path_buf());
(dir, pkg)
}
#[test]
fn catalogs_become_assets_by_language_tag() {
let (_dir, pkg) = package_with_catalogs(&[
("fr.json", r#"{"Refresh": "Actualiser"}"#),
("zh-CN.json", r#"{"Refresh": "刷新"}"#),
("notes.txt", "ignored"),
]);
let decl = locales(&pkg, toml::Value::Table(Default::default())).unwrap();
assert_eq!(decl.json["tags"], serde_json::json!(["fr", "zh-CN"]));
assert_eq!(decl.assets.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(), ["locale/fr", "locale/zh-CN"]);
}
#[test]
fn invalid_catalogs_are_refused() {
for (name, text) in [
("fr.json", "{"),
("fr.json", r#"["Refresh"]"#),
("fr.json", r#"{"Refresh": 3}"#),
("fr.json", r#"{"Refresh": {"a": "b"}}"#),
("french!.json", "{}"),
] {
let (_dir, pkg) = package_with_catalogs(&[(name, text)]);
assert!(locales(&pkg, toml::Value::Table(Default::default())).is_err(), "{name}: {text}");
}
}
}