From ec5193f827d5a91ff78daf54f6a142aa19c8b499 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:54:31 +0800 Subject: [PATCH] feat(format): external formatting with managed Prettier (#671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ide-core/src/error.rs | 53 +++++- crates/ide-core/src/settings.rs | 41 +++++ crates/ide-dap/src/tests.rs | 1 + crates/ide-host/src/formatter.rs | 107 +++++++++++ crates/ide-host/src/lib.rs | 1 + crates/ide-host/src/ops/lsp.rs | 2 +- crates/ide-language/src/lib.rs | 13 ++ crates/ide-lsp/src/lib.rs | 6 + crates/ide-lsp/src/types.rs | 3 + crates/ide-lsp/tests/mock_server.rs | 39 +++- crates/ide-lsp/tests/real_servers.rs | 1 + crates/ide-tools/src/formatter.rs | 174 ++++++++++++++++++ crates/ide-tools/src/lib.rs | 4 + crates/ide-tools/src/process.rs | 117 ++++++++++++ crates/ide-tools/src/registry.rs | 19 +- crates/ide-tools/tests/prettier.rs | 91 +++++++++ languages/css.toml | 1 + languages/graphql.toml | 1 + languages/html.toml | 1 + languages/javascript.toml | 1 + languages/javascriptreact.toml | 1 + languages/json.toml | 1 + languages/jsonc.toml | 1 + languages/less.toml | 1 + languages/markdown.toml | 1 + languages/scss.toml | 1 + languages/typescript.toml | 1 + languages/typescriptreact.toml | 1 + languages/vue.toml | 1 + languages/yaml.toml | 1 + src/bindings.ts | 19 +- src/editor/lsp/actions.ts | 12 +- src/editor/lsp/format.module.css | 3 + src/editor/lsp/format.test.ts | 35 +++- src/editor/lsp/format.ts | 39 +++- src/editor/lsp/formatErrors.test.tsx | 124 +++++++++++++ .../settings/__tests__/languages.test.ts | 11 ++ .../settings/languages/PrettierPage.tsx | 72 ++++++++ src/features/settings/pages/languages.tsx | 11 ++ src/features/tools/LanguageServersPage.tsx | 1 + src/features/tools/__tests__/tools.test.tsx | 30 ++- src/features/tools/index.ts | 12 +- src/features/tools/missing.ts | 13 +- src/platform/ipc.ts | 2 + src/platform/languageSettings.ts | 31 ++++ src/platform/locales/text/zh.json | 22 ++- src/platform/locales/textCatalog.test.ts | 2 + src/workbench/Workbench.test.tsx | 11 ++ src/workbench/notificationStore.ts | 4 +- 49 files changed, 1111 insertions(+), 29 deletions(-) create mode 100644 crates/ide-host/src/formatter.rs create mode 100644 crates/ide-tools/src/formatter.rs create mode 100644 crates/ide-tools/tests/prettier.rs create mode 100644 src/editor/lsp/format.module.css create mode 100644 src/editor/lsp/formatErrors.test.tsx create mode 100644 src/features/settings/languages/PrettierPage.tsx diff --git a/crates/ide-core/src/error.rs b/crates/ide-core/src/error.rs index 7df81c9a..f78cf37f 100644 --- a/crates/ide-core/src/error.rs +++ b/crates/ide-core/src/error.rs @@ -12,6 +12,15 @@ pub type IdeResult = Result; 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, +} + +#[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) -> Self { - Self { kind, message: message.into() } + Self { kind, message: message.into(), data: None } + } + pub fn with_tool(mut self, tool: impl Into) -> Self { + if self.data.is_none() { + self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false }); + } + self + } + pub fn missing_tool(tool: impl Into) -> 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) -> Self { Self::new(ErrorKind::NotFound, m) @@ -65,3 +89,30 @@ impl From 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}) + ); + } +} diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 1b49cd81..7f360b1f 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -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, + /// 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 { diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 6bcd57b6..e1fb0eca 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -905,6 +905,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc, 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, + options: FormatOptions, +) -> IdeResult> { + 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> = 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)); + } +} diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 0d310d47..5937b972 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -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; diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index f729f798..b57f54ef 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -214,7 +214,7 @@ pub async fn lsp_format( range: Option, options: FormatOptions, ) -> IdeResult> { - 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> { diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index fc935338..a42587cc 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -53,6 +53,19 @@ pub struct LanguageConfig { pub language_servers: Vec, #[serde(default)] pub debugger: Option, + /// External formatter used when no language server can format, or selected in settings. + #[serde(default)] + pub formatter: Option, +} + +/// 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. diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 7091c69a..52145184 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -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 { + 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, options: FormatOptions) -> IdeResult> { let (method, cap, feature) = match range { None => ("textDocument/formatting", "documentFormattingProvider", "formatting"), diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index beaee53d..050d493d 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -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)] diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index aa15d3ed..a636e5e4 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -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()); +} diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 13ab9667..43c250a2 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -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, diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs new file mode 100644 index 00000000..9b599f0b --- /dev/null +++ b/crates/ide-tools/src/formatter.rs @@ -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 { + file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file()) +} + +fn package_bin(package: &Path) -> IdeResult { + 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 { + 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 { + 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 { + let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { + "mdx" + } else { + &config.parser + }; + let mut args: Vec = [ + "--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 { + 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())); + } + } +} diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 51272bf6..1822367f 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -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 { } }; 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); } diff --git a/crates/ide-tools/src/process.rs b/crates/ide-tools/src/process.rs index cd3e1578..fad1f755 100644 --- a/crates/ide-tools/src/process.rs +++ b/crates/ide-tools/src/process.rs @@ -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 { + 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>), + } + 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), + (true, Box::new(child.stderr.take().unwrap()) as Box), + ] { + 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 + ); + } +} diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index d23f6db3..5c05cbe7 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -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())), diff --git a/crates/ide-tools/tests/prettier.rs b/crates/ide-tools/tests/prettier.rs new file mode 100644 index 00000000..3d622caa --- /dev/null +++ b/crates/ide-tools/tests/prettier.rs @@ -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 { + 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" + ); +} diff --git a/languages/css.toml b/languages/css.toml index 81379d5b..4f8ba1e1 100644 --- a/languages/css.toml +++ b/languages/css.toml @@ -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 } diff --git a/languages/graphql.toml b/languages/graphql.toml index d3a7a926..a26bb6ac 100644 --- a/languages/graphql.toml +++ b/languages/graphql.toml @@ -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 } diff --git a/languages/html.toml b/languages/html.toml index 9da5321d..908771a2 100644 --- a/languages/html.toml +++ b/languages/html.toml @@ -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*"] diff --git a/languages/javascript.toml b/languages/javascript.toml index edbd83d8..b5676537 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -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 = "//" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index f2864cdf..3c5a1776 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -1,5 +1,6 @@ id = "javascriptreact" name = "JavaScript JSX" +formatter = { tool = "prettier", parser = "babel", range = true } extensions = ["jsx"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/json.toml b/languages/json.toml index bad60726..f7010dff 100644 --- a/languages/json.toml +++ b/languages/json.toml @@ -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 } diff --git a/languages/jsonc.toml b/languages/jsonc.toml index 272f5c95..d90e5a65 100644 --- a/languages/jsonc.toml +++ b/languages/jsonc.toml @@ -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 = "//" diff --git a/languages/less.toml b/languages/less.toml index 227590f9..cfc834ee 100644 --- a/languages/less.toml +++ b/languages/less.toml @@ -1,5 +1,6 @@ id = "less" name = "Less" +formatter = { tool = "prettier", parser = "less", range = false } extensions = ["less"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/markdown.toml b/languages/markdown.toml index 9fec3835..675ac34f 100644 --- a/languages/markdown.toml +++ b/languages/markdown.toml @@ -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 } diff --git a/languages/scss.toml b/languages/scss.toml index 1fd1d0d5..dcdaadd4 100644 --- a/languages/scss.toml +++ b/languages/scss.toml @@ -1,5 +1,6 @@ id = "scss" name = "SCSS" +formatter = { tool = "prettier", parser = "scss", range = false } extensions = ["scss"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/typescript.toml b/languages/typescript.toml index e5f231bc..41817c5c 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -1,5 +1,6 @@ id = "typescript" name = "TypeScript" +formatter = { tool = "prettier", parser = "typescript", range = true } extensions = ["ts", "mts", "cts"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index 0a5069c7..ad238c6c 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -1,5 +1,6 @@ id = "typescriptreact" name = "TypeScript JSX" +formatter = { tool = "prettier", parser = "typescript", range = true } extensions = ["tsx"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/vue.toml b/languages/vue.toml index a7cdade0..e260bce7 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -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 } diff --git a/languages/yaml.toml b/languages/yaml.toml index 76d55067..17e64e1b 100644 --- a/languages/yaml.toml +++ b/languages/yaml.toml @@ -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" diff --git a/src/bindings.ts b/src/bindings.ts index 5c4e5a3b..c3d5811c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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. */ diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts index 44ca7765..f774f677 100644 --- a/src/editor/lsp/actions.ts +++ b/src/editor/lsp/actions.ts @@ -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 }); }, }); } diff --git a/src/editor/lsp/format.module.css b/src/editor/lsp/format.module.css new file mode 100644 index 00000000..54460af1 --- /dev/null +++ b/src/editor/lsp/format.module.css @@ -0,0 +1,3 @@ +.diagnostics { + white-space: pre-wrap; +} diff --git a/src/editor/lsp/format.test.ts b/src/editor/lsp/format.test.ts index 75c31b42..8f21dd7c 100644 --- a/src/editor/lsp/format.test.ts +++ b/src/editor/lsp/format.test.ts @@ -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); + } + }); +}); diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 1e37e155..061e377c 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -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 { 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 { +async function format( + view: EditorView, + opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean }, +): Promise { 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(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; diff --git a/src/editor/lsp/formatErrors.test.tsx b/src/editor/lsp/formatErrors.test.tsx new file mode 100644 index 00000000..aa21c997 --- /dev/null +++ b/src/editor/lsp/formatErrors.test.tsx @@ -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(); + 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); + }); +}); diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 0fd9ac55..5864b058 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -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", ]); diff --git a/src/features/settings/languages/PrettierPage.tsx b/src/features/settings/languages/PrettierPage.tsx new file mode 100644 index 00000000..ea20c7b0 --- /dev/null +++ b/src/features/settings/languages/PrettierPage.tsx @@ -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 = { + 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("languages.prettier.path"); + const [languages, setLanguages] = useSetting("languages.prettier.languages"); + const [prefer, setPrefer] = useSetting("languages.prettier.prefer"); + const [save, setSave] = useSetting("languages.prettier.runOnSave"); + return ( + <> +
+ + setPath(e.target.value)} /> + + +