diff --git a/apps/oxfmt/src/lsp/mod.rs b/apps/oxfmt/src/lsp/mod.rs index 197a078d57d6a..340dc15fe3771 100644 --- a/apps/oxfmt/src/lsp/mod.rs +++ b/apps/oxfmt/src/lsp/mod.rs @@ -1,4 +1,7 @@ -use oxc_language_server::run_server; +use std::path::{Path, PathBuf}; + +use oxc_language_server::{LanguageId, run_server}; +use tower_lsp_server::ls_types::Uri; use crate::core::ExternalFormatter; @@ -8,6 +11,41 @@ mod server_formatter; mod tester; const FORMAT_CONFIG_FILES: &[&str; 2] = &[".oxfmtrc.json", ".oxfmtrc.jsonc"]; +fn get_file_extension_from_language_id(language_id: &LanguageId) -> Option<&'static str> { + match language_id.as_str() { + "javascript" => Some("js"), + "typescript" => Some("ts"), + "javascriptreact" => Some("jsx"), + "typescriptreact" => Some("tsx"), + "toml" => Some("toml"), + "css" => Some("css"), + "graphql" => Some("graphql"), + "handlebars" => Some("handlebars"), + "json" => Some("json"), + "jsonc" => Some("jsonc"), + "json5" => Some("json5"), + "markdown" => Some("md"), + "mdx" => Some("mdx"), + "mjml" => Some("mjml"), + "html" => Some("html"), + "scss" => Some("scss"), + "less" => Some("less"), + "vue" => Some("vue"), + "yaml" => Some("yaml"), + _ => None, + } +} + +pub fn create_fake_file_path_from_language_id( + language_id: &LanguageId, + root: &Path, + uri: &Uri, +) -> Option { + let file_extension = get_file_extension_from_language_id(language_id)?; + let file_name = format!("{}.{}", uri.authority()?, file_extension); + Some(root.join(file_name)) +} + /// Run the language server pub async fn run_lsp(external_formatter: ExternalFormatter) { run_server( diff --git a/apps/oxfmt/src/lsp/server_formatter.rs b/apps/oxfmt/src/lsp/server_formatter.rs index d2af320639775..256ea1e683e9f 100644 --- a/apps/oxfmt/src/lsp/server_formatter.rs +++ b/apps/oxfmt/src/lsp/server_formatter.rs @@ -11,6 +11,7 @@ use crate::core::{ ConfigResolver, ExternalFormatter, FormatFileStrategy, FormatResult, SourceFormatter, resolve_editorconfig_path, resolve_oxfmtrc_path, utils, }; +use crate::lsp::create_fake_file_path_from_language_id; use crate::lsp::{FORMAT_CONFIG_FILES, options::FormatOptions as LSPFormatOptions}; pub struct ServerFormatterBuilder { @@ -74,7 +75,12 @@ impl ServerFormatterBuilder { let source_formatter = SourceFormatter::new(num_of_threads) .with_external_formatter(Some(self.external_formatter.clone())); - ServerFormatter::new(source_formatter, config_resolver, gitignore_glob) + ServerFormatter::new( + root_path.to_path_buf(), + source_formatter, + config_resolver, + gitignore_glob, + ) } } @@ -152,6 +158,7 @@ impl ServerFormatterBuilder { // --- pub struct ServerFormatter { + root_path: PathBuf, source_formatter: SourceFormatter, config_resolver: ConfigResolver, gitignore_glob: Option, @@ -246,35 +253,35 @@ impl Tool for ServerFormatter { fn run_format( &self, uri: &Uri, - _language_id: &LanguageId, + language_id: &LanguageId, content: Option<&str>, ) -> Result, String> { - let Some(path) = uri.to_file_path() else { return Err("Invalid file URI".to_string()) }; + let file_content; + let (result, source_text) = if uri.as_str().starts_with("untitled:") { + let source_text = + content.ok_or_else(|| "In-memory formatting requires content".to_string())?; - if self.is_ignored(&path) { - debug!("File is ignored: {}", path.display()); - return Ok(Vec::new()); - } + let Some(result) = self.format_in_memory(uri, source_text, language_id) else { + return Ok(vec![]); // currently not supported + }; + (result, source_text) + } else { + let Some(path) = uri.to_file_path() else { return Err("Invalid file URI".to_string()) }; - // Determine format strategy from file path (supports JS/TS, JSON, YAML, CSS, etc.) - let Ok(strategy) = FormatFileStrategy::try_from(path.to_path_buf()) else { - debug!("Unsupported file type for formatting: {}", path.display()); - return Ok(Vec::new()); - }; - let source_text = match content { - Some(c) => c, - None => { - &utils::read_to_string(&path).map_err(|e| format!("Failed to read file: {e}"))? - } - }; + let source_text = if let Some(c) = content { + c + } else { + file_content = utils::read_to_string(&path) + .map_err(|e| format!("Failed to read file: {e}"))?; + &file_content + }; - // Resolve options for this file - let resolved_options = self.config_resolver.resolve(&strategy); - debug!("resolved_options = {resolved_options:?}"); + let Some(result) = self.format_file(&path, source_text) else { + return Ok(vec![]); // No formatting for this file (unsupported or ignored) + }; - let result = tokio::task::block_in_place(|| { - self.source_formatter.format(&strategy, source_text, resolved_options) - }); + (result, source_text) + }; // Handle result match result { @@ -307,11 +314,12 @@ impl Tool for ServerFormatter { impl ServerFormatter { pub fn new( + root_path: PathBuf, source_formatter: SourceFormatter, config_resolver: ConfigResolver, gitignore_glob: Option, ) -> Self { - Self { source_formatter, config_resolver, gitignore_glob } + Self { root_path, source_formatter, config_resolver, gitignore_glob } } fn is_ignored(&self, path: &Path) -> bool { @@ -325,6 +333,53 @@ impl ServerFormatter { false } } + + fn format_file(&self, path: &Path, source_text: &str) -> Option { + if self.is_ignored(path) { + debug!("File is ignored: {}", path.display()); + return None; + } + + // Determine format strategy from file path (supports JS/TS, JSON, YAML, CSS, etc.) + let Ok(strategy) = FormatFileStrategy::try_from(path.to_path_buf()) else { + debug!("Unsupported file type for formatting: {}", path.display()); + return None; + }; + + // Resolve options for this file + let resolved_options = self.config_resolver.resolve(&strategy); + debug!("resolved_options = {resolved_options:?}"); + + Some(tokio::task::block_in_place(|| { + self.source_formatter.format(&strategy, source_text, resolved_options) + })) + } + + fn format_in_memory( + &self, + uri: &Uri, + source_text: &str, + language_id: &LanguageId, + ) -> Option { + let Some(path) = create_fake_file_path_from_language_id(language_id, &self.root_path, uri) + else { + debug!("Unsupported language id for in-memory formatting: {language_id:?}"); + return None; + }; + + let Ok(strategy) = FormatFileStrategy::try_from(path.clone()) else { + debug!("Unsupported file type for formatting: {}", path.display()); + return None; + }; + + // Resolve options for this file + let resolved_options = self.config_resolver.resolve(&strategy); + debug!("resolved_options = {resolved_options:?}"); + + Some(tokio::task::block_in_place(|| { + self.source_formatter.format(&strategy, source_text, resolved_options) + })) + } } // --- diff --git a/apps/oxfmt/test/lsp/format/__snapshots__/format.test.ts.snap b/apps/oxfmt/test/lsp/format/__snapshots__/format.test.ts.snap index a678140a532c1..3e3d0f489b946 100644 --- a/apps/oxfmt/test/lsp/format/__snapshots__/format.test.ts.snap +++ b/apps/oxfmt/test/lsp/format/__snapshots__/format.test.ts.snap @@ -234,3 +234,81 @@ debugger --------------------" `; + +exports[`LSP formatting > unsaved document > should format unsaved file format/formatted.ts 1`] = ` +"--- FILE ----------- +format/formatted.ts +--- BEFORE --------- +const x = 1; + +--- AFTER ---------- +const x = 1; + +--------------------" +`; + +exports[`LSP formatting > unsaved document > should format unsaved file format/test.json 1`] = ` +"--- FILE ----------- +format/test.json +--- BEFORE --------- +{"name":"test","version":"1.0.0"} + +--- AFTER ---------- +{ "name": "test", "version": "1.0.0" } + +--------------------" +`; + +exports[`LSP formatting > unsaved document > should format unsaved file format/test.toml 1`] = ` +"--- FILE ----------- +format/test.toml +--- BEFORE --------- +[package] +name="test" +version="1.0.0" + +--- AFTER ---------- +[package] +name = "test" +version = "1.0.0" + +--------------------" +`; + +exports[`LSP formatting > unsaved document > should format unsaved file format/test.tsx 1`] = ` +"--- FILE ----------- +format/test.tsx +--- BEFORE --------- +const App: React.FC = () =>
Hello
+ +--- AFTER ---------- +const App: React.FC = () =>
Hello
; + +--------------------" +`; + +exports[`LSP formatting > unsaved document > should format unsaved file format/test.txt 1`] = ` +"--- FILE ----------- +format/test.txt +--- BEFORE --------- +hello world + +--- AFTER ---------- +hello world + +--------------------" +`; + +exports[`LSP formatting > unsaved document > should format unsaved file format/test.vue 1`] = ` +"--- FILE ----------- +format/test.vue +--- BEFORE --------- + + +--- AFTER ---------- + + +--------------------" +`; diff --git a/apps/oxfmt/test/lsp/format/format.test.ts b/apps/oxfmt/test/lsp/format/format.test.ts index 374e6e59e49e8..0574a72e04f2f 100644 --- a/apps/oxfmt/test/lsp/format/format.test.ts +++ b/apps/oxfmt/test/lsp/format/format.test.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { formatFixture } from "../utils"; +import { formatFixture, formatFixtureContent } from "../utils"; const FIXTURES_DIR = join(import.meta.dirname, "fixtures"); @@ -33,6 +33,26 @@ describe("LSP formatting", () => { }); }); + describe("unsaved document", () => { + it.each([ + ["format/test.tsx", "typescriptreact"], + ["format/test.json", "json"], + ["format/test.vue", "vue"], + ["format/test.toml", "toml"], + ["format/formatted.ts", "typescript"], + ["format/test.txt", "plaintext"], + ])("should format unsaved file %s", async (path, languageId) => { + expect( + await formatFixtureContent( + FIXTURES_DIR, + path, + "untitled://Untitled-" + languageId, + languageId, + ), + ).toMatchSnapshot(); + }); + }); + describe("ignore patterns", () => { it.each([ ["ignore-prettierignore/ignored.ts", "typescript"], diff --git a/apps/oxfmt/test/lsp/utils.ts b/apps/oxfmt/test/lsp/utils.ts index afe07a53dcbdc..f28da9014dddd 100644 --- a/apps/oxfmt/test/lsp/utils.ts +++ b/apps/oxfmt/test/lsp/utils.ts @@ -27,7 +27,9 @@ import type { const CLI_PATH = join(import.meta.dirname, "..", "..", "dist", "cli.js"); export function createLspConnection() { - const proc = spawn("node", [CLI_PATH, "--lsp"]); + const proc = spawn("node", [CLI_PATH, "--lsp"], { + // env: { ...process.env, OXC_LOG: "info" }, for debugging + }); const connection = createMessageConnection( new StreamMessageReader(proc.stdout), @@ -107,8 +109,26 @@ export async function formatFixture( initializationOptions?: OxfmtLSPConfig, ): Promise { const filePath = join(fixturesDir, fixturePath); - const dirPath = dirname(filePath); const fileUri = pathToFileURL(filePath).href; + + return await formatFixtureContent( + fixturesDir, + fixturePath, + fileUri, + languageId, + initializationOptions, + ); +} + +export async function formatFixtureContent( + fixturesDir: string, + fixturePath: string, + fileUri: string, + languageId: string, + initializationOptions?: OxfmtLSPConfig, +): Promise { + const filePath = join(fixturesDir, fixturePath); + const dirPath = dirname(filePath); const content = await fs.readFile(filePath, "utf-8"); await using client = createLspConnection(); diff --git a/crates/oxc_language_server/src/language_id.rs b/crates/oxc_language_server/src/language_id.rs index 24b2961183a2a..e5d211c90914b 100644 --- a/crates/oxc_language_server/src/language_id.rs +++ b/crates/oxc_language_server/src/language_id.rs @@ -14,4 +14,8 @@ impl LanguageId { pub fn new(id: String) -> Self { Self(id) } + + pub fn as_str(&self) -> &str { + &self.0 + } }