mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat: add oxfmt/oxlint support for JS and TS projects (#717)
Below an oxfmt configuration (.oxfmtrc.json/.jsonc, oxfmt.config.ts/.mts) Reformat Code runs oxfmt instead of Prettier, with the same external-formatter settings; oxfmt formats whole files, so a selection formats the file with a notice. oxlint --lsp serves JS/TS/React/Vue/Svelte files below an oxlint config. Both are managed npm tools; a missing oxfmt gets the same Install prompt and error handling as Prettier, and formatter messages are translated for either tool. Co-authored-by: d0whc3r <1378986+d0whc3r@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,16 @@ use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
|
||||
use ide_language::FormatterConfig;
|
||||
use ide_lsp::types::FormatOptions;
|
||||
use ide_tools::{Tools, formatter};
|
||||
use std::path::Path;
|
||||
|
||||
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
|
||||
|
||||
/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier
|
||||
/// settings (languages, preference, on save) apply to both.
|
||||
fn external_tool(file: &Path) -> &'static str {
|
||||
let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
|
||||
if oxfmt { "oxfmt" } else { "prettier" }
|
||||
}
|
||||
|
||||
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
|
||||
config.is_some_and(|c| c.tool == "prettier")
|
||||
@@ -44,16 +54,19 @@ pub async fn format(
|
||||
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 tool = external_tool(&snap.path);
|
||||
let name = formatter::display_name(tool);
|
||||
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier");
|
||||
let tools = Tools::new(&host.local_data_dir);
|
||||
let whole_selection = range.is_some() && !config.range;
|
||||
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
|
||||
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())?;
|
||||
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
|
||||
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
|
||||
command.current_dir(dir);
|
||||
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
|
||||
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
|
||||
Ok(text
|
||||
.diff(&formatted)
|
||||
.changes
|
||||
@@ -69,13 +82,13 @@ pub async fn format(
|
||||
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(),
|
||||
),
|
||||
title: name.into(),
|
||||
body: Some(format!(
|
||||
"{name} does not support selection formatting for this language; formatted the whole file"
|
||||
)),
|
||||
});
|
||||
}
|
||||
edits.map_err(|e| e.with_tool("prettier"))
|
||||
edits.map_err(|e| e.with_tool(tool))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -104,4 +117,19 @@ mod tests {
|
||||
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
|
||||
assert!(!external_selected(None, "rust", &settings, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oxfmt_config_selects_oxfmt_over_prettier() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("web/src")).unwrap();
|
||||
let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts"));
|
||||
assert_eq!(external_tool(&web), "prettier");
|
||||
for config in OXFMT_CONFIGS {
|
||||
std::fs::write(root.join("web").join(config), "{}").unwrap();
|
||||
assert_eq!(external_tool(&web), "oxfmt", "{config}");
|
||||
assert_eq!(external_tool(&api), "prettier", "{config}");
|
||||
std::fs::remove_file(root.join("web").join(config)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,7 +576,7 @@ mod tests {
|
||||
};
|
||||
// After the TypeScript fallback (an available server before it would hide
|
||||
// it); before the HTML server (hover, rename and symbols take the first).
|
||||
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]);
|
||||
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]);
|
||||
assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]);
|
||||
for lang in ["typescript", "html"] {
|
||||
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
|
||||
@@ -586,6 +586,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oxlint_serves_js_files_only_below_its_config() {
|
||||
let reg = reg();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("apps/web/src")).unwrap();
|
||||
std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap();
|
||||
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] {
|
||||
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
|
||||
let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap();
|
||||
assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice()));
|
||||
assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}");
|
||||
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers);
|
||||
assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}");
|
||||
assert_eq!(served("apps/api/main.ts"), None, "{lang}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_of_angular_projects_is_an_angular_template() {
|
||||
let reg = reg();
|
||||
|
||||
@@ -13,20 +13,30 @@ 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> {
|
||||
pub fn display_name(tool: &str) -> &str {
|
||||
crate::registry::find(tool).map_or(tool, |t| t.display)
|
||||
}
|
||||
|
||||
fn package_bin(package: &Path, tool: &str) -> IdeResult<PathBuf> {
|
||||
let name = display_name(tool);
|
||||
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
|
||||
.map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?;
|
||||
.map_err(|e| IdeError::invalid(format!("{name}: {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"))?;
|
||||
.or_else(|| json["bin"][tool].as_str())
|
||||
.ok_or_else(|| IdeError::invalid(format!("{name}: 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())));
|
||||
return Err(IdeError::unavailable(format!("{name}: CLI does not exist: {}", path.display())));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only.
|
||||
pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool {
|
||||
tool == "prettier" && config.range
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -34,22 +44,26 @@ impl Tools {
|
||||
}
|
||||
|
||||
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
|
||||
if tool != "prettier" {
|
||||
if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) {
|
||||
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
|
||||
}
|
||||
let package = Path::new("node_modules").join(tool);
|
||||
let manifest = package.join("package.json");
|
||||
let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) {
|
||||
(package_bin(custom)?, true)
|
||||
(package_bin(custom, tool)?, 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 if let Some(manifest) = find_up(file, &manifest) {
|
||||
(package_bin(manifest.parent().unwrap(), tool)?, true)
|
||||
} else if self.root.join(&manifest).is_file() {
|
||||
(package_bin(&self.root.join(&package), tool)?, true)
|
||||
} else {
|
||||
(self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false)
|
||||
(self.which(tool).ok_or_else(|| IdeError::missing_tool(tool))?, false)
|
||||
};
|
||||
if node {
|
||||
let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?;
|
||||
let name = display_name(tool);
|
||||
let node =
|
||||
self.which("node").ok_or_else(|| IdeError::unavailable(format!("{name}: Node.js is required")))?;
|
||||
let mut cmd = self.command(node);
|
||||
cmd.arg(path);
|
||||
Ok(cmd)
|
||||
@@ -59,34 +73,40 @@ impl Tools {
|
||||
}
|
||||
}
|
||||
|
||||
/// Project configuration overrides these defaults; Prettier resolves config and plugins itself.
|
||||
/// Both tools resolve their configuration and plugins themselves. Prettier's
|
||||
/// project configuration overrides the editor indentation passed here; oxfmt has
|
||||
/// no such flags and reads its configuration and `.editorconfig` only.
|
||||
pub fn arguments(
|
||||
tool: &str,
|
||||
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();
|
||||
let mut args: Vec<OsString> = Vec::new();
|
||||
if tool == "prettier" {
|
||||
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
|
||||
"mdx"
|
||||
} else {
|
||||
&config.parser
|
||||
};
|
||||
args.extend(
|
||||
[
|
||||
"--parser",
|
||||
parser,
|
||||
"--config-precedence",
|
||||
"file-override",
|
||||
"--tab-width",
|
||||
&tab_size.max(1).to_string(),
|
||||
"--use-tabs",
|
||||
if insert_spaces { "false" } else { "true" },
|
||||
]
|
||||
.map(OsString::from),
|
||||
);
|
||||
}
|
||||
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
|
||||
if config.range
|
||||
if supports_range(tool, config)
|
||||
&& let Some((start, end)) = range
|
||||
{
|
||||
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
|
||||
@@ -100,8 +120,8 @@ pub fn arguments(
|
||||
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"))
|
||||
pub fn run(tool: &str, 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(tool))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -118,7 +138,7 @@ mod tests {
|
||||
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();
|
||||
let failure = run("prettier", custom, "text", &Default::default()).unwrap_err();
|
||||
assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
}
|
||||
|
||||
@@ -127,7 +147,7 @@ mod tests {
|
||||
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();
|
||||
let failure = run("prettier", 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 }));
|
||||
}
|
||||
@@ -161,7 +181,8 @@ mod tests {
|
||||
("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 =
|
||||
arguments("prettier", &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"]));
|
||||
@@ -171,4 +192,36 @@ mod tests {
|
||||
assert!(!args.contains(&"--write".into()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oxfmt_runs_the_nearest_package_without_prettier_flags() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("node_modules/oxfmt/bin")).unwrap();
|
||||
std::fs::write(root.join("node_modules/oxfmt/package.json"), r#"{"bin":{"oxfmt":"bin/oxfmt"}}"#).unwrap();
|
||||
std::fs::write(root.join("node_modules/oxfmt/bin/oxfmt"), "").unwrap();
|
||||
std::fs::write(root.join(".prettierignore"), "dist").unwrap();
|
||||
let file = root.join("packages/web/src/app.ts");
|
||||
let path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let tools = Tools::with_paths(root.join("tools"), path, crate::platform::Platform::current());
|
||||
|
||||
let command = tools.formatter_command("oxfmt", &file, None).unwrap();
|
||||
let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned();
|
||||
assert_eq!(program, "node");
|
||||
let script: Vec<_> = command.get_args().collect();
|
||||
assert_eq!(script, [root.join("node_modules/oxfmt/bin/oxfmt").as_os_str()]);
|
||||
|
||||
let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true };
|
||||
let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9)));
|
||||
let ignore = root.join(".prettierignore").into_os_string();
|
||||
assert_eq!(
|
||||
args[..4],
|
||||
["--stdin-filepath".into(), file.clone().into_os_string(), "--ignore-path".into(), ignore]
|
||||
);
|
||||
assert!(args[4..].chunks(2).all(|pair| pair[0] == "--ignore-path"), "{args:?}");
|
||||
assert!(!supports_range("oxfmt", &config));
|
||||
|
||||
let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err();
|
||||
assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,28 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
needs: NODE,
|
||||
homepage: "https://prettier.io",
|
||||
},
|
||||
ToolSpec {
|
||||
name: "oxfmt",
|
||||
display: "oxfmt",
|
||||
kind: Formatter,
|
||||
commands: &["oxfmt"],
|
||||
method: Method::Npm { packages: &["oxfmt"] },
|
||||
fallback: None,
|
||||
version: ver("oxfmt"),
|
||||
needs: NODE,
|
||||
homepage: "https://oxc.rs/docs/guide/usage/formatter",
|
||||
},
|
||||
ToolSpec {
|
||||
name: "oxlint",
|
||||
display: "oxlint",
|
||||
kind: LanguageServer,
|
||||
commands: &["oxlint"],
|
||||
method: Method::Npm { packages: &["oxlint"] },
|
||||
fallback: None,
|
||||
version: ver("oxlint"),
|
||||
needs: NODE,
|
||||
homepage: "https://oxc.rs/docs/guide/usage/linter",
|
||||
},
|
||||
ToolSpec {
|
||||
name: "rust-analyzer",
|
||||
display: "rust-analyzer",
|
||||
@@ -965,12 +987,8 @@ 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) {
|
||||
// The host selects the formatter of each file (Prettier or oxfmt).
|
||||
for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) {
|
||||
assert!(
|
||||
known.contains(&(t.name.to_string(), t.commands[0].to_string())),
|
||||
"{} / {} is not referenced by languages/*.toml",
|
||||
|
||||
@@ -19,8 +19,8 @@ fn format(
|
||||
};
|
||||
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())
|
||||
cmd.args(formatter::arguments("prettier", &config, path, 4, true, range));
|
||||
formatter::run("prettier", cmd, text, &CancellationToken::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,6 +27,14 @@ args = ["--stdio"]
|
||||
root-markers = ["jsconfig.json", "tsconfig.json", "package.json"]
|
||||
install-hint = "npm i -g typescript@6 typescript-language-server"
|
||||
|
||||
[[language-servers]]
|
||||
name = "oxlint"
|
||||
command = "oxlint"
|
||||
args = ["--lsp"]
|
||||
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
|
||||
require-root-marker = true
|
||||
install-hint = "npm i -D oxlint"
|
||||
|
||||
[debugger]
|
||||
adapter = "js-debug"
|
||||
command = "js-debug-adapter"
|
||||
|
||||
@@ -26,6 +26,14 @@ args = ["--stdio"]
|
||||
root-markers = ["jsconfig.json", "tsconfig.json", "package.json"]
|
||||
install-hint = "npm i -g typescript@6 typescript-language-server"
|
||||
|
||||
[[language-servers]]
|
||||
name = "oxlint"
|
||||
command = "oxlint"
|
||||
args = ["--lsp"]
|
||||
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
|
||||
require-root-marker = true
|
||||
install-hint = "npm i -D oxlint"
|
||||
|
||||
[debugger]
|
||||
adapter = "js-debug"
|
||||
command = "js-debug-adapter"
|
||||
|
||||
@@ -13,3 +13,11 @@ command = "svelteserver"
|
||||
args = ["--stdio"]
|
||||
root-markers = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts", "package.json"]
|
||||
install-hint = "npm i -g svelte-language-server"
|
||||
|
||||
[[language-servers]]
|
||||
name = "oxlint"
|
||||
command = "oxlint"
|
||||
args = ["--lsp"]
|
||||
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
|
||||
require-root-marker = true
|
||||
install-hint = "npm i -D oxlint"
|
||||
|
||||
@@ -37,6 +37,14 @@ root-markers = ["angular.json", "node_modules/@angular/core"]
|
||||
require-root-marker = true
|
||||
install-hint = "npm i -g @angular/language-server"
|
||||
|
||||
[[language-servers]]
|
||||
name = "oxlint"
|
||||
command = "oxlint"
|
||||
args = ["--lsp"]
|
||||
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
|
||||
require-root-marker = true
|
||||
install-hint = "npm i -D oxlint"
|
||||
|
||||
[debugger]
|
||||
adapter = "js-debug"
|
||||
command = "js-debug-adapter"
|
||||
|
||||
@@ -26,6 +26,14 @@ args = ["--stdio"]
|
||||
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
|
||||
install-hint = "npm i -g typescript@6 typescript-language-server"
|
||||
|
||||
[[language-servers]]
|
||||
name = "oxlint"
|
||||
command = "oxlint"
|
||||
args = ["--lsp"]
|
||||
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
|
||||
require-root-marker = true
|
||||
install-hint = "npm i -D oxlint"
|
||||
|
||||
[debugger]
|
||||
adapter = "js-debug"
|
||||
command = "js-debug-adapter"
|
||||
|
||||
@@ -22,3 +22,11 @@ command = "vtsls"
|
||||
args = ["--stdio"]
|
||||
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
|
||||
install-hint = "npm i -g @vtsls/language-server"
|
||||
|
||||
[[language-servers]]
|
||||
name = "oxlint"
|
||||
command = "oxlint"
|
||||
args = ["--lsp"]
|
||||
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
|
||||
require-root-marker = true
|
||||
install-hint = "npm i -D oxlint"
|
||||
|
||||
@@ -118,19 +118,22 @@ async function format(
|
||||
try {
|
||||
edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave }));
|
||||
} catch (e) {
|
||||
if (e instanceof IpcError && e.data?.type === "tool" && e.data.tool === "prettier") {
|
||||
if (e instanceof IpcError && e.data?.type === "tool" && (e.data.tool === "prettier" || e.data.tool === "oxfmt")) {
|
||||
const tool = e.data.tool;
|
||||
if (e.kind === "notFound" && e.data.missing) {
|
||||
await executeAction("tools.promptInstall", { name: "prettier", sticky: !opts.onSave });
|
||||
await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave });
|
||||
} else if (e.kind !== "cancelled") {
|
||||
notifications().notify({
|
||||
group: "tools",
|
||||
level: "error",
|
||||
title: tx("Prettier formatting failed"),
|
||||
title: tool === "oxfmt" ? tx("oxfmt formatting failed") : tx("Prettier formatting failed"),
|
||||
body:
|
||||
e.kind === "timedOut"
|
||||
? tx("Prettier formatting timed out")
|
||||
? tool === "oxfmt"
|
||||
? tx("oxfmt formatting timed out")
|
||||
: tx("Prettier formatting timed out")
|
||||
: createElement("span", { className: styles.diagnostics }, e.message),
|
||||
key: `prettier-format-error:${doc.path}`,
|
||||
key: `${tool}-format-error:${doc.path}`,
|
||||
sticky: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
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({
|
||||
@@ -43,7 +42,9 @@ afterEach(() => {
|
||||
_resetI18nForTests();
|
||||
});
|
||||
|
||||
describe("external formatter errors", () => {
|
||||
describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => {
|
||||
const tool = { type: "tool", tool: name, missing: false } as const;
|
||||
const display = name === "prettier" ? "Prettier" : name;
|
||||
it("uses structured missing-tool information to request the shared install prompt", async () => {
|
||||
const prompt = vi.fn();
|
||||
const dispose = registerAction({
|
||||
@@ -54,9 +55,9 @@ describe("external formatter errors", () => {
|
||||
try {
|
||||
failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } });
|
||||
await formatDocument(viewFor());
|
||||
expect(prompt).toHaveBeenCalledWith({ name: "prettier", sticky: true });
|
||||
expect(prompt).toHaveBeenCalledWith({ name, sticky: true });
|
||||
await formatDocument(viewFor(), { explicit: false, onSave: true });
|
||||
expect(prompt).toHaveBeenLastCalledWith({ name: "prettier", sticky: false });
|
||||
expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false });
|
||||
expect(useNotifications.getState().items).toHaveLength(0);
|
||||
} finally {
|
||||
dispose();
|
||||
@@ -95,7 +96,7 @@ describe("external formatter errors", () => {
|
||||
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(notes[0]).toMatchObject({ sticky: false, title: `${display} 格式化失败` });
|
||||
expect(balloonTimeout(notes[0]!)).toBe(10000);
|
||||
render(<Balloons />);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(stderr);
|
||||
@@ -115,7 +116,7 @@ describe("external formatter errors", () => {
|
||||
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 });
|
||||
expect(useNotifications.getState().items[0]).toMatchObject({ body: `${display} 格式化超时`, sticky: false });
|
||||
clearNotifications();
|
||||
request.mockRejectedValue(new IpcError({ kind: "cancelled", message: "cancelled", data: tool }));
|
||||
await formatDocument(view);
|
||||
|
||||
@@ -1614,16 +1614,18 @@
|
||||
"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 不支持此语言的选区格式化,已格式化整个文件",
|
||||
"oxfmt formatting failed": "oxfmt 格式化失败",
|
||||
"oxfmt formatting timed out": "oxfmt 格式化超时",
|
||||
"{name} does not support selection formatting for this language; formatted the whole file": "{name} 不支持此语言的选区格式化,已格式化整个文件",
|
||||
"Formatting timed out": "格式化超时",
|
||||
"Prettier: Node.js is required": "Prettier:需要 Node.js",
|
||||
"{name}: Node.js is required": "{name}:需要 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}",
|
||||
"{name}: package has no CLI entry point": "{name}:软件包未提供命令行入口",
|
||||
"{name}: CLI does not exist: {path}": "{name}:命令行程序不存在:{path}",
|
||||
"{name}: file has no directory": "{name}:文件路径不含目录",
|
||||
"{name}: {error}": "{name}:{error}",
|
||||
"Reformat File": "重新格式化文件",
|
||||
"Reformat code": "重新格式化代码",
|
||||
"Reformat code on save": "保存时重新格式化代码",
|
||||
|
||||
Reference in New Issue
Block a user