use crate::prelude::*;
pub mod runtime;
use regex::Regex;
use serde_json::Value;
use std::fs::File;
use std::io::{Cursor, Read, Seek};
use std::path::{Component, Path, PathBuf};
use zip::ZipArchive;
use self::runtime::{execute_commonjs_command, inspect_commonjs_bundle, CommandExecution};
fn parse_jsonc<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
jsonc_parser::parse_to_serde_value(text, &Default::default())
.map_err(|e| anyhow::anyhow!("{e}"))?
.ok_or_else(|| anyhow::anyhow!("empty JSONC document"))
.and_then(|v| serde_json::from_value(v).map_err(|e| anyhow::anyhow!("{e}")))
}
#[derive(Debug, Clone)]
pub struct ExtensionHost {
workspace_root: PathBuf,
}
impl ExtensionHost {
pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
Self {
workspace_root: workspace_root.into(),
}
}
pub fn discover(&self) -> Result<(Vec<InstalledExtension>, Vec<String>)> {
let mut packages = Vec::new();
let mut warnings = Vec::new();
let extensions_dir = self.workspace_root.join(".wsb").join("extensions");
if extensions_dir.is_dir() {
for entry in std::fs::read_dir(&extensions_dir)
.with_context(|| format!("failed to read {}", extensions_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some("vsix")
{
continue;
}
match InstalledExtension::from_vsix(&path) {
Ok((extension, extension_warnings)) => {
packages.push(extension);
warnings.extend(extension_warnings);
}
Err(e) => {
let msg = format!("{}: {e:#}", path.display());
tracing::warn!("skipping bad extension {msg}");
warnings.push(msg);
}
}
}
}
packages.sort_by(|left, right| left.id.cmp(&right.id));
Ok((packages, warnings))
}
pub fn workbench_model(&self) -> Result<(VsCodeWorkbenchModel, Vec<String>)> {
let (all, mut warnings) = self.discover()?;
let disabled = self.disabled_ids();
let enabled: Vec<InstalledExtension> = all
.into_iter()
.filter(|ext| !disabled.contains(&ext.id))
.collect();
let (model, model_warnings) = VsCodeWorkbenchModel::from_extensions(enabled)?;
warnings.extend(model_warnings);
Ok((model, warnings))
}
pub fn disabled_ids(&self) -> std::collections::HashSet<String> {
let path = self.disabled_file_path();
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => return std::collections::HashSet::new(),
};
serde_json::from_slice::<Vec<String>>(&bytes)
.unwrap_or_default()
.into_iter()
.collect()
}
pub fn set_disabled_ids(&self, ids: &std::collections::HashSet<String>) -> Result<()> {
let path = self.disabled_file_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut sorted: Vec<&String> = ids.iter().collect();
sorted.sort();
let json = serde_json::to_string_pretty(&sorted)?;
std::fs::write(&path, json)?;
Ok(())
}
pub fn disable_extension(&self, extension_id: &str) -> Result<()> {
let (extensions, _) = self.discover()?;
if !extensions.iter().any(|ext| ext.id == extension_id) {
anyhow::bail!("extension '{}' is not installed", extension_id);
}
let mut disabled = self.disabled_ids();
disabled.insert(extension_id.to_string());
self.set_disabled_ids(&disabled)
}
pub fn enable_extension(&self, extension_id: &str) -> Result<()> {
let (extensions, _) = self.discover()?;
if !extensions.iter().any(|ext| ext.id == extension_id) {
anyhow::bail!("extension '{}' is not installed", extension_id);
}
let mut disabled = self.disabled_ids();
disabled.remove(extension_id);
self.set_disabled_ids(&disabled)
}
fn disabled_file_path(&self) -> PathBuf {
self.workspace_root
.join(".wsb")
.join("extensions")
.join("disabled.json")
}
pub fn execute_command(
&self,
command_id: &str,
server_base_url: &str,
context: WorkbenchExecutionContext,
) -> Result<CommandExecution> {
let extension = self.extension_for_command(command_id)?;
extension.execute_command(command_id, server_base_url, context)
}
pub fn read_asset(&self, extension_id: &str, asset_path: &str) -> Result<ExtensionAsset> {
let extension = self
.discover()?
.0
.into_iter()
.find(|extension| extension.id == extension_id)
.with_context(|| format!("unknown extension id '{}'", extension_id))?;
extension.read_asset(asset_path)
}
pub fn read_readme(&self, extension_id: &str) -> Result<String> {
let extension = self
.discover()?
.0
.into_iter()
.find(|extension| extension.id == extension_id)
.with_context(|| format!("unknown extension id '{}'", extension_id))?;
extension.read_readme()
}
fn extension_for_command(&self, command_id: &str) -> Result<InstalledExtension> {
self.discover()?
.0
.into_iter()
.find(|extension| {
extension
.contributes
.commands
.iter()
.any(|command| command.command == command_id)
})
.with_context(|| format!("unknown extension command '{}'", command_id))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtensionAsset {
pub path: String,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TextDocumentContext {
pub path: String,
pub uri: String,
pub language_id: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkbenchTabContext {
pub id: String,
pub label: String,
pub input_kind: String,
pub file_path: Option<String>,
pub language_id: Option<String>,
pub is_active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkbenchGroupContext {
pub id: String,
pub tabs: Vec<WorkbenchTabContext>,
pub active_tab_id: Option<String>,
pub is_active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkbenchExecutionContext {
pub groups: Vec<WorkbenchGroupContext>,
pub active_group_id: Option<String>,
pub document: Option<TextDocumentContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ShowDocumentRequest {
pub uri: String,
pub view_column: i32,
pub preserve_focus: bool,
pub selection_start_line: Option<u32>,
pub selection_start_char: Option<u32>,
pub selection_end_line: Option<u32>,
pub selection_end_char: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExtensionMessage {
pub level: String,
pub message: String,
pub items: Vec<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct VsCodeWorkbenchModel {
pub extensions: Vec<WorkbenchExtension>,
pub commands: Vec<WorkbenchCommand>,
pub languages: Vec<WorkbenchLanguage>,
pub grammars: Vec<WorkbenchGrammar>,
pub menus: HashMap<String, Vec<WorkbenchMenuItem>>,
pub configuration_properties: Vec<WorkbenchConfigurationProperty>,
pub views_containers: HashMap<String, Vec<WorkbenchViewContainer>>,
pub views: HashMap<String, Vec<WorkbenchView>>,
pub themes: Vec<WorkbenchTheme>,
pub keybindings: Vec<WorkbenchKeybinding>,
pub snippets: Vec<WorkbenchSnippet>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct WorkbenchViewContainer {
pub extension_id: String,
pub id: String,
pub title: String,
pub icon: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct WorkbenchView {
pub extension_id: String,
pub id: String,
pub name: String,
pub when: Option<String>,
}
impl VsCodeWorkbenchModel {
pub fn from_extensions(extensions: Vec<InstalledExtension>) -> Result<(Self, Vec<String>)> {
let extension_records = extensions
.iter()
.map(WorkbenchExtension::from_installed)
.collect::<Vec<_>>();
let mut warnings = Vec::new();
let mut commands = Vec::new();
let mut languages = Vec::new();
let mut grammars = Vec::new();
let mut menus: HashMap<String, Vec<WorkbenchMenuItem>> = HashMap::new();
let mut configuration_properties = Vec::new();
let mut views_containers: HashMap<String, Vec<WorkbenchViewContainer>> = HashMap::new();
let mut views: HashMap<String, Vec<WorkbenchView>> = HashMap::new();
let mut themes = Vec::new();
let mut keybindings = Vec::new();
let mut snippets = Vec::new();
let mut seen_commands: HashMap<String, String> = HashMap::new();
let mut seen_languages: HashMap<String, String> = HashMap::new();
let mut seen_grammars: HashMap<String, String> = HashMap::new();
let mut seen_configuration_properties: HashMap<String, String> = HashMap::new();
let mut seen_view_containers: HashMap<String, String> = HashMap::new();
let mut seen_views: HashMap<String, String> = HashMap::new();
for extension in &extensions {
for command in &extension.contributes.commands {
if let Some(previous) =
seen_commands.insert(command.command.clone(), extension.id.clone())
{
warnings.push(format!(
"command '{}' is contributed by both '{}' and '{}' — skipping duplicate",
command.command, previous, extension.id
));
continue;
}
commands.push(WorkbenchCommand {
extension_id: extension.id.clone(),
title: command.title.clone(),
command: command.command.clone(),
enable_command_uris: command.enable_command_uris,
icon: command.icon.clone(),
activation_events: extension.activation_events.clone(),
});
}
for language in &extension.contributes.languages {
if let Some(previous) =
seen_languages.insert(language.id.clone(), extension.id.clone())
{
warnings.push(format!(
"language '{}' is contributed by both '{}' and '{}' — skipping duplicate",
language.id, previous, extension.id
));
continue;
}
languages.push(WorkbenchLanguage {
extension_id: extension.id.clone(),
id: language.id.clone(),
aliases: language.aliases.clone(),
extensions: language.extensions.clone(),
configuration_path: language.configuration_path.clone(),
configuration: language.configuration.clone(),
});
}
for grammar in &extension.contributes.grammars {
if let Some(previous) =
seen_grammars.insert(grammar.scope_name.clone(), extension.id.clone())
{
warnings.push(format!(
"grammar for scope '{}' is contributed by both '{}' and '{}' — skipping duplicate",
grammar.scope_name, previous, extension.id
));
continue;
}
grammars.push(WorkbenchGrammar {
extension_id: extension.id.clone(),
language: grammar.language.clone(),
scope_name: grammar.scope_name.clone(),
path: grammar.path.clone(),
grammar: grammar.grammar.clone(),
});
}
for (menu_id, menu_items) in &extension.contributes.menus {
let bucket = menus.entry(menu_id.clone()).or_default();
for item in menu_items {
bucket.push(WorkbenchMenuItem {
extension_id: extension.id.clone(),
command: item.command.clone(),
when: item.when.clone(),
group: item.group.clone(),
});
}
}
if let Some(configuration) = &extension.contributes.configuration {
match configuration.properties.as_object() {
Some(properties) => {
for (property_key, schema) in properties {
if let Some(previous) = seen_configuration_properties
.insert(property_key.clone(), extension.id.clone())
{
warnings.push(format!(
"configuration property '{}' is contributed by both '{}' and '{}' — skipping duplicate",
property_key, previous, extension.id
));
continue;
}
configuration_properties.push(WorkbenchConfigurationProperty {
extension_id: extension.id.clone(),
key: property_key.clone(),
schema: schema.clone(),
});
}
}
None => {
warnings.push(format!(
"configuration properties for '{}' is not a JSON object — skipping",
extension.id
));
}
}
}
for (location, containers) in &extension.contributes.views_containers {
let bucket = views_containers.entry(location.clone()).or_default();
for container in containers {
if let Some(previous) =
seen_view_containers.insert(container.id.clone(), extension.id.clone())
{
warnings.push(format!(
"view container '{}' is contributed by both '{}' and '{}' — skipping duplicate",
container.id, previous, extension.id
));
continue;
}
bucket.push(WorkbenchViewContainer {
extension_id: extension.id.clone(),
id: container.id.clone(),
title: container.title.clone(),
icon: container.icon.clone().unwrap_or_default(),
});
}
}
for (container_id, view_list) in &extension.contributes.views {
let bucket = views.entry(container_id.clone()).or_default();
for view in view_list {
if let Some(previous) = seen_views.insert(view.id.clone(), extension.id.clone())
{
warnings.push(format!(
"view '{}' is contributed by both '{}' and '{}' — skipping duplicate",
view.id, previous, extension.id
));
continue;
}
bucket.push(WorkbenchView {
extension_id: extension.id.clone(),
id: view.id.clone(),
name: view.name.clone(),
when: view.when.clone(),
});
}
}
for theme in &extension.contributes.themes {
themes.push(WorkbenchTheme {
extension_id: extension.id.clone(),
label: theme.label.clone(),
ui_theme: theme.ui_theme.clone(),
colors: theme.colors.clone(),
token_colors: theme.token_colors.clone(),
});
}
for keybinding in &extension.contributes.keybindings {
keybindings.push(WorkbenchKeybinding {
extension_id: extension.id.clone(),
command: keybinding.command.clone(),
key: keybinding.key.clone(),
mac: keybinding.mac.clone(),
linux: keybinding.linux.clone(),
when: keybinding.when.clone(),
});
}
for snippet in &extension.contributes.snippets {
snippets.push(WorkbenchSnippet {
extension_id: extension.id.clone(),
language: snippet.language.clone(),
body: snippet.body.clone(),
});
}
}
let mut all_themes = builtin_themes();
all_themes.extend(themes);
let themes = all_themes;
commands.sort_by(|left, right| left.command.cmp(&right.command));
languages.sort_by(|left, right| left.id.cmp(&right.id));
grammars.sort_by(|left, right| left.scope_name.cmp(&right.scope_name));
configuration_properties.sort_by(|left, right| left.key.cmp(&right.key));
for menu_items in menus.values_mut() {
menu_items.sort_by(|left, right| left.command.cmp(&right.command));
}
Ok((
Self {
extensions: extension_records,
commands,
languages,
grammars,
menus,
configuration_properties,
views_containers,
views,
themes,
keybindings,
snippets,
},
warnings,
))
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct WorkbenchExtension {
pub id: String,
pub publisher: String,
pub name: String,
pub display_name: String,
pub version: String,
pub description: Option<String>,
pub icon: Option<String>,
pub package_path: String,
pub main: Option<String>,
pub runtime: Option<ExtensionRuntime>,
pub activation_events: Vec<String>,
}
impl WorkbenchExtension {
pub fn from_installed(extension: &InstalledExtension) -> Self {
Self {
id: extension.id.clone(),
publisher: extension.publisher.clone(),
name: extension.name.clone(),
display_name: extension.display_name.clone(),
version: extension.version.clone(),
description: extension.description.clone(),
icon: extension.icon.clone(),
package_path: extension.package_path.clone(),
main: extension.main.clone(),
runtime: extension.runtime.clone(),
activation_events: extension.activation_events.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct WorkbenchCommand {
pub extension_id: String,
pub title: String,
pub command: String,
pub enable_command_uris: bool,
pub icon: Option<CommandIcon>,
pub activation_events: Vec<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct WorkbenchLanguage {
pub extension_id: String,
pub id: String,
pub aliases: Vec<String>,
pub extensions: Vec<String>,
pub configuration_path: Option<String>,
pub configuration: Option<Value>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct WorkbenchGrammar {
pub extension_id: String,
pub language: Option<String>,
pub scope_name: String,
pub path: String,
pub grammar: Value,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct WorkbenchMenuItem {
pub extension_id: String,
pub command: String,
pub when: Option<String>,
pub group: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct WorkbenchConfigurationProperty {
pub extension_id: String,
pub key: String,
pub schema: Value,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct WorkbenchTheme {
pub extension_id: String,
pub label: String,
pub ui_theme: String,
pub colors: Value,
pub token_colors: Value,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct WorkbenchKeybinding {
pub extension_id: String,
pub command: String,
pub key: String,
pub mac: Option<String>,
pub linux: Option<String>,
pub when: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct WorkbenchSnippet {
pub extension_id: String,
pub language: String,
pub body: Value,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct InstalledExtension {
pub id: String,
pub publisher: String,
pub name: String,
pub display_name: String,
pub version: String,
pub description: Option<String>,
pub icon: Option<String>,
pub package_path: String,
pub main: Option<String>,
pub runtime: Option<ExtensionRuntime>,
pub activation_events: Vec<String>,
pub contributes: ExtensionContributions,
}
impl InstalledExtension {
pub fn from_vsix(path: &Path) -> Result<(Self, Vec<String>)> {
let file = File::open(path)
.with_context(|| format!("failed to open VSIX package {}", path.display()))?;
let mut archive = ZipArchive::new(file)
.with_context(|| format!("failed to read ZIP archive {}", path.display()))?;
Self::from_archive(&path.display().to_string(), &mut archive)
}
pub fn from_vsix_bytes(package_path: &str, bytes: Vec<u8>) -> Result<(Self, Vec<String>)> {
let cursor = Cursor::new(bytes);
let mut archive = ZipArchive::new(cursor)
.with_context(|| format!("failed to read ZIP archive {package_path}"))?;
Self::from_archive(package_path, &mut archive)
}
fn from_archive<R: Read + Seek>(
package_path: &str,
archive: &mut ZipArchive<R>,
) -> Result<(Self, Vec<String>)> {
let mut warnings = Vec::new();
let manifest_text = read_zip_string(archive, "extension/package.json")?;
let manifest: VsixManifest = parse_jsonc(&manifest_text)
.with_context(|| format!("failed to parse extension/package.json in {package_path}"))?;
let contributes = ExtensionContributions::from_manifest(&manifest, archive)?;
let display_name = manifest
.display_name
.clone()
.unwrap_or_else(|| manifest.name.clone());
let icon = manifest
.icon
.as_deref()
.map(normalize_runtime_asset_path)
.transpose()
.with_context(|| format!("invalid icon path in {package_path}"))?;
let extension_id = format!("{}.{}", manifest.publisher, manifest.name);
let runtime = match manifest.main.as_deref() {
Some(main) => match ExtensionRuntime::from_main(main, archive) {
Ok(runtime) => Some(runtime),
Err(error) => {
warnings.push(format!(
"extension '{}' runtime inspection failed: {error:#}",
extension_id
));
None
}
},
None => None,
};
Ok((
Self {
id: format!("{}.{}", manifest.publisher, manifest.name),
publisher: manifest.publisher,
name: manifest.name,
display_name,
version: manifest.version,
description: manifest.description,
icon,
package_path: package_path.to_string(),
main: manifest.main,
runtime,
activation_events: manifest.activation_events.unwrap_or_default(),
contributes,
},
warnings,
))
}
pub fn execute_command(
&self,
command_id: &str,
server_base_url: &str,
context: WorkbenchExecutionContext,
) -> Result<CommandExecution> {
let main = self
.main
.as_deref()
.with_context(|| format!("extension '{}' has no runtime entrypoint", self.id))?;
let file = File::open(&self.package_path)
.with_context(|| format!("failed to open VSIX package {}", self.package_path))?;
let mut archive = ZipArchive::new(file)
.with_context(|| format!("failed to read ZIP archive {}", self.package_path))?;
let resolved = resolve_extension_path(main)?;
let source = read_zip_string(&mut archive, &resolved)?;
let asset_files = read_zip_assets(&mut archive)?;
execute_commonjs_command(
&source,
Path::new(main),
&self.id,
command_id,
server_base_url,
&asset_files,
&context,
)
.with_context(|| {
format!(
"failed to execute command '{}' for extension '{}'",
command_id, self.id
)
})
}
pub fn read_asset(&self, asset_path: &str) -> Result<ExtensionAsset> {
let file = File::open(&self.package_path)
.with_context(|| format!("failed to open VSIX package {}", self.package_path))?;
let mut archive = ZipArchive::new(file)
.with_context(|| format!("failed to read ZIP archive {}", self.package_path))?;
let normalized = normalize_runtime_asset_path(asset_path)?;
let bytes = read_zip_bytes(&mut archive, &runtime_asset_to_archive_entry(&normalized))?;
Ok(ExtensionAsset {
path: normalized,
bytes,
})
}
pub fn read_readme(&self) -> Result<String> {
let file = File::open(&self.package_path)
.with_context(|| format!("failed to open VSIX package {}", self.package_path))?;
let mut archive = ZipArchive::new(file)
.with_context(|| format!("failed to read ZIP archive {}", self.package_path))?;
let entry_name = find_extension_readme_entry(&mut archive).with_context(|| {
format!(
"extension '{}' does not contain a supported README",
self.id
)
})?;
read_zip_string(&mut archive, &entry_name)
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ExtensionRuntime {
pub entrypoint: String,
pub builtin_modules: Vec<String>,
pub server_entrypoints: Vec<String>,
pub export_names: Vec<String>,
pub registered_commands: Vec<String>,
pub output_channels: Vec<String>,
pub workspace_listeners: Vec<String>,
pub subscription_count: usize,
}
impl ExtensionRuntime {
fn from_main<R: Read + Seek>(main: &str, archive: &mut ZipArchive<R>) -> Result<Self> {
let resolved = resolve_extension_path(main)?;
let source = read_zip_string(archive, &resolved)?;
let require_pattern = Regex::new(r#"require\("([^"]+)"\)|require\('([^']+)'\)"#).unwrap();
let mut builtin_modules = require_pattern
.captures_iter(&source)
.filter_map(|capture| capture.get(1).or_else(|| capture.get(2)))
.map(|value| value.as_str().to_string())
.filter(|value| !value.starts_with('.'))
.collect::<Vec<_>>();
builtin_modules.sort();
builtin_modules.dedup();
let server_entrypoints = detect_server_entrypoints(&source);
let inspection = inspect_commonjs_bundle(&source, Path::new(main))
.with_context(|| format!("failed to inspect bundled runtime entrypoint {}", main))?;
Ok(Self {
entrypoint: main.to_string(),
builtin_modules,
server_entrypoints,
export_names: inspection.export_names,
registered_commands: inspection.registered_commands,
output_channels: inspection.output_channels,
workspace_listeners: inspection.workspace_listeners,
subscription_count: inspection.subscription_count,
})
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Default)]
pub struct ExtensionContributions {
pub commands: Vec<ExtensionCommand>,
pub languages: Vec<ExtensionLanguage>,
pub grammars: Vec<ExtensionGrammar>,
pub configuration: Option<ExtensionConfiguration>,
pub menus: HashMap<String, Vec<ExtensionMenuItem>>,
pub views_containers: HashMap<String, Vec<ManifestViewContainer>>,
pub views: HashMap<String, Vec<ManifestView>>,
pub themes: Vec<ExtensionTheme>,
pub keybindings: Vec<ExtensionKeybinding>,
pub snippets: Vec<ExtensionSnippet>,
}
impl ExtensionContributions {
fn from_manifest<R: Read + Seek>(
manifest: &VsixManifest,
archive: &mut ZipArchive<R>,
) -> Result<Self> {
let Some(contributes) = manifest.contributes.as_ref() else {
return Ok(Self::default());
};
let commands = contributes
.commands
.clone()
.unwrap_or_default()
.into_iter()
.map(ExtensionCommand::from_manifest)
.collect();
let languages = contributes
.languages
.clone()
.unwrap_or_default()
.into_iter()
.map(|language| ExtensionLanguage::from_manifest(language, archive))
.collect::<Result<Vec<_>>>()?;
let grammars = contributes
.grammars
.clone()
.unwrap_or_default()
.into_iter()
.map(|grammar| ExtensionGrammar::from_manifest(grammar, archive))
.collect::<Result<Vec<_>>>()?;
let configuration = contributes
.configuration
.clone()
.map(ExtensionConfiguration::from_manifest);
let themes = contributes
.themes
.clone()
.unwrap_or_default()
.into_iter()
.map(|theme| ExtensionTheme::from_manifest(theme, archive))
.collect::<Result<Vec<_>>>()?;
let keybindings = contributes
.keybindings
.clone()
.unwrap_or_default()
.into_iter()
.map(ExtensionKeybinding::from_manifest)
.collect();
let snippets = contributes
.snippets
.clone()
.unwrap_or_default()
.into_iter()
.map(|snippet| ExtensionSnippet::from_manifest(snippet, archive))
.collect::<Result<Vec<_>>>()?;
Ok(Self {
commands,
languages,
grammars,
configuration,
menus: contributes.menus.clone().unwrap_or_default(),
views_containers: contributes.views_containers.clone().unwrap_or_default(),
views: contributes.views.clone().unwrap_or_default(),
themes,
keybindings,
snippets,
})
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ExtensionCommand {
pub command: String,
pub title: String,
pub enable_command_uris: bool,
pub icon: Option<CommandIcon>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum CommandIcon {
ThemePath { light: String, dark: String },
ThemeIcon(String),
}
impl ExtensionCommand {
fn from_manifest(command: ManifestCommand) -> Self {
Self {
command: command.command,
title: command.title,
enable_command_uris: command.enable_command_uris.unwrap_or(false),
icon: command.icon,
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ExtensionLanguage {
pub id: String,
pub aliases: Vec<String>,
pub extensions: Vec<String>,
pub configuration_path: Option<String>,
pub configuration: Option<Value>,
}
impl ExtensionLanguage {
fn from_manifest<R: Read + Seek>(
language: ManifestLanguage,
archive: &mut ZipArchive<R>,
) -> Result<Self> {
let configuration_path = language.configuration.clone();
let configuration =
if let Some(path) = configuration_path.as_deref() {
let resolved = resolve_extension_path(path)?;
let text = read_zip_string(archive, &resolved)?;
Some(parse_jsonc(&text).with_context(|| {
format!("failed to parse language configuration {}", resolved)
})?)
} else {
None
};
Ok(Self {
id: language.id,
aliases: language.aliases.unwrap_or_default(),
extensions: language.extensions.unwrap_or_default(),
configuration_path,
configuration,
})
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ExtensionGrammar {
pub language: Option<String>,
pub scope_name: String,
pub path: String,
pub grammar: Value,
}
impl ExtensionGrammar {
fn from_manifest<R: Read + Seek>(
grammar: ManifestGrammar,
archive: &mut ZipArchive<R>,
) -> Result<Self> {
let resolved = resolve_extension_path(&grammar.path)?;
let text = read_zip_string(archive, &resolved)?;
let value =
parse_jsonc(&text).with_context(|| format!("failed to parse grammar {}", resolved))?;
Ok(Self {
language: grammar.language,
scope_name: grammar.scope_name,
path: grammar.path,
grammar: value,
})
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ExtensionConfiguration {
pub title: Option<String>,
pub schema_type: Option<String>,
pub properties: Value,
}
impl ExtensionConfiguration {
fn from_manifest(configuration: ManifestConfiguration) -> Self {
Self {
title: configuration.title,
schema_type: configuration.schema_type,
properties: configuration
.properties
.unwrap_or(Value::Object(Default::default())),
}
}
}
impl ExtensionTheme {
fn from_manifest<R: Read + Seek>(
theme: ManifestTheme,
archive: &mut ZipArchive<R>,
) -> Result<Self> {
let resolved = resolve_extension_path(&theme.path)?;
let text = read_zip_string(archive, &resolved)
.with_context(|| format!("failed to read theme file '{}' from VSIX", theme.path))?;
let parsed: Value = parse_jsonc(&text)
.with_context(|| format!("failed to parse theme JSON '{}'", theme.path))?;
let colors = parsed
.get("colors")
.cloned()
.unwrap_or(Value::Object(Default::default()));
let token_colors = parsed
.get("tokenColors")
.cloned()
.unwrap_or(Value::Array(Default::default()));
Ok(Self {
label: theme.label,
ui_theme: theme.ui_theme,
path: theme.path,
colors,
token_colors,
})
}
}
impl ExtensionKeybinding {
fn from_manifest(keybinding: ManifestKeybinding) -> Self {
Self {
command: keybinding.command,
key: keybinding.key,
mac: keybinding.mac,
linux: keybinding.linux,
when: keybinding.when,
}
}
}
impl ExtensionSnippet {
fn from_manifest<R: Read + Seek>(
snippet: ManifestSnippet,
archive: &mut ZipArchive<R>,
) -> Result<Self> {
let resolved = resolve_extension_path(&snippet.path)?;
let text = read_zip_string(archive, &resolved)
.with_context(|| format!("failed to read snippet file '{}' from VSIX", snippet.path))?;
let body: Value = parse_jsonc(&text)
.with_context(|| format!("failed to parse snippet JSON '{}'", snippet.path))?;
Ok(Self {
language: snippet.language,
path: snippet.path,
body,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExtensionMenuItem {
pub command: String,
#[serde(default)]
pub when: Option<String>,
#[serde(default)]
pub group: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ExtensionTheme {
pub label: String,
pub ui_theme: String,
pub path: String,
pub colors: Value,
pub token_colors: Value,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ExtensionKeybinding {
pub command: String,
pub key: String,
pub mac: Option<String>,
pub linux: Option<String>,
pub when: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ExtensionSnippet {
pub language: String,
pub path: String,
pub body: Value,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VsixManifest {
name: String,
publisher: String,
version: String,
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
main: Option<String>,
#[serde(default)]
activation_events: Option<Vec<String>>,
#[serde(default)]
contributes: Option<ManifestContributes>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ManifestContributes {
#[serde(default)]
commands: Option<Vec<ManifestCommand>>,
#[serde(default)]
languages: Option<Vec<ManifestLanguage>>,
#[serde(default)]
grammars: Option<Vec<ManifestGrammar>>,
#[serde(default)]
configuration: Option<ManifestConfiguration>,
#[serde(default)]
menus: Option<HashMap<String, Vec<ExtensionMenuItem>>>,
#[serde(default)]
views_containers: Option<HashMap<String, Vec<ManifestViewContainer>>>,
#[serde(default)]
views: Option<HashMap<String, Vec<ManifestView>>>,
#[serde(default)]
themes: Option<Vec<ManifestTheme>>,
#[serde(default)]
keybindings: Option<Vec<ManifestKeybinding>>,
#[serde(default)]
snippets: Option<Vec<ManifestSnippet>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ManifestViewContainer {
pub id: String,
pub title: String,
#[serde(default)]
pub icon: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ManifestView {
pub id: String,
pub name: String,
#[serde(default)]
pub when: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ManifestCommand {
command: String,
title: String,
#[serde(default)]
enable_command_uris: Option<bool>,
#[serde(default)]
icon: Option<CommandIcon>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ManifestLanguage {
id: String,
#[serde(default)]
aliases: Option<Vec<String>>,
#[serde(default)]
extensions: Option<Vec<String>>,
#[serde(default)]
configuration: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ManifestGrammar {
#[serde(default)]
language: Option<String>,
path: String,
scope_name: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ManifestConfiguration {
#[serde(default)]
title: Option<String>,
#[serde(rename = "type", default)]
schema_type: Option<String>,
#[serde(default)]
properties: Option<Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ManifestTheme {
label: String,
ui_theme: String,
path: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ManifestKeybinding {
command: String,
key: String,
#[serde(default)]
mac: Option<String>,
#[serde(default)]
linux: Option<String>,
#[serde(default)]
when: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ManifestSnippet {
language: String,
path: String,
}
fn resolve_extension_path(raw_path: &str) -> Result<String> {
let trimmed = raw_path.trim();
if trimmed.is_empty() {
anyhow::bail!("extension asset path must not be empty");
}
let mut parts: Vec<String> = Vec::new();
for component in Path::new(trimmed).components() {
match component {
Component::CurDir => {}
Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
Component::ParentDir => {
anyhow::bail!(
"extension asset path '{}' must not traverse outside the package root",
raw_path
)
}
Component::RootDir | Component::Prefix(_) => {
anyhow::bail!(
"extension asset path '{}' must be relative to the package root",
raw_path
)
}
}
}
Ok(format!("extension/{}", parts.join("/")))
}
fn read_zip_string<R: Read + Seek>(
archive: &mut ZipArchive<R>,
entry_name: &str,
) -> Result<String> {
let mut file = archive
.by_name(entry_name)
.with_context(|| format!("missing '{}' in VSIX package", entry_name))?;
let mut buffer = String::new();
file.read_to_string(&mut buffer)
.with_context(|| format!("failed to read '{}' from VSIX package", entry_name))?;
Ok(buffer)
}
fn read_zip_bytes<R: Read + Seek>(
archive: &mut ZipArchive<R>,
entry_name: &str,
) -> Result<Vec<u8>> {
let mut file = archive
.by_name(entry_name)
.with_context(|| format!("missing '{}' in VSIX package", entry_name))?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)
.with_context(|| format!("failed to read '{}' from VSIX package", entry_name))?;
Ok(buffer)
}
fn read_zip_assets<R: Read + Seek>(
archive: &mut ZipArchive<R>,
) -> Result<HashMap<String, Vec<u8>>> {
let mut assets = HashMap::new();
let len = archive.len();
for index in 0..len {
let mut file = archive.by_index(index)?;
if file.name().ends_with('/') {
continue;
}
let name = file.name().to_string();
if !name.starts_with("extension/") {
continue;
}
let runtime_path = format!("/{}", name.trim_start_matches("extension/"));
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.with_context(|| format!("failed to read '{}' from VSIX package", name))?;
assets.insert(runtime_path, bytes);
}
Ok(assets)
}
fn find_extension_readme_entry<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Option<String> {
const CANDIDATES: &[&str] = &[
"extension/readme.md",
"extension/readme.markdown",
"extension/readme",
];
let mut names = Vec::new();
for index in 0..archive.len() {
let file = archive.by_index(index).ok()?;
if file.name().ends_with('/') {
continue;
}
names.push(file.name().to_string());
}
CANDIDATES.iter().find_map(|candidate| {
names
.iter()
.find(|name| name.eq_ignore_ascii_case(candidate))
.cloned()
})
}
fn normalize_runtime_asset_path(raw_path: &str) -> Result<String> {
let trimmed = raw_path.trim();
if trimmed.is_empty() {
anyhow::bail!("extension runtime asset path must not be empty");
}
let mut parts: Vec<String> = Vec::new();
for component in Path::new(trimmed).components() {
match component {
Component::RootDir | Component::CurDir => {}
Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
Component::ParentDir => {
anyhow::bail!(
"extension runtime asset path '{}' must not traverse outside the package root",
raw_path
)
}
Component::Prefix(_) => {
anyhow::bail!(
"extension runtime asset path '{}' must not use a path prefix",
raw_path
)
}
}
}
let normalized = format!("/{}", parts.join("/"));
if normalized == "/" {
anyhow::bail!(
"extension runtime asset path '{}' must address a file",
raw_path
);
}
Ok(normalized)
}
fn runtime_asset_to_archive_entry(asset_path: &str) -> String {
format!("extension/{}", asset_path.trim_start_matches('/'))
}
fn detect_server_entrypoints(source: &str) -> Vec<String> {
let pattern = Regex::new(
r#"join\("([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\)|join\('([^']+)'\s*,\s*'([^']+)'\s*,\s*'([^']+)'\)"#,
)
.unwrap();
let mut results = pattern
.captures_iter(source)
.filter_map(|capture| {
let values = (1..=6)
.filter_map(|index| capture.get(index).map(|value| value.as_str()))
.collect::<Vec<_>>();
if values.len() != 3 {
return None;
}
let joined = values.join("/");
if joined.ends_with(".js") {
Some(format!("./{}", joined))
} else {
None
}
})
.collect::<Vec<_>>();
results.sort();
results.dedup();
results
}
fn builtin_themes() -> Vec<WorkbenchTheme> {
let light_colors = serde_json::json!({
"editor.background": "#f5f6f8",
"sideBar.background": "#ffffff",
"list.hoverBackground": "#f0f1f4",
"input.background": "#ecedf2",
"tab.activeBackground": "#ffffff",
"panel.border": "#d8dae2",
"widget.border": "#e4e6ec",
"editor.foreground": "#181b24",
"foreground": "#555b6e",
"disabledForeground": "#878ea0",
"focusBorder": "#2563eb",
"errorForeground": "#dc2626",
"testing.iconPassed": "#059669",
"editorWarning.foreground": "#d97706",
"textPreformat.foreground": "#7c3aed",
"widget.shadow": "rgba(0,0,0,0.3)"
});
let dark_colors = serde_json::json!({
"editor.background": "#0d0d12",
"sideBar.background": "#16161e",
"list.hoverBackground": "#1f1f2a",
"input.background": "#111116",
"tab.activeBackground": "#16161e",
"panel.border": "#2b2b38",
"widget.border": "#232330",
"editor.foreground": "#e4e6eb",
"foreground": "#9ea5b4",
"disabledForeground": "#6c7385",
"focusBorder": "#60a5fa",
"errorForeground": "#f87171",
"testing.iconPassed": "#34d399",
"editorWarning.foreground": "#fbbf24",
"textPreformat.foreground": "#a78bfa",
"widget.shadow": "rgba(0,0,0,0.6)"
});
vec![
WorkbenchTheme {
extension_id: "builtin".to_string(),
label: "WSB Light".to_string(),
ui_theme: "vs".to_string(),
colors: light_colors,
token_colors: Value::Array(vec![]),
},
WorkbenchTheme {
extension_id: "builtin".to_string(),
label: "WSB Dark".to_string(),
ui_theme: "vs-dark".to_string(),
colors: dark_colors,
token_colors: Value::Array(vec![]),
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
use zip::write::SimpleFileOptions;
#[test]
fn resolve_extension_path_rejects_parent_traversal() {
let error =
resolve_extension_path("../dist/extension.js").expect_err("path traversal must fail");
assert!(error
.to_string()
.contains("must not traverse outside the package root"));
}
#[test]
fn builtin_themes_are_prepended_to_workbench_model() {
let (model, warnings) = VsCodeWorkbenchModel::from_extensions(vec![])
.expect("empty extensions should produce a valid model");
assert!(warnings.is_empty());
assert_eq!(model.themes.len(), 2);
assert_eq!(model.themes[0].label, "WSB Light");
assert_eq!(model.themes[0].ui_theme, "vs");
assert_eq!(model.themes[0].extension_id, "builtin");
assert_eq!(model.themes[1].label, "WSB Dark");
assert_eq!(model.themes[1].ui_theme, "vs-dark");
assert_eq!(model.themes[1].extension_id, "builtin");
let light_colors = model.themes[0]
.colors
.as_object()
.expect("light theme colors must be a JSON object");
assert_eq!(
light_colors
.get("editor.background")
.and_then(|v| v.as_str()),
Some("#f5f6f8")
);
assert_eq!(
light_colors.get("focusBorder").and_then(|v| v.as_str()),
Some("#2563eb")
);
let dark_colors = model.themes[1]
.colors
.as_object()
.expect("dark theme colors must be a JSON object");
assert_eq!(
dark_colors
.get("editor.background")
.and_then(|v| v.as_str()),
Some("#0d0d12")
);
assert_eq!(
dark_colors.get("focusBorder").and_then(|v| v.as_str()),
Some("#60a5fa")
);
}
#[test]
fn workbench_model_warns_on_duplicate_commands() {
let extension_a = InstalledExtension {
id: "pub.a".to_string(),
publisher: "pub".to_string(),
name: "a".to_string(),
display_name: "A".to_string(),
version: "1.0.0".to_string(),
description: None,
icon: None,
package_path: "a.vsix".to_string(),
main: None,
runtime: None,
activation_events: vec![],
contributes: ExtensionContributions {
commands: vec![ExtensionCommand {
command: "duplicate.command".to_string(),
title: "A".to_string(),
enable_command_uris: false,
icon: None,
}],
..ExtensionContributions::default()
},
};
let extension_b = InstalledExtension {
id: "pub.b".to_string(),
publisher: "pub".to_string(),
name: "b".to_string(),
display_name: "B".to_string(),
version: "1.0.0".to_string(),
description: None,
icon: None,
package_path: "b.vsix".to_string(),
main: None,
runtime: None,
activation_events: vec![],
contributes: ExtensionContributions {
commands: vec![ExtensionCommand {
command: "duplicate.command".to_string(),
title: "B".to_string(),
enable_command_uris: false,
icon: None,
}],
..ExtensionContributions::default()
},
};
let (model, warnings) =
VsCodeWorkbenchModel::from_extensions(vec![extension_a, extension_b])
.expect("duplicate commands should produce a model with warnings");
assert_eq!(model.commands.len(), 1);
assert_eq!(model.commands[0].extension_id, "pub.a");
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("command 'duplicate.command' is contributed by both"));
}
#[test]
fn normalize_runtime_asset_path_accepts_relative_icon_path() {
let normalized = normalize_runtime_asset_path("images/icon.png")
.expect("relative icon path should normalize");
assert_eq!(normalized, "/images/icon.png");
}
#[test]
fn manifest_command_icon_accepts_theme_path_object() {
let command: ManifestCommand = serde_json::from_str(
r#"{
"command": "example.open",
"title": "Example: Open",
"icon": {
"light": "resources/light.svg",
"dark": "resources/dark.svg"
}
}"#,
)
.expect("manifest command icon object must deserialize");
assert_eq!(
command.icon,
Some(CommandIcon::ThemePath {
light: "resources/light.svg".to_string(),
dark: "resources/dark.svg".to_string(),
})
);
}
#[test]
fn discover_keeps_extension_when_runtime_inspection_fails_and_returns_warning() {
let workspace = tempdir().expect("temp workspace must be created");
let extensions_dir = workspace.path().join(".wsb").join("extensions");
std::fs::create_dir_all(&extensions_dir).expect("extensions directory must be created");
let vsix_path = extensions_dir.join("broken-runtime.vsix");
let file = File::create(&vsix_path).expect("vsix file must be created");
let mut zip = zip::ZipWriter::new(file);
let options = SimpleFileOptions::default();
zip.start_file("extension/package.json", options.clone())
.expect("package.json entry must be created");
write!(
zip,
r#"{{
"name": "broken-runtime",
"publisher": "test",
"version": "1.0.0",
"main": "./dist/extension.js",
"contributes": {{
"themes": [
{{
"label": "Broken Runtime Theme",
"uiTheme": "vs-dark",
"path": "./themes/theme.json"
}}
]
}}
}}"#
)
.expect("package.json must be written");
zip.start_file("extension/dist/extension.js", options.clone())
.expect("runtime entrypoint must be created");
write!(
zip,
r#"const missing = require("definitely-unsupported");
exports.activate = () => missing;"#
)
.expect("runtime entrypoint must be written");
zip.start_file("extension/themes/theme.json", options)
.expect("theme entry must be created");
write!(
zip,
r##"{{
"colors": {{
"editor.background": "#010203"
}}
}}"##
)
.expect("theme file must be written");
zip.finish().expect("vsix archive must be finalized");
let host = ExtensionHost::new(workspace.path());
let (extensions, warnings) = host.discover().expect("discovery must succeed");
assert_eq!(extensions.len(), 1, "extension must still be discovered");
assert_eq!(extensions[0].id, "test.broken-runtime");
assert!(
warnings.iter().any(|warning| warning.contains("runtime inspection failed")),
"runtime inspection failure must surface as a warning for frontend notifications: {warnings:?}"
);
assert!(
warnings
.iter()
.any(|warning| warning.contains("test.broken-runtime")),
"warning must identify the affected extension: {warnings:?}"
);
let (model, model_warnings) = host
.workbench_model()
.expect("workbench model must still be created");
assert!(
model
.themes
.iter()
.any(|theme| theme.label == "Broken Runtime Theme"),
"non-runtime contributions must still be available despite runtime inspection failure"
);
assert_eq!(
warnings, model_warnings,
"workbench model must propagate discovery warnings unchanged"
);
assert!(
extensions[0].runtime.is_none(),
"runtime metadata must be omitted when inspection fails"
);
}
#[test]
fn read_readme_loads_root_readme_case_insensitively() {
let workspace = tempdir().expect("temp workspace must be created");
let extensions_dir = workspace.path().join(".wsb").join("extensions");
std::fs::create_dir_all(&extensions_dir).expect("extensions directory must be created");
let vsix_path = extensions_dir.join("readme-extension.vsix");
let file = File::create(&vsix_path).expect("vsix file must be created");
let mut zip = zip::ZipWriter::new(file);
let options = SimpleFileOptions::default();
zip.start_file("extension/package.json", options.clone())
.expect("package.json entry must be created");
write!(
zip,
"{}",
r#"{
"name": "readme-extension",
"publisher": "test",
"version": "1.0.0"
}"#
)
.expect("package.json must be written");
zip.start_file("extension/README.MD", options)
.expect("readme entry must be created");
write!(zip, "# Test Readme\n\nHello from the package.\n").expect("readme must be written");
zip.finish().expect("vsix archive must be finalized");
let host = ExtensionHost::new(workspace.path());
let readme = host
.read_readme("test.readme-extension")
.expect("root readme must be readable");
assert!(readme.contains("# Test Readme"));
assert!(readme.contains("Hello from the package."));
}
}