mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
feat(format): external formatting with managed Prettier (#671)
Reformat Code only asked the language server for formatting, so files whose server has no formatting capability (Markdown with marksman, and others depending on the server) could not be formatted. Languages can now declare an external formatter. Prettier is the first: a managed tool installed on demand, or the project's own node_modules/prettier, an explicit path, or PATH. It runs on the backend copy of the document with the file's path so Prettier resolves the project's config, overrides and ignore files, and its result is applied as one undoable edit. Language servers that format a file keep doing so unless Prettier is preferred in Settings › Languages › Prettier; formatting on save is opt-in. Parsers without range support format the whole file and say so. Errors carry structured tool data, so a missing Prettier reuses the shared install prompt and Prettier diagnostics or timeouts show as non-sticky notifications. Fixes #639
This commit is contained in:
1 parent
9bb44cbeb3
commit
ec5193f827
49 files changed
+1111
-29
No files matched your search
@@ -12,6 +12,15 @@ pub type IdeResult<T> = Result<T, IdeError>;
|
||||
pub struct IdeError {
|
||||
pub kind: ErrorKind,
|
||||
pub message: String,
|
||||
/// Structured detail for errors the UI acts on; `null` for plain errors.
|
||||
#[specta(optional)]
|
||||
pub data: Option<ErrorData>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ErrorData {
|
||||
Tool { tool: String, missing: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -24,6 +33,7 @@ pub enum ErrorKind {
|
||||
/// Document version mismatch; the UI must resync the full text.
|
||||
OutOfSync,
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
/// A required external tool (language server, git, debugger) is unavailable.
|
||||
Unavailable,
|
||||
Io,
|
||||
@@ -33,7 +43,21 @@ pub enum ErrorKind {
|
||||
|
||||
impl IdeError {
|
||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||
Self { kind, message: message.into() }
|
||||
Self { kind, message: message.into(), data: None }
|
||||
}
|
||||
pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
|
||||
if self.data.is_none() {
|
||||
self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false });
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn missing_tool(tool: impl Into<String>) -> Self {
|
||||
let tool = tool.into();
|
||||
Self {
|
||||
kind: ErrorKind::NotFound,
|
||||
message: format!("{tool} is not installed"),
|
||||
data: Some(ErrorData::Tool { tool, missing: true }),
|
||||
}
|
||||
}
|
||||
pub fn not_found(m: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::NotFound, m)
|
||||
@@ -65,3 +89,30 @@ impl From<std::io::Error> for IdeError {
|
||||
Self::new(kind, e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tool_errors_round_trip_without_changing_diagnostics() {
|
||||
let message = "[error] file.css: Unexpected token\n 1 | }\n";
|
||||
let error = IdeError::invalid(message).with_tool("prettier");
|
||||
let decoded: IdeError = serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
|
||||
assert_eq!(decoded.message, message);
|
||||
assert_eq!(decoded.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
let missing = IdeError::missing_tool("prettier").with_tool("prettier");
|
||||
assert_eq!(missing.kind, ErrorKind::NotFound);
|
||||
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_errors_need_no_data() {
|
||||
let error: IdeError = serde_json::from_str(r#"{"kind":"unavailable","message":"no server"}"#).unwrap();
|
||||
assert!(error.data.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(error).unwrap(),
|
||||
serde_json::json!({"kind":"unavailable","message":"no server","data":null})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -508,9 +508,50 @@ pub struct LanguagesSettings {
|
||||
pub rust: RustSettings,
|
||||
pub go: GoSettings,
|
||||
pub typescript: TypeScriptSettings,
|
||||
pub prettier: PrettierSettings,
|
||||
pub java: JavaSettings,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PrettierSettings {
|
||||
/// Empty path: project node_modules, managed installation, then PATH.
|
||||
pub path: String,
|
||||
pub languages: Vec<String>,
|
||||
/// Prefer Prettier even when a language server offers formatting.
|
||||
pub prefer: bool,
|
||||
pub run_on_save: bool,
|
||||
}
|
||||
|
||||
impl Default for PrettierSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: String::new(),
|
||||
languages: [
|
||||
"markdown",
|
||||
"yaml",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"html",
|
||||
"graphql",
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"json",
|
||||
"jsonc",
|
||||
"vue",
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
prefer: false,
|
||||
run_on_save: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PythonSettings {
|
||||
|
||||
@@ -905,6 +905,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
tree_sitter: None,
|
||||
language_servers: vec![],
|
||||
debugger: Some(ide_language::DebuggerConfig {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Select the document's formatting provider before executing it on this host.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::settings::PrettierSettings;
|
||||
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
|
||||
use ide_language::FormatterConfig;
|
||||
use ide_lsp::types::FormatOptions;
|
||||
use ide_tools::{Tools, formatter};
|
||||
|
||||
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
|
||||
config.is_some_and(|c| c.tool == "prettier")
|
||||
&& settings.languages.iter().any(|l| l == language)
|
||||
&& (settings.prefer || !lsp)
|
||||
}
|
||||
|
||||
pub async fn format(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
let project = host.project(window)?;
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let settings = project.settings.effective();
|
||||
let prettier = &settings.languages.prettier;
|
||||
let language = project.languages.get(&snap.language);
|
||||
let config = language.as_ref().and_then(|l| l.formatter.as_ref());
|
||||
let candidate =
|
||||
config.is_some_and(|c| c.tool == "prettier") && prettier.languages.iter().any(|l| l == snap.language.as_str());
|
||||
let lsp = if candidate && !prettier.prefer { project.lsp.can_format(doc).await? } else { false };
|
||||
if !external_selected(config, snap.language.as_str(), prettier, lsp) {
|
||||
if options.on_save && !settings.editor.format_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return project.lsp.format(doc, range, options).await;
|
||||
}
|
||||
if options.on_save && !prettier.run_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
host.require_trust(window)?;
|
||||
if snap.read_only {
|
||||
return Err(IdeError::invalid("The file is read-only"));
|
||||
}
|
||||
let config = config.unwrap().clone();
|
||||
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path);
|
||||
let tools = Tools::new(&host.data_dir);
|
||||
let whole_selection = range.is_some() && !config.range;
|
||||
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
|
||||
let text = ide_text::Document::new(&snap.text);
|
||||
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
|
||||
let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?;
|
||||
command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?);
|
||||
command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets));
|
||||
let formatted = formatter::run(command, &snap.text, &Default::default())?;
|
||||
Ok(text
|
||||
.diff(&formatted)
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| TextEdit {
|
||||
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
|
||||
new_text: c.insert,
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
if edits.is_ok() && whole_selection {
|
||||
project.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Info,
|
||||
title: "Prettier".into(),
|
||||
body: Some(
|
||||
"Prettier does not support selection formatting for this language; formatted the whole file".into(),
|
||||
),
|
||||
});
|
||||
}
|
||||
edits.map_err(|e| e.with_tool("prettier"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn language_enablement_and_preference_preserve_lsp_formatters() {
|
||||
let languages = ide_language::LanguageRegistry::load(None).unwrap();
|
||||
let mut settings = PrettierSettings::default();
|
||||
for lang in languages.all() {
|
||||
for lsp in [false, true] {
|
||||
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
|
||||
assert_eq!(
|
||||
external_selected(lang.formatter.as_ref(), lang.id.as_str(), &settings, lsp),
|
||||
configured && !lsp,
|
||||
"{}",
|
||||
lang.id
|
||||
);
|
||||
}
|
||||
}
|
||||
let ts = languages.get(&ide_core::LanguageId::new("typescript")).unwrap();
|
||||
settings.prefer = true;
|
||||
assert!(external_selected(ts.formatter.as_ref(), "typescript", &settings, true));
|
||||
settings.languages.clear();
|
||||
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
|
||||
assert!(!external_selected(None, "rust", &settings, false));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod environment;
|
||||
mod formatter;
|
||||
mod java_runtime;
|
||||
mod kotlin_bridge;
|
||||
pub mod notebook;
|
||||
|
||||
@@ -214,7 +214,7 @@ pub async fn lsp_format(
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
host.project(window)?.lsp.format(doc, range, options).await
|
||||
crate::formatter::format(host, window, doc, range, options).await
|
||||
}
|
||||
|
||||
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
|
||||
|
||||
@@ -53,6 +53,19 @@ pub struct LanguageConfig {
|
||||
pub language_servers: Vec<LanguageServerConfig>,
|
||||
#[serde(default)]
|
||||
pub debugger: Option<DebuggerConfig>,
|
||||
/// External formatter used when no language server can format, or selected in settings.
|
||||
#[serde(default)]
|
||||
pub formatter: Option<FormatterConfig>,
|
||||
}
|
||||
|
||||
/// An external formatting tool and its language-specific parser.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct FormatterConfig {
|
||||
pub tool: String,
|
||||
pub parser: String,
|
||||
#[serde(default)]
|
||||
pub range: bool,
|
||||
}
|
||||
|
||||
/// A CodeMirror mode for the files of a framework's projects.
|
||||
|
||||
@@ -1886,6 +1886,12 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks initialized capabilities, including dynamic registrations, before choosing a formatter.
|
||||
pub async fn can_format(&self, doc: DocId) -> IdeResult<bool> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
Ok(!self.ready_targets(&snap.servers, |c| c.has("documentFormattingProvider")).await.is_empty())
|
||||
}
|
||||
|
||||
pub async fn format(&self, doc: DocId, range: Option<Range>, options: FormatOptions) -> IdeResult<Vec<TextEdit>> {
|
||||
let (method, cap, feature) = match range {
|
||||
None => ("textDocument/formatting", "documentFormattingProvider", "formatting"),
|
||||
|
||||
@@ -219,6 +219,9 @@ pub struct CodeActionQuery {
|
||||
pub struct FormatOptions {
|
||||
pub tab_size: u32,
|
||||
pub insert_spaces: bool,
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub on_save: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -252,6 +252,7 @@ fn harness_configured(
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
tree_sitter: None,
|
||||
language_servers: servers
|
||||
.iter()
|
||||
@@ -1248,12 +1249,17 @@ async fn navigation_rename_format_and_decorations() {
|
||||
assert!(matches!(&edit.files[0], FileEdit::Edit { edits, .. } if edits.len() == 2));
|
||||
assert_eq!(s.requested("textDocument/rename")[0]["newName"], "bar");
|
||||
|
||||
assert!(h.mgr.can_format(doc).await.unwrap());
|
||||
s.respond("textDocument/formatting", json!([{ "range": r(0,10,0,11), "newText": "\n}" }]));
|
||||
let edits = h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true }).await.unwrap();
|
||||
let edits =
|
||||
h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }).await.unwrap();
|
||||
assert_eq!(edits[0].range, rng(0, 10, 0, 11));
|
||||
assert_eq!(s.requested("textDocument/formatting")[0]["options"], json!({ "tabSize": 2, "insertSpaces": true }));
|
||||
assert!(
|
||||
h.mgr.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true }).await.is_err()
|
||||
h.mgr
|
||||
.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true, on_save: false })
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
s.respond("textDocument/semanticTokens/full", json!({ "data": [0, 3, 3, 0, 1, 1, 0, 3, 0, 0] }));
|
||||
@@ -2737,3 +2743,32 @@ async fn interactive_capability_is_scoped_to_gopls() {
|
||||
assert!(form.is_none());
|
||||
assert!(s.requested("command/resolve").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn formatting_provider_selection_tracks_dynamic_registration() {
|
||||
let h = harness(&[("mock", json!({ "textDocumentSync": 1, "documentRangeFormattingProvider": true }))]);
|
||||
let (doc, _) = h.open("a.mk", "hello\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
assert!(!h.mgr.can_format(doc).await.unwrap(), "a range provider alone cannot format the whole document");
|
||||
let peer = h.launcher.state("mock").peer();
|
||||
peer.request::<_, Value>(
|
||||
"client/registerCapability",
|
||||
json!({"registrations": [
|
||||
{"id": "formatter", "method": "textDocument/formatting"}
|
||||
]}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(h.mgr.can_format(doc).await.unwrap());
|
||||
peer.request::<_, Value>(
|
||||
"client/unregisterCapability",
|
||||
json!({"unregisterations": [
|
||||
{"id": "formatter", "method": "textDocument/formatting"}
|
||||
]}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!h.mgr.can_format(doc).await.unwrap());
|
||||
}
|
||||
@@ -55,6 +55,7 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
tree_sitter: None,
|
||||
language_servers: vec![server],
|
||||
debugger: None,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! External formatting: resolve a tool, pass unsaved text on stdin, return text without writing files.
|
||||
|
||||
use crate::Tools;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_language::FormatterConfig;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Walk from the file's directory upward, including workspace parents (monorepos).
|
||||
pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
|
||||
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
fn package_bin(package: &Path) -> IdeResult<PathBuf> {
|
||||
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
|
||||
.map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?;
|
||||
let bin = json["bin"]
|
||||
.as_str()
|
||||
.or_else(|| json["bin"]["prettier"].as_str())
|
||||
.ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?;
|
||||
let path = package.join(bin);
|
||||
if !path.is_file() {
|
||||
return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display())));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
impl Tools {
|
||||
/// Explicit executable/script, then the nearest project package, then managed tools and PATH.
|
||||
pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
|
||||
self.resolve_formatter_command(tool, file, custom).map_err(|e| e.with_tool(tool))
|
||||
}
|
||||
|
||||
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
|
||||
if tool != "prettier" {
|
||||
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
|
||||
}
|
||||
let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) {
|
||||
(package_bin(custom)?, true)
|
||||
} else if let Some(custom) = custom {
|
||||
(custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs")))
|
||||
} else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) {
|
||||
(package_bin(manifest.parent().unwrap())?, true)
|
||||
} else if self.root.join("node_modules/prettier/package.json").is_file() {
|
||||
(package_bin(&self.root.join("node_modules/prettier"))?, true)
|
||||
} else {
|
||||
(self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false)
|
||||
};
|
||||
if node {
|
||||
let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?;
|
||||
let mut cmd = self.command(node);
|
||||
cmd.arg(path);
|
||||
Ok(cmd)
|
||||
} else {
|
||||
Ok(self.command(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Project configuration overrides these defaults; Prettier resolves config and plugins itself.
|
||||
pub fn arguments(
|
||||
config: &FormatterConfig,
|
||||
file: &Path,
|
||||
tab_size: u32,
|
||||
insert_spaces: bool,
|
||||
range: Option<(u32, u32)>,
|
||||
) -> Vec<OsString> {
|
||||
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
|
||||
"mdx"
|
||||
} else {
|
||||
&config.parser
|
||||
};
|
||||
let mut args: Vec<OsString> = [
|
||||
"--parser",
|
||||
parser,
|
||||
"--config-precedence",
|
||||
"file-override",
|
||||
"--tab-width",
|
||||
&tab_size.max(1).to_string(),
|
||||
"--use-tabs",
|
||||
if insert_spaces { "false" } else { "true" },
|
||||
]
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
|
||||
if config.range
|
||||
&& let Some((start, end)) = range
|
||||
{
|
||||
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
|
||||
}
|
||||
// CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files.
|
||||
for name in [".prettierignore", ".gitignore"] {
|
||||
if let Some(path) = find_up(file, Path::new(name)) {
|
||||
args.extend(["--ignore-path".into(), path.into_os_string()]);
|
||||
}
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_formatter_is_structured_but_invalid_custom_path_is_not_an_install_prompt() {
|
||||
use ide_core::error::{ErrorData, ErrorKind};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tools = Tools::with_paths(dir.path().join("tools"), OsString::new(), crate::platform::Platform::current());
|
||||
let file = dir.path().join("file.md");
|
||||
let missing = tools.formatter_command("prettier", &file, None).unwrap_err();
|
||||
assert_eq!(missing.kind, ErrorKind::NotFound);
|
||||
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
|
||||
let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap();
|
||||
let failure = run(custom, "text", &Default::default()).unwrap_err();
|
||||
assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]);
|
||||
let failure = run(command, "", &Default::default()).unwrap_err();
|
||||
assert_eq!(failure.message, "error\n".repeat(1000));
|
||||
assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_nearest_package_and_ignore_above_nested_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("packages/web/src")).unwrap();
|
||||
std::fs::write(root.join(".prettierignore"), "ignored.md").unwrap();
|
||||
std::fs::create_dir_all(root.join("node_modules/prettier")).unwrap();
|
||||
std::fs::write(root.join("node_modules/prettier/package.json"), "{}").unwrap();
|
||||
let file = root.join("packages/web/src/readme.md");
|
||||
assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join(".prettierignore")));
|
||||
std::fs::write(root.join("packages/web/.prettierignore"), "src/ignored.md").unwrap();
|
||||
assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join("packages/web/.prettierignore")));
|
||||
assert_eq!(
|
||||
find_up(&file, Path::new("node_modules/prettier/package.json")),
|
||||
Some(root.join("node_modules/prettier/package.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_use_stdin_config_precedence_and_only_supported_ranges() {
|
||||
for (parser, supports_range) in [
|
||||
("markdown", false),
|
||||
("css", false),
|
||||
("yaml", false),
|
||||
("typescript", true),
|
||||
("graphql", true),
|
||||
("vue", true),
|
||||
] {
|
||||
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
|
||||
let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
|
||||
let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect();
|
||||
assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"]));
|
||||
assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"]));
|
||||
assert!(args.windows(2).any(|a| a == ["--tab-width", "4"]));
|
||||
assert!(args.windows(2).any(|a| a == ["--use-tabs", "true"]));
|
||||
assert_eq!(args.contains(&"--range-start".into()), supports_range);
|
||||
assert!(!args.contains(&"--write".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
pub mod archive;
|
||||
mod bundled;
|
||||
pub mod formatter;
|
||||
pub mod jdk;
|
||||
pub mod platform;
|
||||
mod process;
|
||||
@@ -323,6 +324,9 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap<String, Usage> {
|
||||
}
|
||||
};
|
||||
for l in langs {
|
||||
if let Some(f) = &l.formatter {
|
||||
add(&f.tool, &f.tool, ToolKind::Formatter, None, &l.name);
|
||||
}
|
||||
for s in &l.language_servers {
|
||||
add(&s.name, &s.command, ToolKind::LanguageServer, s.install_hint.as_ref(), &l.name);
|
||||
}
|
||||
|
||||
@@ -464,3 +464,120 @@ mod tests {
|
||||
assert_eq!(run_quiet(cmd, Duration::from_secs(5)), Some((true, "v1.2.3\n".into())));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a formatter with separate pipes so diagnostics can never become document text.
|
||||
pub(crate) fn run_input(
|
||||
cmd: &mut Command,
|
||||
input: &str,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
use std::io::Write;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let input = input.to_owned();
|
||||
// All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout.
|
||||
enum Pipe {
|
||||
Written(std::io::Result<()>),
|
||||
Read(bool, std::io::Result<Vec<u8>>),
|
||||
}
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes())));
|
||||
});
|
||||
for (is_stderr, mut pipe) in [
|
||||
(false, Box::new(child.stdout.take().unwrap()) as Box<dyn Read + Send>),
|
||||
(true, Box::new(child.stderr.take().unwrap()) as Box<dyn Read + Send>),
|
||||
] {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes)));
|
||||
});
|
||||
}
|
||||
drop(tx);
|
||||
let started = Instant::now();
|
||||
let (mut status, mut output, mut errors, mut written) = (None, None, None, None);
|
||||
let result = loop {
|
||||
if cancel.is_cancelled() {
|
||||
break Err(IdeError::cancelled());
|
||||
}
|
||||
if started.elapsed() >= timeout {
|
||||
break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out"));
|
||||
}
|
||||
if status.is_none() {
|
||||
match child.try_wait() {
|
||||
Ok(s) => status = s,
|
||||
Err(e) => break Err(e.into()),
|
||||
}
|
||||
}
|
||||
if status.is_some() && output.is_some() && errors.is_some() && written.is_some() {
|
||||
break Ok(());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(Pipe::Written(result)) => written = Some(result),
|
||||
Ok(Pipe::Read(true, result)) => errors = Some(result),
|
||||
Ok(Pipe::Read(false, result)) => output = Some(result),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
};
|
||||
if result.is_err() {
|
||||
kill(&mut child);
|
||||
}
|
||||
result?;
|
||||
let output = output.unwrap()?;
|
||||
let errors = errors.unwrap()?;
|
||||
let status = status.unwrap();
|
||||
if !status.success() {
|
||||
let diagnostics: String = String::from_utf8_lossy(&errors).into_owned();
|
||||
return Err(IdeError::invalid(if diagnostics.trim().is_empty() {
|
||||
format!("Formatter exited with {status}")
|
||||
} else {
|
||||
diagnostics
|
||||
}));
|
||||
}
|
||||
written.unwrap()?;
|
||||
String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod input_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn separate_diagnostics_and_never_return_failed_output() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "cat; printf warning >&2"]);
|
||||
assert_eq!(run_input(&mut command, "a😀\n", Duration::from_secs(2), &Default::default()).unwrap(), "a😀\n");
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "printf broken; printf 'invalid syntax' >&2; exit 1"]);
|
||||
let e = run_input(&mut command, "", Duration::from_secs(2), &Default::default()).unwrap_err();
|
||||
assert_eq!(e.message, "invalid syntax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_kills_formatter_and_cancellation_prevents_start() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "sleep 10 &"]);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
run_input(&mut command, "", Duration::from_millis(50), &Default::default()).unwrap_err().kind,
|
||||
ErrorKind::TimedOut
|
||||
);
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
run_input(&mut Command::new("does-not-exist"), "", Duration::from_secs(2), &cancel).unwrap_err().kind,
|
||||
ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub enum ToolKind {
|
||||
Debugger,
|
||||
/// Loaded into another tool's process, e.g. a jdtls agent.
|
||||
Plugin,
|
||||
Formatter,
|
||||
}
|
||||
|
||||
/// Where release archives come from.
|
||||
@@ -105,7 +106,7 @@ impl ToolSpec {
|
||||
|
||||
use Arch::{Arm64, X64};
|
||||
use Os::{Linux, Mac, Windows};
|
||||
use ToolKind::{Debugger, LanguageServer, Plugin};
|
||||
use ToolKind::{Debugger, Formatter, LanguageServer, Plugin};
|
||||
|
||||
const V: Option<(&str, &[&str])> = None;
|
||||
const JAVA: &[(&str, &str)] = &[("java", "Java is required (https://adoptium.net)")];
|
||||
@@ -122,6 +123,17 @@ const fn ver(cmd: &'static str) -> Option<(&'static str, &'static [&'static str]
|
||||
pub const TYPESCRIPT: &str = "typescript@6";
|
||||
|
||||
pub static TOOLS: &[ToolSpec] = &[
|
||||
ToolSpec {
|
||||
name: "prettier",
|
||||
display: "Prettier",
|
||||
kind: Formatter,
|
||||
commands: &["prettier"],
|
||||
method: Method::Npm { packages: &["prettier"] },
|
||||
fallback: None,
|
||||
version: ver("prettier"),
|
||||
needs: NODE,
|
||||
homepage: "https://prettier.io",
|
||||
},
|
||||
ToolSpec {
|
||||
name: "rust-analyzer",
|
||||
display: "rust-analyzer",
|
||||
@@ -943,6 +955,11 @@ mod tests {
|
||||
known.insert((d.adapter.clone(), d.command.clone()));
|
||||
}
|
||||
}
|
||||
for l in &langs {
|
||||
if let Some(f) = &l.formatter {
|
||||
known.insert((f.tool.clone(), f.tool.clone()));
|
||||
}
|
||||
}
|
||||
for t in TOOLS.iter().filter(|t| t.kind != Plugin) {
|
||||
assert!(
|
||||
known.contains(&(t.name.to_string(), t.commands[0].to_string())),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! `cargo test -p ide-tools --test prettier -- --ignored --nocapture`
|
||||
//! Installs into a temporary managed-tools directory; no repository dependency is added.
|
||||
use ide_core::CancellationToken;
|
||||
use ide_language::FormatterConfig;
|
||||
use ide_tools::{Tools, formatter};
|
||||
use std::path::Path;
|
||||
|
||||
fn format(
|
||||
tools: &Tools,
|
||||
path: &Path,
|
||||
parser: &str,
|
||||
text: &str,
|
||||
range: Option<(u32, u32)>,
|
||||
) -> ide_core::IdeResult<String> {
|
||||
let config = FormatterConfig {
|
||||
tool: "prettier".into(),
|
||||
parser: parser.into(),
|
||||
range: matches!(parser, "typescript" | "json" | "graphql"),
|
||||
};
|
||||
let mut cmd = tools.formatter_command("prettier", path, None)?;
|
||||
cmd.current_dir(path.parent().unwrap());
|
||||
cmd.args(formatter::arguments(&config, path, 4, true, range));
|
||||
formatter::run(cmd, text, &CancellationToken::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Node.js/npm and network; installs real Prettier into a temporary managed directory"]
|
||||
fn real_prettier_config_ignore_languages_ranges_and_errors() {
|
||||
if std::env::var_os("OPENIDEA_TEST_OFFLINE").is_some() {
|
||||
eprintln!("SKIP real Prettier: OPENIDEA_TEST_OFFLINE is set; npm network access is disabled");
|
||||
return;
|
||||
}
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let tools = Tools::new(&temp.path().join("app-data"));
|
||||
let info = tools.info("prettier", &[]).unwrap();
|
||||
if !info.can_install {
|
||||
eprintln!("SKIP real Prettier: {}", info.unavailable_reason.unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
tools.install("prettier", &[], &Default::default(), &|_| {}).expect(
|
||||
"Prettier managed install failed; offline environments may set OPENIDEA_TEST_OFFLINE=1 (reported as SKIP)",
|
||||
);
|
||||
let project = temp.path().join("project with spaces");
|
||||
std::fs::create_dir_all(project.join("docs/nested")).unwrap();
|
||||
let md = project.join("docs/nested/readme.md");
|
||||
let input = "# Title\ntext\n\n| a | b |\n|---|---|\n|1|2|\n\n- a\n - b\n";
|
||||
let output = format(&tools, &md, "markdown", input, None).unwrap();
|
||||
assert!(output.starts_with("# Title\n\ntext\n"), "{output}");
|
||||
assert!(output.contains("| 1 | 2 |"), "{output}");
|
||||
assert!(!md.exists(), "formatting must never write the file");
|
||||
std::fs::write(project.join(".prettierrc"), r#"{"proseWrap":"always","printWidth":24,"tabWidth":2}"#).unwrap();
|
||||
let prose =
|
||||
"This is a long paragraph with several words that should wrap according to the project configuration.\n";
|
||||
assert!(format(&tools, &md, "markdown", prose, None).unwrap().lines().count() > 2);
|
||||
std::fs::write(project.join(".prettierignore"), "docs/nested/readme.md\n").unwrap();
|
||||
assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), input);
|
||||
std::fs::remove_file(project.join(".prettierignore")).unwrap();
|
||||
std::fs::remove_file(project.join(".prettierrc")).unwrap();
|
||||
assert_eq!(
|
||||
format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(),
|
||||
"a {\n color: red;\n}\n"
|
||||
);
|
||||
std::fs::write(project.join("package.json"), r#"{"prettier":{"tabWidth":3,"proseWrap":"never"}}"#).unwrap();
|
||||
assert_eq!(
|
||||
format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(),
|
||||
"a {\n color: red;\n}\n"
|
||||
);
|
||||
assert_eq!(format(&tools, &project.join("a.yaml"), "yaml", "a: [1,2]\n", None).unwrap(), "a: [1, 2]\n");
|
||||
let js = "const a={x:1};\nconst b={y:2};\n";
|
||||
let formatted = format(&tools, &project.join("a.ts"), "typescript", js, Some((0, 14))).unwrap();
|
||||
assert!(formatted.starts_with("const a = { x: 1 };"), "{formatted}");
|
||||
assert!(formatted.contains("const b={y:2};"), "{formatted}");
|
||||
assert!(format(&tools, &project.join("bad.css"), "css", "a {", None).is_err());
|
||||
// Nearest project version must win over the managed install, including npm's object-style bin.
|
||||
let package = project.join("node_modules/prettier");
|
||||
std::fs::create_dir_all(&package).unwrap();
|
||||
std::fs::write(package.join("package.json"), r#"{"bin":{"prettier":"cli.cjs"}}"#).unwrap();
|
||||
std::fs::write(
|
||||
package.join("cli.cjs"),
|
||||
"process.stdin.resume(); process.stdin.on('end', () => process.stdout.write('project version\\n')); ",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), "project version\n");
|
||||
std::fs::write(package.join("cli.cjs"), "setInterval(() => {}, 1000)").unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
assert!(format(&tools, &md, "markdown", input, None).unwrap_err().message.contains("timed out"));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(20));
|
||||
eprintln!(
|
||||
"PASS: real managed Prettier, config/ignore discovery, editor defaults, YAML/CSS/Markdown, ranges, errors, project precedence, timeout; no disk writes"
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "css"
|
||||
name = "CSS"
|
||||
formatter = { tool = "prettier", parser = "css", range = false }
|
||||
extensions = ["css", "pcss", "postcss"]
|
||||
block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "graphql"
|
||||
name = "GraphQL"
|
||||
formatter = { tool = "prettier", parser = "graphql", range = true }
|
||||
extensions = ["graphql", "graphqls", "gql"]
|
||||
line-comment = "#"
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "html"
|
||||
name = "HTML"
|
||||
formatter = { tool = "prettier", parser = "html", range = false }
|
||||
extensions = ["html", "htm", "xhtml", "shtml", "html.tmpl"]
|
||||
first-line = "^\\s*<!(?i:doctype)\\s+html"
|
||||
block-comment = ["<!--", "-->"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "javascript"
|
||||
name = "JavaScript"
|
||||
formatter = { tool = "prettier", parser = "babel", range = true }
|
||||
extensions = ["js", "mjs", "cjs"]
|
||||
first-line = "^#!.*\\b(node|nodejs|deno|bun)\\b"
|
||||
line-comment = "//"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "javascriptreact"
|
||||
name = "JavaScript JSX"
|
||||
formatter = { tool = "prettier", parser = "babel", range = true }
|
||||
extensions = ["jsx"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "json"
|
||||
name = "JSON"
|
||||
formatter = { tool = "prettier", parser = "json", range = true }
|
||||
extensions = ["json", "geojson", "topojson", "webmanifest", "har", "avsc", "jsonl", "ndjson", "json.map"]
|
||||
filenames = [".prettierrc", ".watchmanconfig", "composer.lock", ".arcconfig"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "jsonc"
|
||||
name = "JSON with Comments"
|
||||
formatter = { tool = "prettier", parser = "jsonc", range = true }
|
||||
extensions = ["jsonc", "code-workspace", "code-snippets"]
|
||||
filenames = ["tsconfig.json", "jsconfig.json", "tsconfig.*.json", "jsconfig.*.json", ".eslintrc", ".eslintrc.json", ".jshintrc", ".babelrc", ".swcrc", "devcontainer.json", ".devcontainer.json", "launch.json", "tasks.json", "settings.json", "extensions.json", "keybindings.json", "tslint.json", "api-extractor.json", "typedoc.json", "deno.json", "biome.json", "turbo.json", "language-configuration.json"]
|
||||
line-comment = "//"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "less"
|
||||
name = "Less"
|
||||
formatter = { tool = "prettier", parser = "less", range = false }
|
||||
extensions = ["less"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "markdown"
|
||||
name = "Markdown"
|
||||
formatter = { tool = "prettier", parser = "markdown", range = false }
|
||||
extensions = ["md", "markdown", "mdown", "mkd", "mkdn", "mdwn", "mdx", "ronn", "workbook"]
|
||||
block-comment = ["<!--", "-->"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "scss"
|
||||
name = "SCSS"
|
||||
formatter = { tool = "prettier", parser = "scss", range = false }
|
||||
extensions = ["scss"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "typescript"
|
||||
name = "TypeScript"
|
||||
formatter = { tool = "prettier", parser = "typescript", range = true }
|
||||
extensions = ["ts", "mts", "cts"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "typescriptreact"
|
||||
name = "TypeScript JSX"
|
||||
formatter = { tool = "prettier", parser = "typescript", range = true }
|
||||
extensions = ["tsx"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "vue"
|
||||
name = "Vue"
|
||||
formatter = { tool = "prettier", parser = "vue", range = true }
|
||||
extensions = ["vue"]
|
||||
block-comment = ["<!--", "-->"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "yaml"
|
||||
name = "YAML"
|
||||
formatter = { tool = "prettier", parser = "yaml", range = false }
|
||||
extensions = ["yaml", "yml"]
|
||||
filenames = [".clang-format", ".clang-tidy", ".clangd", ".gemrc", "pnpm-lock.yaml", "CITATION.cff"]
|
||||
first-line = "^%YAML"
|
||||
|
||||
+17
-2
@@ -2975,9 +2975,11 @@ export type EnvReloaded = {
|
||||
shellRead: boolean,
|
||||
};
|
||||
|
||||
export type ErrorData = { type: "tool"; tool: string; missing: boolean };
|
||||
|
||||
export type ErrorKind = "notFound" | "alreadyExists" | "permissionDenied" | "invalidInput" |
|
||||
/** Document version mismatch; the UI must resync the full text. */
|
||||
"outOfSync" | "cancelled" |
|
||||
"outOfSync" | "cancelled" | "timedOut" |
|
||||
/** A required external tool (language server, git, debugger) is unavailable. */
|
||||
"unavailable" | "io" | "protocol" | "internal";
|
||||
|
||||
@@ -3207,6 +3209,7 @@ export type FormField = {
|
||||
export type FormatOptions = {
|
||||
tabSize: number,
|
||||
insertSpaces: boolean,
|
||||
onSave?: boolean,
|
||||
};
|
||||
|
||||
/** One occurrence of a duplicated code fragment. */
|
||||
@@ -3543,6 +3546,8 @@ export type HunkRange = {
|
||||
export type IdeError = {
|
||||
kind: ErrorKind,
|
||||
message: string,
|
||||
/** Structured detail for errors the UI acts on; `null` for plain errors. */
|
||||
data?: ErrorData | null,
|
||||
};
|
||||
|
||||
export type IdeEvent =
|
||||
@@ -4028,6 +4033,7 @@ export type LanguagesSettings = {
|
||||
rust?: RustSettings,
|
||||
go?: GoSettings,
|
||||
typescript?: TypeScriptSettings,
|
||||
prettier?: PrettierSettings,
|
||||
java?: JavaSettings,
|
||||
};
|
||||
|
||||
@@ -4771,6 +4777,15 @@ export type PreparedRequest = {
|
||||
body: string | null,
|
||||
};
|
||||
|
||||
export type PrettierSettings = {
|
||||
/** Empty path: project node_modules, managed installation, then PATH. */
|
||||
path?: string,
|
||||
languages?: string[],
|
||||
/** Prefer Prettier even when a language server offers formatting. */
|
||||
prefer?: boolean,
|
||||
runOnSave?: boolean,
|
||||
};
|
||||
|
||||
/** Memory of one row of the breakdown. */
|
||||
export type ProcessMemory = {
|
||||
category: MemoryCategory,
|
||||
@@ -6186,7 +6201,7 @@ export type ToolInfo = {
|
||||
|
||||
export type ToolKind = "languageServer" | "debugger" |
|
||||
/** Loaded into another tool's process, e.g. a jdtls agent. */
|
||||
"plugin";
|
||||
"plugin" | "formatter";
|
||||
|
||||
export type ToolSettings = {
|
||||
/** Executable to launch instead of the managed / `PATH` one; empty = auto-detect. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { executeAction, registerActions, type Action } from "@/platform/actions"
|
||||
import { activeEditor, editorsFor, registerSaveParticipant, type TextEditorHandle } from "@/editor/api";
|
||||
import { maybeEditorService } from "@/workbench/contributions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { validateLanguages } from "@/platform/languageSettings";
|
||||
import { explicitCompletion } from "./completion";
|
||||
import { triggerSignatureHelp } from "./signature";
|
||||
import { quickDefinition, quickDocumentation } from "./hover";
|
||||
@@ -122,8 +123,15 @@ export function registerFormatOnSave(): () => void {
|
||||
id: "lsp.formatOnSave",
|
||||
order: 50,
|
||||
beforeSave: async (ctx, handle) => {
|
||||
if (!handle || ctx.readOnly || !useProject.getState().settings?.editor?.formatOnSave) return;
|
||||
await formatDocument(handle.view, { explicit: false, selectionOnly: false });
|
||||
const settings = useProject.getState().settings;
|
||||
const prettier = validateLanguages(settings?.languages).prettier;
|
||||
if (
|
||||
!handle ||
|
||||
ctx.readOnly ||
|
||||
(!settings?.editor?.formatOnSave && !(prettier.runOnSave && prettier.languages.includes(ctx.language)))
|
||||
)
|
||||
return;
|
||||
await formatDocument(handle.view, { explicit: false, selectionOnly: false, onSave: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.diagnostics {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Text } from "@codemirror/state";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorState, Text } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { CONTRIBUTED_FORMAT } from "@/editor/api";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { Settings } from "@/bindings";
|
||||
import {
|
||||
formatExcluded,
|
||||
guardFormatChanges,
|
||||
@@ -7,7 +11,7 @@ import {
|
||||
protectedRegions,
|
||||
type FormatGuardOptions,
|
||||
} from "@/platform/formatGuard";
|
||||
import { dedentsToColumnZero, formattedLines } from "./format";
|
||||
import { dedentsToColumnZero, formatDocument, formattedLines } from "./format";
|
||||
|
||||
describe("formattedLines", () => {
|
||||
const doc = Text.of(["fn main(){", "let x=1;", "}", "", "fn other() {}"]);
|
||||
@@ -105,3 +109,28 @@ describe("dedentsToColumnZero", () => {
|
||||
expect(dedentsToColumnZero("fun f() {}\n", "fun f() { }\n")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("format on save provider isolation", () => {
|
||||
it("does not enable contributed formatters when only Prettier save is enabled", async () => {
|
||||
const previous = useProject.getState();
|
||||
const formatter = vi.fn(() => [{ from: 0, to: 6, insert: "SELECT" }]);
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "select",
|
||||
extensions: [EditorState.languageData.of(() => [{ [CONTRIBUTED_FORMAT]: formatter }])],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const settings = { editor: { formatOnSave: false }, languages: { prettier: { runOnSave: true } } } as Settings;
|
||||
useProject.setState({ settings });
|
||||
expect((await formatDocument(view, { explicit: false, onSave: true })).applied).toBe(false);
|
||||
expect(formatter).not.toHaveBeenCalled();
|
||||
useProject.setState({ settings: { ...settings, editor: { ...settings.editor, formatOnSave: true } } });
|
||||
expect((await formatDocument(view, { explicit: false, onSave: true })).applied).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe("SELECT");
|
||||
} finally {
|
||||
view.destroy();
|
||||
useProject.setState(previous);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,14 @@
|
||||
* reports "Formatted N lines" with a link to the Reformat File dialog
|
||||
* (⌥⇧⌘L: scope and Optimize Imports).
|
||||
*/
|
||||
import { createElement } from "react";
|
||||
import styles from "./format.module.css";
|
||||
import { indentUnit } from "@codemirror/language";
|
||||
import { isolateHistory } from "@codemirror/commands";
|
||||
import type { EditorState, Text } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { commands } from "@/platform/ipc";
|
||||
import { commands, IpcError } from "@/platform/ipc";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import {
|
||||
@@ -78,14 +81,17 @@ export function formatOptionsOf(state: EditorState): { tabSize: number; insertSp
|
||||
|
||||
export async function formatDocument(
|
||||
view: EditorView,
|
||||
opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true },
|
||||
opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean } = { explicit: true },
|
||||
): Promise<FormatResult> {
|
||||
const result = await format(view, opts);
|
||||
if (opts.explicit && result.applied) formattedHint(view, result.lines);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function format(view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean }): Promise<FormatResult> {
|
||||
async function format(
|
||||
view: EditorView,
|
||||
opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean },
|
||||
): Promise<FormatResult> {
|
||||
const { project, settings } = useProject.getState();
|
||||
const guard = formatGuardOptions(settings);
|
||||
const path = docOf(view)?.path;
|
||||
@@ -95,7 +101,10 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly
|
||||
return NOTHING;
|
||||
}
|
||||
const own = contributedAt<ContributedFormatter>(view.state, CONTRIBUTED_FORMAT, 0);
|
||||
if (own) return applyContributedFormat(view, own, opts, guard);
|
||||
if (own) {
|
||||
if (opts.onSave && !settings?.editor?.formatOnSave) return NOTHING;
|
||||
return applyContributedFormat(view, own, opts, guard);
|
||||
}
|
||||
const doc = docOf(view);
|
||||
if (!doc || doc.readOnly) return NOTHING;
|
||||
// The server formats the backend's copy: let pending keystrokes reach it first.
|
||||
@@ -107,9 +116,25 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly
|
||||
const options = formatOptionsOf(state);
|
||||
let edits;
|
||||
try {
|
||||
edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options));
|
||||
edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave }));
|
||||
} catch (e) {
|
||||
reportLspError(view, e, opts.explicit);
|
||||
if (e instanceof IpcError && e.data?.type === "tool" && e.data.tool === "prettier") {
|
||||
if (e.kind === "notFound" && e.data.missing) {
|
||||
await executeAction("tools.promptInstall", { name: "prettier", sticky: !opts.onSave });
|
||||
} else if (e.kind !== "cancelled") {
|
||||
notifications().notify({
|
||||
group: "tools",
|
||||
level: "error",
|
||||
title: tx("Prettier formatting failed"),
|
||||
body:
|
||||
e.kind === "timedOut"
|
||||
? tx("Prettier formatting timed out")
|
||||
: createElement("span", { className: styles.diagnostics }, e.message),
|
||||
key: `prettier-format-error:${doc.path}`,
|
||||
sticky: false,
|
||||
});
|
||||
}
|
||||
} else reportLspError(view, e, opts.explicit);
|
||||
return NOTHING;
|
||||
}
|
||||
if (view.state.doc !== state.doc) {
|
||||
@@ -179,7 +204,7 @@ export async function formatLines(view: EditorView, from: number, to: number): P
|
||||
function applyContributedFormat(
|
||||
view: EditorView,
|
||||
format: ContributedFormatter,
|
||||
opts: { explicit: boolean; selectionOnly?: boolean },
|
||||
opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean },
|
||||
guard: FormatGuardOptions,
|
||||
): FormatResult {
|
||||
const state = view.state;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { IdeError } from "@/bindings";
|
||||
import { registerAction } from "@/platform/actions";
|
||||
import { _resetI18nForTests, setLocale } from "@/platform/i18n";
|
||||
import { IpcError } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { provideNotificationService } from "@/workbench/contributions";
|
||||
import { Balloons } from "@/workbench/Notifications";
|
||||
import {
|
||||
balloonTimeout,
|
||||
clearNotifications,
|
||||
notificationServiceImpl,
|
||||
useNotifications,
|
||||
} from "@/workbench/notificationStore";
|
||||
import { formatDocument } from "./format";
|
||||
import * as lsp from "./lsp";
|
||||
|
||||
const tool = { type: "tool", tool: "prettier", missing: false } as const;
|
||||
const views: EditorView[] = [];
|
||||
const viewFor = (path = "/project/file.css") => {
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: ".card {",
|
||||
extensions: [lsp.lspDoc.of({ doc: 1, path, language: "css", uiMode: null, readOnly: false })],
|
||||
}),
|
||||
});
|
||||
views.push(view);
|
||||
return view;
|
||||
};
|
||||
const failWith = (error: IdeError) => vi.spyOn(lsp, "lspRequest").mockRejectedValue(new IpcError(error));
|
||||
|
||||
beforeEach(() => {
|
||||
clearNotifications();
|
||||
provideNotificationService(notificationServiceImpl);
|
||||
});
|
||||
afterEach(() => {
|
||||
views.splice(0).forEach((view) => view.destroy());
|
||||
clearNotifications();
|
||||
vi.restoreAllMocks();
|
||||
_resetI18nForTests();
|
||||
});
|
||||
|
||||
describe("external formatter errors", () => {
|
||||
it("uses structured missing-tool information to request the shared install prompt", async () => {
|
||||
const prompt = vi.fn();
|
||||
const dispose = registerAction({
|
||||
id: "tools.promptInstall",
|
||||
title: "Install Missing Tool",
|
||||
run: ({ arg }) => prompt(arg),
|
||||
});
|
||||
try {
|
||||
failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } });
|
||||
await formatDocument(viewFor());
|
||||
expect(prompt).toHaveBeenCalledWith({ name: "prettier", sticky: true });
|
||||
await formatDocument(viewFor(), { explicit: false, onSave: true });
|
||||
expect(prompt).toHaveBeenLastCalledWith({ name: "prettier", sticky: false });
|
||||
expect(useNotifications.getState().items).toHaveLength(0);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat text alone or a broken custom executable as a missing installation", async () => {
|
||||
const prompt = vi.fn();
|
||||
const dispose = registerAction({
|
||||
id: "tools.promptInstall",
|
||||
title: "Install Missing Tool",
|
||||
run: ({ arg }) => prompt(arg),
|
||||
});
|
||||
try {
|
||||
const request = failWith({ kind: "unavailable", message: "Prettier is not installed" });
|
||||
const view = viewFor();
|
||||
await formatDocument(view, { explicit: false });
|
||||
expect(useNotifications.getState().items).toHaveLength(0);
|
||||
request.mockRejectedValue(new IpcError({ kind: "notFound", message: "custom executable missing", data: tool }));
|
||||
await formatDocument(view);
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
expect(useNotifications.getState().items).toHaveLength(1);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps stderr untranslated in a Chinese notification and replaces failures per file, including on save", async () => {
|
||||
await setLocale("zh");
|
||||
const previous = useProject.getState().settings;
|
||||
useProject.setState({ settings: null });
|
||||
try {
|
||||
const stderr = "Formatting timed out";
|
||||
failWith({ kind: "invalidInput", message: stderr, data: tool });
|
||||
const view = viewFor();
|
||||
for (let i = 0; i < 3; i++) await formatDocument(view, { explicit: false, onSave: true });
|
||||
const notes = useNotifications.getState().items;
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toMatchObject({ sticky: false, title: "Prettier 格式化失败" });
|
||||
expect(balloonTimeout(notes[0]!)).toBe(10000);
|
||||
render(<Balloons />);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(stderr);
|
||||
expect(screen.queryByText("格式化超时")).toBeNull();
|
||||
expect(view.state.doc.toString()).toBe(".card {");
|
||||
await act(async () => {
|
||||
await formatDocument(viewFor("/project/other.css"));
|
||||
});
|
||||
expect(useNotifications.getState().items).toHaveLength(2);
|
||||
} finally {
|
||||
useProject.setState({ settings: previous });
|
||||
}
|
||||
});
|
||||
|
||||
it("localizes the timeout explanation by kind and ignores cancellation", async () => {
|
||||
await setLocale("zh");
|
||||
const request = failWith({ kind: "timedOut", message: "arbitrary backend text", data: tool });
|
||||
const view = viewFor();
|
||||
await formatDocument(view);
|
||||
expect(useNotifications.getState().items[0]).toMatchObject({ body: "Prettier 格式化超时", sticky: false });
|
||||
clearNotifications();
|
||||
request.mockRejectedValue(new IpcError({ kind: "cancelled", message: "cancelled", data: tool }));
|
||||
await formatDocument(view);
|
||||
expect(useNotifications.getState().items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,16 @@ describe("languages settings", () => {
|
||||
expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES);
|
||||
});
|
||||
|
||||
it("keeps Prettier opt-in on save and normalizes its enabled languages", () => {
|
||||
expect(validateLanguages({}).prettier).toMatchObject({ prefer: false, runOnSave: false, path: "" });
|
||||
expect(
|
||||
validateLanguages({
|
||||
prettier: { path: " ./bin/prettier ", languages: ["markdown", 4, "markdown"], prefer: true, runOnSave: true },
|
||||
}).prettier,
|
||||
).toEqual({ path: "./bin/prettier", languages: ["markdown"], prefer: true, runOnSave: true });
|
||||
expect(validateLanguages({ prettier: { languages: [] } }).prettier.languages).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks folders with one role each", () => {
|
||||
expect(normalizeFolder("./src/main/")).toBe("src/main");
|
||||
let ps = validateProjectStructure({ sourceRoots: ["src/", "src", 3], excluded: ["/dist"] });
|
||||
@@ -58,6 +68,7 @@ describe("language pages", () => {
|
||||
"Java",
|
||||
"Node.js",
|
||||
"Python Interpreter",
|
||||
"Prettier",
|
||||
"Rust",
|
||||
"TypeScript",
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useId } from "react";
|
||||
import { Input } from "@/ui";
|
||||
import { Row, Section, Select, Toggle } from "../components/Form";
|
||||
import { useSetting } from "../components/context";
|
||||
import { DEFAULT_LANGUAGES } from "@/platform/languageSettings";
|
||||
|
||||
const names: Record<string, string> = {
|
||||
markdown: "Markdown",
|
||||
yaml: "YAML",
|
||||
css: "CSS",
|
||||
scss: "SCSS",
|
||||
less: "Less",
|
||||
html: "HTML",
|
||||
graphql: "GraphQL",
|
||||
javascript: "JavaScript",
|
||||
javascriptreact: "JavaScript JSX",
|
||||
typescript: "TypeScript",
|
||||
typescriptreact: "TypeScript JSX",
|
||||
json: "JSON",
|
||||
jsonc: "JSON with Comments",
|
||||
vue: "Vue",
|
||||
};
|
||||
|
||||
export function PrettierPage() {
|
||||
const id = useId();
|
||||
const [path, setPath] = useSetting<string>("languages.prettier.path");
|
||||
const [languages, setLanguages] = useSetting<string[]>("languages.prettier.languages");
|
||||
const [prefer, setPrefer] = useSetting<boolean>("languages.prettier.prefer");
|
||||
const [save, setSave] = useSetting<boolean>("languages.prettier.runOnSave");
|
||||
return (
|
||||
<>
|
||||
<Section title="Prettier">
|
||||
<Row
|
||||
label="Prettier path"
|
||||
htmlFor={id}
|
||||
hint="Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection."
|
||||
>
|
||||
<Input id={id} mono value={path} placeholder="Auto" onChange={(e) => setPath(e.target.value)} />
|
||||
</Row>
|
||||
<Row label="Formatting provider">
|
||||
<Select
|
||||
value={prefer ? "prettier" : "auto"}
|
||||
onChange={(v) => setPrefer(v === "prettier")}
|
||||
aria-label="Formatting provider"
|
||||
options={[
|
||||
{ value: "auto", label: "Automatic (language server first)" },
|
||||
{ value: "prettier", label: "Prettier" },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Toggle
|
||||
label="Run on save"
|
||||
checked={save}
|
||||
onChange={setSave}
|
||||
hint="Run Prettier on save for enabled languages when it is the selected formatting provider."
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Enabled languages">
|
||||
{DEFAULT_LANGUAGES.prettier.languages.map((language) => (
|
||||
<Toggle
|
||||
key={language}
|
||||
label={names[language]!}
|
||||
checked={languages.includes(language)}
|
||||
onChange={(checked) =>
|
||||
setLanguages(checked ? [...languages, language] : languages.filter((l) => l !== language))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
* `../languages/`; settings are `languages.*` / `projectStructure.*`.
|
||||
*/
|
||||
import { GoPage, JavaPage, NodePage, RustPage, TypeScriptPage } from "../languages/LanguagePages";
|
||||
import { PrettierPage } from "../languages/PrettierPage";
|
||||
import { ProjectStructurePage } from "../languages/ProjectStructurePage";
|
||||
import { PythonInterpreterPage } from "../languages/PythonInterpreterPage";
|
||||
import { GoBuildTagsPage, GO_BUILD_TAG_SECTIONS } from "../languages/GoBuildTags";
|
||||
@@ -107,6 +108,16 @@ export const LANGUAGE_PAGES: PageSpec[] = [
|
||||
keywords: ["node interpreter", "package manager", "npm", "pnpm", "yarn", "bun", "nvm"],
|
||||
component: NodePage,
|
||||
},
|
||||
{
|
||||
id: "languages.prettier",
|
||||
projectScope: true,
|
||||
defaultScope: "project",
|
||||
title: "Prettier",
|
||||
group: LANGUAGES_GROUP,
|
||||
order: 403.5,
|
||||
keywords: ["formatter", "prettier", "markdown", "yaml", "css", "format on save", "node_modules"],
|
||||
component: PrettierPage,
|
||||
},
|
||||
{
|
||||
id: "languages.rust",
|
||||
projectScope: true,
|
||||
|
||||
@@ -39,6 +39,7 @@ const KIND_LABEL: Record<ToolInfo["kind"], string> = {
|
||||
languageServer: "Language server",
|
||||
debugger: "Debugger",
|
||||
plugin: "Plugin",
|
||||
formatter: "Formatter",
|
||||
};
|
||||
|
||||
export function filterTools(tools: readonly ToolInfo[], query: string, filter: Filter): ToolInfo[] {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useTasks } from "@/workbench/tasks";
|
||||
import { MemoryToolsBackend, sampleTools, setToolsBackend } from "../backend";
|
||||
import { cancelInstall, installTool, refreshTools, uninstallTool, useTools } from "../store";
|
||||
import { filterTools, LanguageServersPage } from "../LanguageServersPage";
|
||||
import { installMissingServerPrompts, missingKey } from "../missing";
|
||||
import { installMissingServerPrompts, missingKey, promptInstall } from "../missing";
|
||||
import { resetTestForm, stagedFor, TestSettingsForm } from "./settingsForm";
|
||||
|
||||
type Note = Parameters<NotificationService["notify"]>[0];
|
||||
@@ -104,6 +104,34 @@ describe("tools store", () => {
|
||||
});
|
||||
|
||||
describe("missing language server prompt", () => {
|
||||
it("uses the same install prompt for Prettier with a Reformat Code explanation", async () => {
|
||||
const template = sampleTools()[0]!;
|
||||
backend.tools.push({
|
||||
...template,
|
||||
name: "prettier",
|
||||
displayName: "Prettier",
|
||||
command: "prettier",
|
||||
kind: "formatter",
|
||||
status: "missing",
|
||||
installMethod: "npm",
|
||||
languages: ["Markdown"],
|
||||
});
|
||||
await promptInstall("prettier");
|
||||
expect(notes[0]).toMatchObject({
|
||||
title: "Prettier is not installed",
|
||||
key: missingKey("prettier"),
|
||||
sticky: true,
|
||||
body: "Reformat Code needs Prettier. Install it via npm?",
|
||||
});
|
||||
expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Install", "Language Servers…"]);
|
||||
await promptInstall("prettier", false);
|
||||
expect(notes.at(-1)).toMatchObject({ key: missingKey("prettier"), sticky: false });
|
||||
notes[0]!.actions![0]!.run();
|
||||
await waitFor(() =>
|
||||
expect(notes.at(-1)).toMatchObject({ title: "Prettier installed", key: missingKey("prettier") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Install for installable servers, once, replacing the generic notice", async () => {
|
||||
const off = installMissingServerPrompts();
|
||||
const ev = { type: "languageServer", server: 1, name: "gopls", status: "missing", message: null } as const;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { lazyComponent } from "@/ui";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { registerSettingsPage } from "@/workbench/contributions";
|
||||
import { installJdkPrompts, registerJdkActions } from "./jdk";
|
||||
import { installMissingServerPrompts } from "./missing";
|
||||
import { installMissingServerPrompts, promptInstall } from "./missing";
|
||||
import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName, useTools } from "./store";
|
||||
|
||||
/** The Language Servers page (also used by the Settings window). */
|
||||
@@ -40,6 +40,16 @@ export function activate(): () => void {
|
||||
const disposers = [
|
||||
activateSettingsPages(),
|
||||
registerActions([
|
||||
{
|
||||
id: "tools.promptInstall",
|
||||
title: "Install Missing Tool",
|
||||
hidden: true,
|
||||
run: async ({ arg }) => {
|
||||
if (arg && typeof arg === "object" && "name" in arg && typeof arg.name === "string") {
|
||||
await promptInstall(arg.name, !("sticky" in arg) || arg.sticky !== false);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tools.languageServers",
|
||||
title: "Language Servers…",
|
||||
|
||||
@@ -12,7 +12,7 @@ import { tx } from "@/platform/i18n";
|
||||
|
||||
export const missingKey = (name: string) => `lsp-missing-${name}`;
|
||||
|
||||
export async function promptInstall(name: string): Promise<boolean> {
|
||||
export async function promptInstall(name: string, sticky = true): Promise<boolean> {
|
||||
if (!useTools.getState().loaded) await refreshTools();
|
||||
const tool = toolByName(name);
|
||||
if (!tool || !tool.canInstall || tool.status !== "missing") return false;
|
||||
@@ -26,16 +26,19 @@ export async function promptInstall(name: string): Promise<boolean> {
|
||||
t.status !== "missing" &&
|
||||
t.languages.some((l) => tool.languages.includes(l)),
|
||||
);
|
||||
if (served) return false;
|
||||
if (tool.kind !== "formatter" && served) return false;
|
||||
const key = missingKey(name);
|
||||
const langs = tool.languages.length ? tool.languages.join(", ") : "these files";
|
||||
notifications().notify({
|
||||
group: "tools",
|
||||
level: "warning",
|
||||
title: `${tool.displayName} is not installed`,
|
||||
body: tx`Code insight for ${langs} needs ${tool.command}. Install it via ${tool.installMethod}?`,
|
||||
title: tx`${tool.displayName} is not installed`,
|
||||
body:
|
||||
tool.kind === "formatter"
|
||||
? tx`Reformat Code needs ${tool.displayName}. Install it via ${tool.installMethod}?`
|
||||
: tx`Code insight for ${langs} needs ${tool.command}. Install it via ${tool.installMethod}?`,
|
||||
key,
|
||||
sticky: true,
|
||||
sticky,
|
||||
actions: [
|
||||
{ label: "Install", run: () => void installTool(name, { notificationKey: key }) },
|
||||
{ label: "Language Servers…", run: () => void executeAction("file.settings", SETTINGS_PAGE_ID) },
|
||||
|
||||
@@ -14,10 +14,12 @@ export type * from "@/bindings";
|
||||
|
||||
export class IpcError extends Error {
|
||||
readonly kind: IdeError["kind"];
|
||||
readonly data: IdeError["data"];
|
||||
constructor(error: IdeError) {
|
||||
super(error.message);
|
||||
this.name = "IpcError";
|
||||
this.kind = error.kind;
|
||||
this.data = error.data;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface LanguagesValid {
|
||||
modules: { env: Record<string, string>; autoVendor: boolean };
|
||||
};
|
||||
typescript: { version: string };
|
||||
prettier: { path: string; languages: string[]; prefer: boolean; runOnSave: boolean };
|
||||
java: {
|
||||
jdk: string;
|
||||
jdtlsJdk: string;
|
||||
@@ -80,6 +81,27 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
|
||||
rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" },
|
||||
go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } },
|
||||
typescript: { version: "bundled" },
|
||||
prettier: {
|
||||
path: "",
|
||||
languages: [
|
||||
"markdown",
|
||||
"yaml",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"html",
|
||||
"graphql",
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"json",
|
||||
"jsonc",
|
||||
"vue",
|
||||
],
|
||||
prefer: false,
|
||||
runOnSave: false,
|
||||
},
|
||||
java: {
|
||||
jdk: "",
|
||||
jdtlsJdk: "",
|
||||
@@ -142,6 +164,7 @@ export function validateLanguages(raw: unknown): LanguagesValid {
|
||||
const tags = obj(go.buildTags);
|
||||
const modules = obj(go.modules);
|
||||
const ts = obj(l.typescript);
|
||||
const prettier = obj(l.prettier);
|
||||
const java = obj(l.java);
|
||||
const maven = obj(java.maven);
|
||||
const gradle = obj(java.gradle);
|
||||
@@ -177,6 +200,14 @@ export function validateLanguages(raw: unknown): LanguagesValid {
|
||||
},
|
||||
},
|
||||
typescript: { version: text(ts.version, d.typescript.version) || d.typescript.version },
|
||||
prettier: {
|
||||
path: text(prettier.path, d.prettier.path),
|
||||
languages: Array.isArray(prettier.languages)
|
||||
? [...new Set(prettier.languages.filter((v): v is string => typeof v === "string"))]
|
||||
: d.prettier.languages,
|
||||
prefer: bool(prettier.prefer, d.prettier.prefer),
|
||||
runOnSave: bool(prettier.runOnSave, d.prettier.runOnSave),
|
||||
},
|
||||
java: {
|
||||
jdk: text(java.jdk, d.java.jdk),
|
||||
jdtlsJdk: text(java.jdtlsJdk, d.java.jdtlsJdk),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"Kotlin bridge requires a Maven build root": "Kotlin 桥接需要 Maven 构建根目录",
|
||||
"Kotlin bridge bundle is unavailable": "Kotlin 桥接扩展不可用",
|
||||
"Kotlin bridge command did not acknowledge its library snapshot": "Kotlin 桥接命令未确认类库更新",
|
||||
"Maven compilation failed": "Maven 编译失败",
|
||||
"Kotlin editor compilation failed": "Kotlin 编辑器编译失败",
|
||||
"Updating results…": "正在更新结果…",
|
||||
"Uploading files": "正在上传文件",
|
||||
@@ -1601,6 +1600,27 @@
|
||||
"Refactor This…": "重构此项…",
|
||||
"Refactoring undone": "重构已撤消",
|
||||
"Reformat Code": "重新格式化代码",
|
||||
"Prettier path": "Prettier 路径",
|
||||
"Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection.": "自动:依次查找最近的项目 node_modules、受管安装和 PATH。指定路径将覆盖自动检测。",
|
||||
"Formatting provider": "格式化工具",
|
||||
"Automatic (language server first)": "自动(优先语言服务器)",
|
||||
"Run on save": "保存时运行",
|
||||
"Run Prettier on save for enabled languages when it is the selected formatting provider.": "对已启用的语言,在选择 Prettier 作为格式化工具时于保存时运行。",
|
||||
"Enabled languages": "启用的语言",
|
||||
"Reformat Code needs {name}. Install it via {method}?": "重新格式化代码需要 {name}。是否通过 {method} 安装?",
|
||||
"Install Missing Tool": "安装缺失的工具",
|
||||
"Prettier formatting failed": "Prettier 格式化失败",
|
||||
"Prettier formatting timed out": "Prettier 格式化超时",
|
||||
"Prettier does not support selection formatting for this language; formatted the whole file": "Prettier 不支持此语言的选区格式化,已格式化整个文件",
|
||||
"Formatting timed out": "格式化超时",
|
||||
"Prettier: Node.js is required": "Prettier:需要 Node.js",
|
||||
"Formatter": "格式化工具",
|
||||
"JSON with Comments": "带注释的 JSON",
|
||||
"Unknown external formatter: {tool}": "未知的外部格式化工具:{tool}",
|
||||
"Prettier: package has no CLI entry point": "Prettier:软件包未提供命令行入口",
|
||||
"Prettier: CLI does not exist: {path}": "Prettier:命令行程序不存在:{path}",
|
||||
"Prettier: file has no directory": "Prettier:文件路径不含目录",
|
||||
"Prettier: {error}": "Prettier:{error}",
|
||||
"Reformat File": "重新格式化文件",
|
||||
"Reformat code": "重新格式化代码",
|
||||
"Reformat code on save": "保存时重新格式化代码",
|
||||
|
||||
@@ -79,6 +79,8 @@ const BUILT_AT_RUNTIME = new Set<string>([]);
|
||||
|
||||
/** Text that stays as written in every locale: names, code, formats, shortcuts. */
|
||||
const UNTRANSLATED = new Set<string>([
|
||||
// Product names
|
||||
"Prettier",
|
||||
// Go environment variables and placeholder values
|
||||
"CGO",
|
||||
"GOPROXY",
|
||||
|
||||
@@ -148,6 +148,17 @@ describe("notifications", () => {
|
||||
expect(useNotifications.getState().items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("explicitly non-sticky errors fade after the ordinary balloon timeout", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(true);
|
||||
render(<Balloons />);
|
||||
act(() => void notify({ level: "error", title: "Formatting failed", sticky: false }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Formatting failed");
|
||||
act(() => vi.advanceTimersByTime(BALLOON_TIMEOUT.info! + 10));
|
||||
expect(screen.queryByText("Formatting failed")).toBeNull();
|
||||
expect(useNotifications.getState().items[0]).toMatchObject({ sticky: false, balloon: false });
|
||||
});
|
||||
|
||||
it("sticky balloons fade into the tool window after 5 minutes, staying unread", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(true);
|
||||
|
||||
@@ -117,8 +117,8 @@ export const STICKY_BALLOON_TIMEOUT = 5 * 60 * 1000;
|
||||
|
||||
/** Fade-out delay of a balloon, or null when it stays until dismissed. */
|
||||
export function balloonTimeout(n: Pick<IdeNotification, "level" | "sticky" | "logged">): number | null {
|
||||
if (n.sticky || BALLOON_TIMEOUT[n.level] === null) return n.logged ? STICKY_BALLOON_TIMEOUT : null;
|
||||
return BALLOON_TIMEOUT[n.level];
|
||||
if (n.sticky) return n.logged ? STICKY_BALLOON_TIMEOUT : null;
|
||||
return BALLOON_TIMEOUT[n.level] ?? BALLOON_TIMEOUT.info;
|
||||
}
|
||||
const MAX_HISTORY = 200;
|
||||
let seq = 0;
|
||||
|
||||
Reference in new issue
Block a user