diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 405761d4..205a4676 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -601,7 +601,7 @@ impl Default for FileSettings { } } -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct LanguagesSettings { pub python: PythonSettings, @@ -612,11 +612,34 @@ pub struct LanguagesSettings { pub typescript: TypeScriptSettings, pub prettier: ExternalFormatterSettings, pub oxfmt: ExternalFormatterSettings, + pub ruff: ExternalFormatterSettings, pub java: JavaSettings, pub ruby: RubySettings, pub markdown: MarkdownSettings, } +impl Default for LanguagesSettings { + fn default() -> Self { + Self { + python: PythonSettings::default(), + node: NodeSettings::default(), + php: PhpSettings::default(), + rust: RustSettings::default(), + go: GoSettings::default(), + typescript: TypeScriptSettings::default(), + prettier: ExternalFormatterSettings::default(), + oxfmt: ExternalFormatterSettings::default(), + ruff: ExternalFormatterSettings { + languages: vec!["python".into()], + ..ExternalFormatterSettings::default() + }, + java: JavaSettings::default(), + ruby: RubySettings::default(), + markdown: MarkdownSettings::default(), + } + } +} + /// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] @@ -630,6 +653,7 @@ impl LanguagesSettings { match tool { ExternalFormatter::Prettier => &self.prettier, ExternalFormatter::Oxfmt => &self.oxfmt, + ExternalFormatter::Ruff => &self.ruff, } } } @@ -640,6 +664,7 @@ impl LanguagesSettings { pub enum ExternalFormatter { Prettier, Oxfmt, + Ruff, } impl ExternalFormatter { @@ -648,16 +673,19 @@ impl ExternalFormatter { match self { Self::Prettier => "prettier", Self::Oxfmt => "oxfmt", + Self::Ruff => "ruff", } } } -/// An external formatter (Prettier, oxfmt) and the languages it formats. +/// An external formatter (Prettier, oxfmt, Ruff) and the languages it formats. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct ExternalFormatterSettings { - /// Empty path: project node_modules, managed installation, then PATH. + /// Empty path: project node_modules (Prettier/oxfmt), managed installation, then PATH. pub path: String, + /// Ruff only: directory containing its configuration, or a configuration file. + pub configuration_directory: String, pub languages: Vec, /// Prefer this formatter even when a language server offers formatting. pub prefer: bool, @@ -668,6 +696,7 @@ impl Default for ExternalFormatterSettings { fn default() -> Self { Self { path: String::new(), + configuration_directory: String::new(), languages: [ "markdown", "yaml", diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index 59e9ffea..3e6862dc 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -3,7 +3,7 @@ use crate::Host; use ide_core::event::NotifyLevel; use ide_core::settings::{ExternalFormatter, LanguagesSettings}; -use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit}; +use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Pos, Range, TextEdit}; use ide_language::LanguageConfig; use ide_lsp::types::FormatOptions; use ide_tools::{Tools, formatter}; @@ -11,20 +11,27 @@ use std::path::Path; const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"]; +fn supports_external_format(language: &LanguageConfig, tool: ExternalFormatter) -> bool { + match language.formatter.as_ref().map(|f| f.tool.as_str()) { + Some("prettier") => matches!(tool, ExternalFormatter::Prettier | ExternalFormatter::Oxfmt), + Some("ruff") => tool == ExternalFormatter::Ruff, + _ => false, + } +} + /// The external formatter of a file, for a language with an external formatter. -/// `forced` names the tool (Reformat with Prettier/oxfmt). Otherwise oxfmt -/// applies when it is enabled for the language and either an oxfmt -/// configuration is found above the file or oxfmt is the selected provider; -/// else Prettier, when it is enabled. Turning Prettier off for a language -/// leaves it to the language server, not to oxfmt. +/// `forced` names the tool (Reformat with Prettier, oxfmt, or Ruff). Ruff is +/// the base formatter for Python; oxfmt can replace Prettier for its languages +/// when configured or selected. A tool that cannot format the language is +/// rejected even when explicitly requested. fn external_tool( forced: Option, language: Option<&LanguageConfig>, file: &Path, settings: &LanguagesSettings, ) -> IdeResult> { - use ExternalFormatter::{Oxfmt, Prettier}; - let Some(language) = language.filter(|l| l.formatter.as_ref().is_some_and(|f| f.tool == "prettier")) else { + use ExternalFormatter::{Oxfmt, Prettier, Ruff}; + let Some(language) = language.filter(|l| l.formatter.is_some()) else { return match forced { Some(tool) => Err(IdeError::invalid(format!( "{} cannot format {} files", @@ -34,10 +41,21 @@ fn external_tool( None => Ok(None), }; }; - if forced.is_some() { - return Ok(forced); + if let Some(tool) = forced { + return if supports_external_format(language, tool) { + Ok(Some(tool)) + } else { + Err(IdeError::invalid(format!( + "{} cannot format {} files", + formatter::display_name(tool.name()), + language.name + ))) + }; } let enabled = |tool| settings.formatter(tool).languages.iter().any(|l| l == language.id.as_str()); + if language.formatter.as_ref().is_some_and(|f| f.tool == "ruff") { + return Ok(enabled(Ruff).then_some(Ruff)); + } let oxfmt_config = || OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some()); Ok(if enabled(Oxfmt) && (settings.oxfmt.prefer || oxfmt_config()) { Some(Oxfmt) @@ -46,6 +64,34 @@ fn external_tool( }) } +fn codepoint_col(line: &str, utf16_col: u32) -> u32 { + let mut units = 0; + for (index, ch) in line.chars().enumerate() { + if units >= utf16_col { + return index as u32; + } + units += ch.len_utf16() as u32; + } + line.chars().count() as u32 +} + +fn format_range(text: &ide_text::Document, range: Range) -> formatter::FormatRange { + let position = |pos: Pos| { + let line = text.line(pos.line).unwrap_or_default(); + (pos.line + 1, codepoint_col(&line, pos.col) + 1) + }; + let (start_line, start_col) = position(range.start); + let (end_line, end_col) = position(range.end); + formatter::FormatRange { + start_offset: text.pos_to_offset(range.start), + end_offset: text.pos_to_offset(range.end), + start_line, + start_col, + end_line, + end_col, + } +} + pub async fn format( host: &Host, window: &str, @@ -85,13 +131,28 @@ pub async fn format( let custom = ide_sdk::apply::setting_path(&project.root, &tool_settings.path); let tools = Tools::new(&host.local_data_dir); let whole_selection = range.is_some() && !formatter::supports_range(tool, &config); + let project_root = project.root.clone(); + let configuration_directory = settings.languages.ruff.configuration_directory.clone(); 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 range = range.map(|range| format_range(&text, range)); + let config_file = if tool == "ruff" { + ide_sdk::apply::ruff_configuration(&project_root, &configuration_directory)? + } else { + None + }; 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)); + command.args(formatter::arguments( + tool, + &config, + &snap.path, + options.tab_size, + options.insert_spaces, + range, + config_file.as_deref(), + )); let formatted = formatter::run(tool, command, &snap.text, &Default::default())?; Ok(text .diff(&formatted) @@ -120,7 +181,7 @@ pub async fn format( #[cfg(test)] mod tests { use super::*; - use ExternalFormatter::{Oxfmt, Prettier}; + use ExternalFormatter::{Oxfmt, Prettier, Ruff}; use ide_core::LanguageId; use ide_language::LanguageRegistry; @@ -135,17 +196,36 @@ mod tests { } #[test] - fn default_settings_give_every_external_formatter_language_to_prettier() { + fn default_settings_use_ruff_for_python_and_prettier_for_the_web() { let settings = LanguagesSettings::default(); let file = Path::new("/project/file"); for lang in LanguageRegistry::load(None).unwrap().all() { - let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier"); + let configured = match lang.formatter.as_ref().map(|f| f.tool.as_str()) { + Some("prettier") => Some(Prettier), + Some("ruff") => Some(Ruff), + _ => None, + }; let selected = external_tool(None, Some(&lang), file, &settings).unwrap(); - assert_eq!(selected, configured.then_some(Prettier), "{}", lang.id); + assert_eq!(selected, configured, "{}", lang.id); } assert_eq!(external_tool(None, None, file, &settings).unwrap(), None); } + #[test] + fn ruff_is_the_python_external_formatter_and_is_opt_out() { + let file = Path::new("/project/src/example.py"); + let mut settings = LanguagesSettings::default(); + assert_eq!(tool(None, "python", file, &settings).unwrap(), Some(Ruff)); + assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff)); + settings.ruff.languages.clear(); + assert_eq!(tool(None, "python", file, &settings).unwrap(), None); + assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff)); + let prettier = tool(Some(Prettier), "python", file, &settings).unwrap_err(); + assert_eq!(prettier.message, "Prettier cannot format Python files"); + let oxfmt = tool(Some(Oxfmt), "python", file, &settings).unwrap_err(); + assert_eq!(oxfmt.message, "oxfmt cannot format Python files"); + } + #[test] fn oxfmt_config_selects_oxfmt_for_its_enabled_languages() { let dir = tempfile::tempdir().unwrap(); @@ -192,4 +272,21 @@ mod tests { let rust = tool(Some(Oxfmt), "rust", &dir.path().join("main.rs"), &settings).unwrap_err(); assert_eq!(rust.message, "oxfmt cannot format Rust files"); } + + #[test] + fn ruff_ranges_are_one_based_unicode_columns() { + let text = ide_text::Document::new("😀x = 1\ny = 2\n"); + let range = Range::new(Pos::new(0, 2), Pos::new(1, 2)); + assert_eq!( + format_range(&text, range), + formatter::FormatRange { + start_offset: 2, + end_offset: 10, + start_line: 1, + start_col: 2, + end_line: 2, + end_col: 3, + } + ); + } } diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 2ee3a07e..91e1d6e8 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -7,6 +7,7 @@ use crate::{angular, godot, pinned, vue}; use crate::{bin_dir, exe, venv_of}; use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings}; use ide_core::version_managers::Tool; +use ide_core::{IdeError, IdeResult}; use ide_language::{LanguageConfig, LanguageServerConfig}; use ide_run::RunConfig; use serde_json::{Value, json}; @@ -28,6 +29,39 @@ pub fn setting_path(root: &Path, s: &str) -> Option { Some(if p.is_absolute() { p } else { root.join(p) }) } +fn toml_has_table(file: &Path, table: &str) -> bool { + let Ok(text) = std::fs::read_to_string(file) else { return false }; + let Ok(value) = toml::from_str::(&text) else { return false }; + table.split('.').try_fold(&value, |value, key| value.get(key)).is_some_and(toml::Value::is_table) +} + +/// The Ruff configuration file selected by `languages.ruff.configurationDirectory`. +/// A file can be chosen directly; a directory resolves `ruff.toml`, `.ruff.toml`, +/// then a `pyproject.toml` containing `[tool.ruff]`. +pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult> { + let Some(path) = setting_path(root, configured) else { return Ok(None) }; + if path.is_file() { + return Ok(Some(path)); + } + if !path.is_dir() { + return Err(IdeError::invalid(format!("Ruff configuration path does not exist: {}", path.display()))); + } + for name in ["ruff.toml", ".ruff.toml"] { + let file = path.join(name); + if file.is_file() { + return Ok(Some(file)); + } + } + let pyproject = path.join("pyproject.toml"); + if toml_has_table(&pyproject, "tool.ruff") { + return Ok(Some(pyproject)); + } + Err(IdeError::invalid(format!( + "Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {}", + path.display() + ))) +} + /// The project interpreter: the configured one, else a project virtualenv /// (`.venv`, `venv`, …) in the project root, else the Python the project pins for mise / asdf. pub fn python_interpreter(root: &Path, s: &Settings) -> Option { @@ -575,6 +609,18 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root } } + if name == "ruff" { + match ruff_configuration(root, &s.languages.ruff.configuration_directory) { + Ok(Some(path)) => { + let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({})); + settings["configuration"] = json!(path.to_string_lossy()); + cfg.settings = Some(settings); + } + Ok(None) => {} + Err(e) => cfg.launch_error = Some(e.message), + } + } + if name == "rust-analyzer" { let r = &s.languages.rust; let extra_args: Vec<&str> = r.rustfmt_args.split_whitespace().collect(); @@ -719,6 +765,11 @@ pub fn language_servers(language: &str, defined: &[LanguageServerConfig], s: &Se out.push(custom_server(name, ls)); } } + if non_empty(&s.languages.ruff.configuration_directory).is_some() { + for cfg in out.iter_mut().filter(|cfg| cfg.name == "ruff") { + cfg.require_root_marker = false; + } + } out } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 84a45bb8..a97835c9 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -1,6 +1,6 @@ use crate::apply::{ apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter, - terminal_activation, tsdk, without_derived, + ruff_configuration, terminal_activation, tsdk, without_derived, }; use crate::python::parse_pip_list; use crate::{bin_dir, exe, venv_of}; @@ -481,6 +481,9 @@ fn language_server_settings_disable_add_and_override() { (ruff.command.as_str(), ruff.args.as_slice()), ("/opt/ruff", &["server".to_string(), "--preview".to_string()][..]) ); + s.languages.ruff.configuration_directory = "ruff-config".into(); + let defined = [server("ruff")]; + assert!(!language_servers("python", &defined, &s)[0].require_root_marker); s.language_servers.insert( "rust-analyzer".into(), @@ -523,6 +526,38 @@ fn language_server_settings_disable_add_and_override() { assert_eq!(parsed.code_action_kinds, Some(vec!["refactor".to_string()])); } +#[test] +fn ruff_configuration_directory_resolves_files_and_validates_pyproject() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let dir = root.join("shared ruff"); + std::fs::create_dir_all(&dir).unwrap(); + assert_eq!(ruff_configuration(root, "").unwrap(), None); + assert!(ruff_configuration(root, &dir.to_string_lossy()).unwrap_err().message.contains("has no")); + + let ruff = dir.join("ruff.toml"); + std::fs::write(&ruff, "line-length = 100\n").unwrap(); + assert_eq!(ruff_configuration(root, &dir.to_string_lossy()).unwrap(), Some(ruff.clone())); + assert_eq!(ruff_configuration(root, &ruff.to_string_lossy()).unwrap(), Some(ruff)); + + std::fs::remove_file(dir.join("ruff.toml")).unwrap(); + let pyproject = dir.join("pyproject.toml"); + std::fs::write(&pyproject, "[project]\nname = \"x\"\n").unwrap(); + assert!(ruff_configuration(root, &dir.to_string_lossy()).unwrap_err().message.contains("has no")); + std::fs::write(&pyproject, "[tool.ruff]\nline-length = 100\n").unwrap(); + assert_eq!(ruff_configuration(root, &dir.to_string_lossy()).unwrap(), Some(pyproject.clone())); + + let mut settings = Settings::default(); + settings.languages.ruff.configuration_directory = dir.to_string_lossy().into_owned(); + let mut cfg = server("ruff"); + configure_server(&mut cfg, root, root, &settings); + let configured = dir.join("pyproject.toml").to_string_lossy().into_owned(); + assert_eq!( + cfg.settings.as_ref().and_then(|s| s.get("configuration")).and_then(|v| v.as_str()), + Some(configured.as_str()) + ); +} + #[test] fn jdtls_jdk_setting() { if cfg!(windows) { diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs index b6a7a692..ac3a759f 100644 --- a/crates/ide-tools/src/formatter.rs +++ b/crates/ide-tools/src/formatter.rs @@ -8,6 +8,18 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; +/// A selection to format. Offsets are UTF-16 code units for Prettier; the +/// one-based line and column values are Unicode code points for Ruff. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FormatRange { + pub start_offset: u32, + pub end_offset: u32, + pub start_line: u32, + pub start_col: u32, + pub end_line: u32, + pub end_col: u32, +} + /// 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()) @@ -32,9 +44,13 @@ fn package_bin(package: &Path, tool: &str) -> IdeResult { Ok(path) } -/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only. +/// Prettier and Ruff format ranges for some languages (`config.range`); oxfmt formats whole files only. pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool { - tool == "prettier" && config.range + matches!(tool, "prettier" | "ruff") && config.range +} + +fn is_formatter(tool: &str) -> bool { + crate::registry::find(tool).is_some_and(|t| t.kind == crate::ToolKind::Formatter || t.name == "ruff") } impl Tools { @@ -44,7 +60,7 @@ impl Tools { } fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { - if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) { + if !is_formatter(tool) { return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}"))); } let package = Path::new("node_modules").join(tool); @@ -73,18 +89,36 @@ impl Tools { } } -/// 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. +/// Every tool resolves its configuration itself. Prettier's project configuration +/// overrides the editor indentation passed here; oxfmt and Ruff read their own +/// configuration files. Ruff also discovers the nearest `pyproject.toml`, +/// `ruff.toml`, or `.ruff.toml` from the stdin filename. pub fn arguments( tool: &str, config: &FormatterConfig, file: &Path, tab_size: u32, insert_spaces: bool, - range: Option<(u32, u32)>, + range: Option, + config_file: Option<&Path>, ) -> Vec { let mut args: Vec = Vec::new(); + if tool == "ruff" { + args.push("format".into()); + if let Some(config_file) = config_file { + args.extend(["--config".into(), config_file.as_os_str().to_owned()]); + } + args.extend(["--stdin-filename".into(), file.as_os_str().to_owned(), "-".into()]); + if supports_range(tool, config) + && let Some(range) = range + { + args.extend([ + "--range".into(), + format!("{}:{}-{}:{}", range.start_line, range.start_col, range.end_line, range.end_col).into(), + ]); + } + return args; + } if tool == "prettier" { let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { "mdx" @@ -107,9 +141,14 @@ pub fn arguments( } args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]); if supports_range(tool, config) - && let Some((start, end)) = range + && let Some(range) = range { - args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]); + args.extend([ + "--range-start".into(), + range.start_offset.to_string().into(), + "--range-end".into(), + range.end_offset.to_string().into(), + ]); } // CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files. for name in [".prettierignore", ".gitignore"] { @@ -181,8 +220,15 @@ mod tests { ("vue", true), ] { let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range }; - let args = - arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); + let range = Some(FormatRange { + start_offset: 3, + end_offset: 9, + start_line: 1, + start_col: 1, + end_line: 1, + end_col: 7, + }); + let args = arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, range, None); 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"])); @@ -217,7 +263,9 @@ mod tests { ); let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true }; - let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9))); + let range = + Some(FormatRange { start_offset: 3, end_offset: 9, start_line: 1, start_col: 1, end_line: 1, end_col: 7 }); + let args = arguments("oxfmt", &config, &file, 4, false, range, None); let ignore = root.join(".prettierignore").into_os_string(); assert_eq!( args[..4], @@ -229,4 +277,38 @@ mod tests { let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err(); assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message); } + + #[test] + fn ruff_formats_stdin_and_uses_its_config_and_range_flags() { + let config = FormatterConfig { tool: "ruff".into(), parser: "python".into(), range: true }; + let file = Path::new("/project with spaces/src/example.py"); + let range = Some(FormatRange { + start_offset: 10, + end_offset: 20, + start_line: 2, + start_col: 5, + end_line: 4, + end_col: 1, + }); + let config_file = Path::new("/shared/config/ruff.toml"); + let args: Vec<_> = arguments("ruff", &config, file, 4, true, range, Some(config_file)) + .into_iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert_eq!( + args, + [ + "format", + "--config", + "/shared/config/ruff.toml", + "--stdin-filename", + "/project with spaces/src/example.py", + "-", + "--range", + "2:5-4:1", + ] + ); + assert!(supports_range("ruff", &config)); + assert!(!args.iter().any(|arg| arg == "--stdin-filepath")); + } } diff --git a/crates/ide-tools/tests/prettier.rs b/crates/ide-tools/tests/prettier.rs index c09f6d59..59ecdb50 100644 --- a/crates/ide-tools/tests/prettier.rs +++ b/crates/ide-tools/tests/prettier.rs @@ -19,7 +19,15 @@ fn format( }; let mut cmd = tools.formatter_command("prettier", path, None)?; cmd.current_dir(path.parent().unwrap()); - cmd.args(formatter::arguments("prettier", &config, path, 4, true, range)); + let range = range.map(|(start_offset, end_offset)| formatter::FormatRange { + start_offset, + end_offset, + start_line: 1, + start_col: 1, + end_line: 1, + end_col: 1, + }); + cmd.args(formatter::arguments("prettier", &config, path, 4, true, range, None)); formatter::run("prettier", cmd, text, &CancellationToken::new()) } diff --git a/crates/ide-tools/tests/ruff.rs b/crates/ide-tools/tests/ruff.rs new file mode 100644 index 00000000..afe51f76 --- /dev/null +++ b/crates/ide-tools/tests/ruff.rs @@ -0,0 +1,67 @@ +//! `cargo test -p ide-tools --test ruff -- --ignored --nocapture` + +use ide_core::CancellationToken; +use ide_language::FormatterConfig; +use ide_tools::{Tools, formatter}; +use std::path::Path; + +fn format( + tools: &Tools, + path: &Path, + text: &str, + range: Option, + config_file: Option<&Path>, +) -> ide_core::IdeResult { + let config = FormatterConfig { tool: "ruff".into(), parser: "python".into(), range: true }; + let mut cmd = tools.formatter_command("ruff", path, None)?; + cmd.current_dir(path.parent().unwrap()); + cmd.args(formatter::arguments("ruff", &config, path, 4, true, range, config_file)); + formatter::run("ruff", cmd, text, &CancellationToken::new()) +} + +#[test] +#[ignore = "requires ruff on PATH"] +fn real_ruff_reads_project_config_formats_stdin_and_supports_ranges() { + let temp = tempfile::tempdir().unwrap(); + let tools = Tools::new(&temp.path().join("app-data")); + if tools.info("ruff", &[]).unwrap().status == ide_tools::ToolStatus::Missing { + eprintln!("SKIP real Ruff: not on PATH"); + return; + } + let project = temp.path().join("project with spaces"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("pyproject.toml"), "[tool.ruff.format]\nquote-style = \"single\"\n").unwrap(); + let path = project.join("example.py"); + let input = "def greet(name):\n return {\"message\": name}\n"; + let output = format(&tools, &path, input, None, None).unwrap(); + assert_eq!(output, "def greet(name):\n return {'message': name}\n"); + assert!(!path.exists(), "formatting must never write the file"); + + let range = formatter::FormatRange { + start_offset: 0, + end_offset: 0, + start_line: 2, + start_col: 1, + end_line: 2, + end_col: 100, + }; + let mixed = "def untouched( a,b ):\n return {\"message\": name}\n"; + let output = format(&tools, &path, mixed, Some(range), None).unwrap(); + assert!(output.starts_with("def untouched( a,b ):"), "{output}"); + assert!(output.contains("return {'message': name}"), "{output}"); + + let shared = temp.path().join("shared config"); + std::fs::create_dir_all(&shared).unwrap(); + let shared_config = shared.join("ruff.toml"); + std::fs::write(&shared_config, "[format]\nquote-style = \"double\"\n").unwrap(); + std::fs::remove_file(project.join("pyproject.toml")).unwrap(); + let output = format(&tools, &path, input, None, Some(&shared_config)).unwrap(); + assert_eq!(output, "def greet(name):\n return {\"message\": name}\n"); + + let invalid = "def broken(:\n pass\n"; + let error = format(&tools, &path, invalid, None, Some(&shared_config)).unwrap_err(); + assert!(error.message.contains("Failed to parse"), "{}", error.message); + eprintln!( + "PASS: real Ruff config discovery, custom config file, stdin formatting, quote style, range, invalid syntax diagnostics, no disk writes" + ); +} diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index 3b278a0b..8327f134 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -479,6 +479,10 @@ mod tests { let text = std::fs::read_to_string(added.join("settings.toml")).unwrap(); assert_eq!(text, "[languageServers.ruff]\nargs = [\"server\"]\ncommand = \"ruff\"\nlanguages = [\"python\"]\n"); + let s = + store.set(SettingsScope::Global, "languages.ruff.configurationDirectory", json!("shared/ruff")).unwrap(); + assert_eq!(s.languages.ruff.configuration_directory, "shared/ruff"); + // Project layer is created on demand and wins. let s = store.set(SettingsScope::Project, "editor.fontSize", json!(12)).unwrap(); assert_eq!(s.editor.font_size, 12.0); diff --git a/languages/python.toml b/languages/python.toml index a9b4ea92..b3f331a4 100644 --- a/languages/python.toml +++ b/languages/python.toml @@ -1,5 +1,6 @@ id = "python" name = "Python" +formatter = { tool = "ruff", parser = "python", range = true } extensions = ["py", "pyi", "pyw"] filenames = ["SConstruct", "SConscript"] first-line = "^#!.*\\bpython[0-9.]*\\b" diff --git a/src/bindings.ts b/src/bindings.ts index 8fba6499..75a3b748 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3489,12 +3489,14 @@ export type ExtensionCount = { }; /** A formatter that runs outside the language servers, with its own settings group. */ -export type ExternalFormatter = "prettier" | "oxfmt"; +export type ExternalFormatter = "prettier" | "oxfmt" | "ruff"; -/** An external formatter (Prettier, oxfmt) and the languages it formats. */ +/** An external formatter (Prettier, oxfmt, Ruff) and the languages it formats. */ export type ExternalFormatterSettings = { - /** Empty path: project node_modules, managed installation, then PATH. */ + /** Empty path: project node_modules (Prettier/oxfmt), managed installation, then PATH. */ path?: string, + /** Ruff only: directory containing its configuration, or a configuration file. */ + configurationDirectory?: string, languages?: string[], /** Prefer this formatter even when a language server offers formatting. */ prefer?: boolean, @@ -4815,6 +4817,7 @@ export type LanguagesSettings = { typescript?: TypeScriptSettings, prettier?: ExternalFormatterSettings, oxfmt?: ExternalFormatterSettings, + ruff?: ExternalFormatterSettings, java?: JavaSettings, ruby?: RubySettings, markdown?: MarkdownSettings, diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts index 40cc2cfb..4838348b 100644 --- a/src/editor/lsp/actions.ts +++ b/src/editor/lsp/actions.ts @@ -124,12 +124,12 @@ function editorAction( return { id, title, category, when: EDITOR_WHEN, run: withEditor(run), ...extra }; } -/** Whether saving a `language` file formats it: Format on save, or Run on save of Prettier or oxfmt. */ +/** Whether saving a `language` file formats it: Format on save, or Run on save of an external formatter. */ export function formatsOnSave(settings: Settings | null, language: string): boolean { - const { prettier, oxfmt } = validateLanguages(settings?.languages); + const { prettier, oxfmt, ruff } = validateLanguages(settings?.languages); return ( !!settings?.editor?.formatOnSave || - [prettier, oxfmt].some((formatter) => formatter.runOnSave && formatter.languages.includes(language)) + [prettier, oxfmt, ruff].some((formatter) => formatter.runOnSave && formatter.languages.includes(language)) ); } @@ -206,6 +206,13 @@ export function registerLspActions(): () => void { (v) => formatDocument(v, { explicit: true, tool: "oxfmt" }), { keywords: ["format", "oxfmt", "oxc"] }, ), + editorAction( + "code.reformatWithRuff", + "Reformat with Ruff", + "Code", + (v) => formatDocument(v, { explicit: true, tool: "ruff" }), + { keywords: ["format", "ruff", "python"] }, + ), editorAction("code.reformatDialog", "Reformat File…", "Code", (v) => reformatDialog(v, (view) => applyKindSilently(view, "source.organizeImports")), ), diff --git a/src/editor/lsp/format.test.ts b/src/editor/lsp/format.test.ts index 503fc8bd..3d568614 100644 --- a/src/editor/lsp/format.test.ts +++ b/src/editor/lsp/format.test.ts @@ -177,6 +177,8 @@ describe("format on save provider isolation", () => { expect(formatsOnSave(oxfmt, "typescript")).toBe(true); expect(formatsOnSave(oxfmt, "markdown")).toBe(false); expect(formatsOnSave(settings({ prettier: { runOnSave: true } }), "markdown")).toBe(true); + expect(formatsOnSave(settings({ ruff: { runOnSave: true } }), "python")).toBe(true); + expect(formatsOnSave(settings({ ruff: { runOnSave: true } }), "typescript")).toBe(false); expect(formatsOnSave(settings({}), "typescript")).toBe(false); expect(formatsOnSave(settings({}, true), "rust")).toBe(true); expect(formatsOnSave(null, "typescript")).toBe(false); diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 89c38ffa..2193e125 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -136,20 +136,26 @@ async function format(view: EditorView, opts: FormatDocumentOptions): Promise { _resetI18nForTests(); }); -describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => { +describe.each(["prettier", "oxfmt", "ruff"])("%s formatter errors", (name) => { const tool = { type: "tool", tool: name, missing: false } as const; - const display = name === "prettier" ? "Prettier" : name; + const display = name === "prettier" ? "Prettier" : name === "ruff" ? "Ruff" : name; it("uses structured missing-tool information to request the shared install prompt", async () => { const prompt = vi.fn(); const dispose = registerAction({ @@ -57,9 +57,9 @@ describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => { try { failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } }); await formatDocument(viewFor()); - expect(prompt).toHaveBeenCalledWith({ name, sticky: true }); + expect(prompt).toHaveBeenCalledWith({ name, sticky: true, formatter: true }); await formatDocument(viewFor(), { explicit: false, onSave: true }); - expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false }); + expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false, formatter: true }); expect(useNotifications.getState().items).toHaveLength(0); } finally { dispose(); @@ -126,10 +126,11 @@ describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => { }); }); -describe("Reformat with Prettier / oxfmt", () => { +describe("Reformat with an external formatter", () => { it.each([ ["code.reformatWithPrettier", "prettier"], ["code.reformatWithOxfmt", "oxfmt"], + ["code.reformatWithRuff", "ruff"], ])("%s asks the backend for %s whatever the settings select", async (id, tool) => { vi.spyOn(lsp, "lspRequest").mockImplementation(async (request) => { await request(); diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 1140093a..f542a9fe 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -92,7 +92,13 @@ describe("languages settings", () => { validateLanguages({ prettier: { path: " ./bin/prettier ", languages: ["markdown", 4, "markdown"], prefer: true, runOnSave: true }, }).prettier, - ).toEqual({ path: "./bin/prettier", languages: ["markdown"], prefer: true, runOnSave: true }); + ).toEqual({ + path: "./bin/prettier", + configurationDirectory: "", + languages: ["markdown"], + prefer: true, + runOnSave: true, + }); expect(validateLanguages({ prettier: { languages: [] } }).prettier.languages).toEqual([]); }); @@ -102,10 +108,43 @@ describe("languages settings", () => { prettier: { runOnSave: true }, oxfmt: { path: " ./bin/oxfmt ", languages: ["typescript", null, "typescript"], prefer: true }, }); - expect(v.oxfmt).toEqual({ path: "./bin/oxfmt", languages: ["typescript"], prefer: true, runOnSave: false }); + expect(v.oxfmt).toEqual({ + path: "./bin/oxfmt", + configurationDirectory: "", + languages: ["typescript"], + prefer: true, + runOnSave: false, + }); expect(v.prettier).toMatchObject({ runOnSave: true, prefer: false, path: "" }); }); + it("enables Ruff for Python by default and keeps its settings separate", () => { + expect(validateLanguages({}).ruff).toMatchObject({ + path: "", + configurationDirectory: "", + languages: ["python"], + prefer: false, + runOnSave: false, + }); + const v = validateLanguages({ + ruff: { + path: " ./bin/ruff ", + configurationDirectory: " ./config/ruff ", + languages: ["python", null, "python", "pyi"], + prefer: true, + runOnSave: true, + }, + }); + expect(v.ruff).toEqual({ + path: "./bin/ruff", + configurationDirectory: "./config/ruff", + languages: ["python", "pyi"], + prefer: true, + runOnSave: true, + }); + expect(v.prettier).toEqual(DEFAULT_LANGUAGES.prettier); + }); + it("leaves the Markdown preview font size unset (editor font) and keeps a set one in 8..72", () => { expect(validateLanguages({}).markdown).toEqual({ previewFontSize: null }); expect(validateLanguages({ markdown: { previewFontSize: 90 } }).markdown).toEqual({ previewFontSize: 72 }); @@ -143,6 +182,7 @@ describe("language pages", () => { "PHP", "Prettier", "oxfmt", + "Ruff", "Ruby", "Rust", "TypeScript", diff --git a/src/features/settings/languages/FormatterPage.tsx b/src/features/settings/languages/FormatterPage.tsx index 5c73746f..69f7d1f1 100644 --- a/src/features/settings/languages/FormatterPage.tsx +++ b/src/features/settings/languages/FormatterPage.tsx @@ -1,9 +1,11 @@ import { useId } from "react"; import type { ExternalFormatter } from "@/bindings"; -import { Input } from "@/ui"; +import { IconButton, Input } from "@/ui"; import { Row, Section, Select, Toggle } from "../components/Form"; import { useSetting } from "../components/context"; import { DEFAULT_LANGUAGES } from "@/platform/languageSettings"; +import { choosePath } from "./api"; +import styles from "./Sdk.module.css"; const names: Record = { markdown: "Markdown", @@ -20,17 +22,28 @@ const names: Record = { json: "JSON", jsonc: "JSON with Comments", vue: "Vue", + python: "Python", }; interface FormatterPageProps { tool: ExternalFormatter; name: string; pathLabel: string; + pathHint?: string; + configurationDirectory?: boolean; providerHint?: string; saveHint: string; } -function FormatterPage({ tool, name, pathLabel, providerHint, saveHint }: FormatterPageProps) { +function FormatterPage({ + tool, + name, + pathLabel, + pathHint = "Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection.", + configurationDirectory = false, + providerHint, + saveHint, +}: FormatterPageProps) { const id = useId(); const [path, setPath] = useSetting(`languages.${tool}.path`); const [languages, setLanguages] = useSetting(`languages.${tool}.languages`); @@ -39,13 +52,10 @@ function FormatterPage({ tool, name, pathLabel, providerHint, saveHint }: Format return ( <>
- + setPath(e.target.value)} /> + {configurationDirectory && } setPath(e.target.value)} + /> + { + const picked = await choosePath({ + directory: true, + title: "Select Ruff Configuration Directory", + defaultPath: path || undefined, + }); + if (picked) setPath(picked); + }} + /> + + + ); +} + export function PrettierPage() { return ( ); } + +export function RuffPage() { + return ( + + ); +} diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index 0ef49739..5291b3e0 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -5,7 +5,7 @@ */ import { GoPage, JavaPage, NodePage, PhpPage, RubyPage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; import { MarkdownPage } from "../languages/MarkdownPage"; -import { OxfmtPage, PrettierPage } from "../languages/FormatterPage"; +import { OxfmtPage, PrettierPage, RuffPage } from "../languages/FormatterPage"; import { ProjectStructurePage } from "../languages/ProjectStructurePage"; import { PythonInterpreterPage } from "../languages/PythonInterpreterPage"; import { GoBuildTagsPage, GO_BUILD_TAG_SECTIONS } from "../languages/GoBuildTags"; @@ -138,6 +138,24 @@ export const LANGUAGE_PAGES: PageSpec[] = [ keywords: ["formatter", "oxfmt", "oxc", ".oxfmtrc.json", "format on save", "node_modules"], component: OxfmtPage, }, + { + id: "languages.ruff", + projectScope: true, + defaultScope: "project", + title: "Ruff", + group: LANGUAGES_GROUP, + order: 403.7, + keywords: [ + "formatter", + "ruff", + "python", + "configuration directory", + "pyproject.toml", + "ruff.toml", + "format on save", + ], + component: RuffPage, + }, { id: "languages.php", projectScope: true, diff --git a/src/features/tools/__tests__/tools.test.tsx b/src/features/tools/__tests__/tools.test.tsx index 260a2d49..de36662f 100644 --- a/src/features/tools/__tests__/tools.test.tsx +++ b/src/features/tools/__tests__/tools.test.tsx @@ -147,6 +147,25 @@ describe("missing language server prompt", () => { off(); }); + it("offers Ruff formatting even when another Python server is installed", async () => { + const installed = sampleTools().find((t) => t.name === "basedpyright")!; + backend.tools.push({ + ...installed, + name: "ruff", + displayName: "Ruff", + command: "ruff", + installMethod: "uv (virtualenv)", + languages: ["Python"], + alternatives: [], + }); + await promptInstall("ruff", true, new Set(), true); + expect(notes.at(-1)).toMatchObject({ + title: "Ruff is not installed", + body: "Reformat Code needs Ruff. Install it via uv (virtualenv)?", + key: missingKey("ruff"), + }); + }); + it("stays silent when an installed alternative serves the same languages", async () => { const off = installMissingServerPrompts(); // typescript-language-server (on PATH) covers vtsls' languages. diff --git a/src/features/tools/index.ts b/src/features/tools/index.ts index f9c79d9a..780d4fcc 100644 --- a/src/features/tools/index.ts +++ b/src/features/tools/index.ts @@ -46,7 +46,12 @@ export function activate(): () => void { 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); + await promptInstall( + arg.name, + !("sticky" in arg) || arg.sticky !== false, + new Set(), + "formatter" in arg && arg.formatter === true, + ); } }, }, diff --git a/src/features/tools/missing.ts b/src/features/tools/missing.ts index cbed9907..2cac4ced 100644 --- a/src/features/tools/missing.ts +++ b/src/features/tools/missing.ts @@ -21,12 +21,14 @@ export async function promptInstall( name: string, sticky = true, offered: ReadonlySet = new Set(), + formatter = false, ): Promise { if (!useTools.getState().loaded) await refreshTools(); const tool = toolByName(name); if (!tool || !tool.canInstall || tool.status !== "missing") return false; const position = tool.alternatives.indexOf(name); if (position > 0 && tool.alternatives.slice(0, position).some((n) => offered.has(n))) return false; + formatter ||= tool.kind === "formatter"; // An installed alternative (typescript-language-server for vtsls) already serves these files. const served = useTools .getState() @@ -37,17 +39,16 @@ export async function promptInstall( t.status !== "missing" && t.languages.some((l) => tool.languages.includes(l)), ); - if (tool.kind !== "formatter" && served) return false; + if (!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: 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}?`, + body: 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, actions: [ diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index 493527c8..519c648c 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -25,9 +25,10 @@ const DEFAULT_GO_BUILD_TAGS: GoBuildTags = { experiments: "", }; -/** An external formatter (Prettier, oxfmt) and the languages it formats. */ +/** An external formatter (Prettier, oxfmt, Ruff) and the languages it formats. */ interface ExternalFormatterValid { path: string; + configurationDirectory: string; languages: string[]; prefer: boolean; runOnSave: boolean; @@ -35,6 +36,7 @@ interface ExternalFormatterValid { const DEFAULT_FORMATTER: ExternalFormatterValid = { path: "", + configurationDirectory: "", languages: [ "markdown", "yaml", @@ -55,6 +57,11 @@ const DEFAULT_FORMATTER: ExternalFormatterValid = { runOnSave: false, }; +const DEFAULT_RUFF_FORMATTER: ExternalFormatterValid = { + ...DEFAULT_FORMATTER, + languages: ["python"], +}; + /** `languages.php.languageLevel` values; `""` = Auto (from `composer.json`). */ export const PHP_LANGUAGE_LEVELS = [ "", @@ -99,6 +106,7 @@ export interface LanguagesValid { typescript: { version: string }; prettier: ExternalFormatterValid; oxfmt: ExternalFormatterValid; + ruff: ExternalFormatterValid; java: { jdk: string; jdtlsJdk: string; @@ -159,6 +167,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = { typescript: { version: "bundled" }, prettier: DEFAULT_FORMATTER, oxfmt: DEFAULT_FORMATTER, + ruff: DEFAULT_RUFF_FORMATTER, java: { jdk: "", jdtlsJdk: "", @@ -228,11 +237,11 @@ const folders = (v: unknown): string[] => const folderKey = (stored: string) => (stored === "." ? "" : stored); const storedFolder = (rel: string) => normalizeFolder(rel) || "."; -function validateFormatter(raw: unknown): ExternalFormatterValid { - const d = DEFAULT_FORMATTER; +function validateFormatter(raw: unknown, d = DEFAULT_FORMATTER): ExternalFormatterValid { const f = obj(raw); return { path: text(f.path, d.path), + configurationDirectory: text(f.configurationDirectory, d.configurationDirectory), languages: Array.isArray(f.languages) ? [...new Set(f.languages.filter((v): v is string => typeof v === "string"))] : d.languages, @@ -299,6 +308,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { typescript: { version: text(ts.version, d.typescript.version) || d.typescript.version }, prettier: validateFormatter(l.prettier), oxfmt: validateFormatter(l.oxfmt), + ruff: validateFormatter(l.ruff, DEFAULT_RUFF_FORMATTER), java: { jdk: text(java.jdk, d.java.jdk), jdtlsJdk: text(java.jdtlsJdk, d.java.jdtlsJdk), diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 774307a7..0beb9e74 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -13,6 +13,19 @@ "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon": "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon", "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.": "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.", "Select PHPStan Configuration File": "Select PHPStan Configuration File", + "Reformat with Ruff": "Reformat with Ruff", + "Ruff path": "Ruff path", + "Auto: managed installation, then PATH. An explicit path overrides auto detection.": "Auto: managed installation, then PATH. An explicit path overrides auto detection.", + "Configuration directory": "Configuration directory", + "Project configuration": "Project configuration", + "Select Ruff Configuration Directory": "Select Ruff Configuration Directory", + "Empty uses the nearest project pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. A configured directory must contain one of them.": "Empty uses the nearest project pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. A configured directory must contain one of them.", + "Ruff reads the nearest pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. Use it before the language server when preferred.": "Ruff reads the nearest pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. Use it before the language server when preferred.", + "Run Ruff on save for enabled languages when it is the selected formatting provider.": "Run Ruff on save for enabled languages when it is the selected formatting provider.", + "Ruff formatting failed": "Ruff formatting failed", + "Ruff formatting timed out": "Ruff formatting timed out", + "Ruff configuration path does not exist: {path}": "Ruff configuration path does not exist: {path}", + "Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {path}": "Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {path}", "Show lines added and deleted": "Show lines added and deleted", "{added} line added": "{added} line added", "{added} lines added": "{added} lines added", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 111271f2..a7a8d701 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1714,15 +1714,24 @@ "Reformat Code": "重新格式化代码", "Reformat with Prettier": "使用 Prettier 重新格式化", "Reformat with oxfmt": "使用 oxfmt 重新格式化", + "Reformat with Ruff": "使用 Ruff 重新格式化", "Prettier path": "Prettier 路径", "oxfmt path": "oxfmt 路径", + "Ruff path": "Ruff 路径", "Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection.": "自动:依次查找最近的项目 node_modules、受管安装和 PATH。指定路径将覆盖自动检测。", + "Auto: managed installation, then PATH. An explicit path overrides auto detection.": "自动:依次查找受管安装和 PATH。指定路径将覆盖自动检测。", + "Configuration directory": "配置目录", + "Project configuration": "项目配置", + "Select Ruff Configuration Directory": "选择 Ruff 配置目录", + "Empty uses the nearest project pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. A configured directory must contain one of them.": "留空时使用最近的 pyproject.toml([tool.ruff])、ruff.toml 或 .ruff.toml。指定目录必须包含其中一种配置。", "Formatting provider": "格式化工具", "Automatic (language server first)": "自动(优先语言服务器)", "Run on save": "保存时运行", "Run Prettier on save for enabled languages when it is the selected formatting provider.": "对已启用的语言,在选择 Prettier 作为格式化工具时于保存时运行。", "Run oxfmt on save for enabled languages when it is the selected formatting provider.": "对已启用的语言,在选择 oxfmt 作为格式化工具时于保存时运行。", + "Run Ruff on save for enabled languages when it is the selected formatting provider.": "对已启用的语言,在选择 Ruff 作为格式化工具时于保存时运行。", "oxfmt formats an enabled language instead of Prettier below an oxfmt configuration file (.oxfmtrc.json, oxfmt.config.ts) or when it is the selected provider.": "在 oxfmt 配置文件(.oxfmtrc.json、oxfmt.config.ts)所在目录及其子目录中,或选择 oxfmt 作为格式化工具时,oxfmt 将代替 Prettier 格式化已启用的语言。", + "Ruff reads the nearest pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. Use it before the language server when preferred.": "Ruff 会读取最近的 pyproject.toml([tool.ruff])、ruff.toml 或 .ruff.toml。选择优先使用 Ruff 时,将优先于语言服务器。", "Enabled languages": "启用的语言", "Reformat Code needs {name}. Install it via {method}?": "重新格式化代码需要 {name}。是否通过 {method} 安装?", "Install Missing Tool": "安装缺失的工具", @@ -1730,6 +1739,10 @@ "Prettier formatting timed out": "Prettier 格式化超时", "oxfmt formatting failed": "oxfmt 格式化失败", "oxfmt formatting timed out": "oxfmt 格式化超时", + "Ruff formatting failed": "Ruff 格式化失败", + "Ruff formatting timed out": "Ruff 格式化超时", + "Ruff configuration path does not exist: {path}": "Ruff 配置路径不存在:{path}", + "Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {path}": "Ruff 配置目录中没有 ruff.toml、.ruff.toml 或包含 [tool.ruff] 的 pyproject.toml:{path}", "{name} does not support selection formatting for this language; formatted the whole file": "{name} 不支持此语言的选区格式化,已格式化整个文件", "Formatting timed out": "格式化超时", "{name} executable": "{name} 可执行文件", diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 2392c344..3ec10d8e 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -91,6 +91,7 @@ const UNTRANSLATED = new Set([ "CMake", "Prettier", "oxfmt", + "Ruff", "IntelliJ IDEA", "PHPStan", // Go environment variables and placeholder values