From fcfe26474de69ce47e921c74370d1c722d6c7aa0 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 14:56:56 +0800 Subject: [PATCH] fix(format): apply the Code Style indent override to reformat requests (#949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Editor › Code Style › Java › Tabs and Indents had no effect on Reformat Code: a file indented with 4 spaces stayed that way after choosing "Use tab character" with tab size and indent 1. There were two causes. The reformat request sent the editor's indent, which comes from the indents detected in the file, instead of the Code Style; and jdt.ls takes the per-level indent width (`indentation.size`) only from its formatter profile, never from the request. With an explicit Tabs and Indents override for the language, Reformat Code now asks for the Code Style indent (EditorConfig still applies, the detected indents don't; without an override the editor's indent is kept). The jdt.ls profile carries the same override: it is patched into the bundled IntelliJ profile, or put into a minimal generated profile for the default formatter, and custom profiles are left alone. An unset Indent follows the tab size, as in Code Style. With tabs the request sends the tab size; every editor of a document gets the override when it is created, splits and reopened tabs included, not only after a settings change. Checked with jdt.ls 1.63 on JDK 25: tab character, tab size 1 and indent 1 reformat a 4-space file to one tab per level with both the default and the IntelliJ formatter. Fixes #935. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-lsp/src/jdtls/formatter.rs | 146 ++++++++++++++++++++++++- src/editor/core/cm/setup.ts | 21 +++- src/editor/core/model/document.test.ts | 19 ++++ src/editor/core/model/document.ts | 15 ++- src/editor/core/model/slot.ts | 1 + src/editor/lsp/format.test.ts | 37 ++++++- src/editor/lsp/format.ts | 12 +- 7 files changed, 242 insertions(+), 9 deletions(-) diff --git a/crates/ide-lsp/src/jdtls/formatter.rs b/crates/ide-lsp/src/jdtls/formatter.rs index f7c19dea6..18c2b3cd0 100644 --- a/crates/ide-lsp/src/jdtls/formatter.rs +++ b/crates/ide-lsp/src/jdtls/formatter.rs @@ -7,6 +7,61 @@ use std::path::Path; const PROFILE: &str = "OpenIDEA IntelliJ IDEA"; const XML: &str = include_str!("intellij.xml"); +const GENERATED_PROFILE: &str = "OpenIDEA Code Style"; +const TAB_CHAR: &str = "org.eclipse.jdt.core.formatter.tabulation.char"; +const TAB_SIZE: &str = "org.eclipse.jdt.core.formatter.tabulation.size"; +const INDENT_SIZE: &str = "org.eclipse.jdt.core.formatter.indentation.size"; + +/// The Tabs and Indents override as Eclipse formatter settings. The profile must +/// carry them too: `indentation.size`, the per-level width, cannot come from the +/// format request, and an unset Indent follows the tab size, as in IDEA. +fn indent_settings(style: &LanguageCodeStyle) -> Option> { + let tab_char = style.use_tab.map(|tab| if tab { "tab" } else { "space" }); + let indent = style.indent.or(style.tab_size); + if tab_char.is_none() && style.tab_size.is_none() && indent.is_none() { + return None; + } + let mut settings: Vec<(&'static str, String)> = Vec::new(); + if let Some(char) = tab_char { + settings.push((TAB_CHAR, char.to_owned())); + } + if let Some(size) = style.tab_size { + settings.push((TAB_SIZE, size.to_string())); + } + if let Some(size) = indent { + settings.push((INDENT_SIZE, size.to_string())); + } + Some(settings) +} + +fn patch_profile_xml(mut xml: String, settings: &[(&'static str, String)]) -> String { + for (id, value) in settings { + let marker = format!(r#" String { + let mut xml = format!( + r#" + + +"#, + GENERATED_PROFILE + ); + for (id, value) in settings { + xml.push_str(&format!(r#" "#)); + xml.push('\n'); + } + xml.push_str(" \n\n"); + xml +} /// Protocol settings only: switching profiles reaches a running jdtls through /// didChangeConfiguration. Explicit language-server settings are applied later. @@ -31,12 +86,17 @@ pub fn configure_formatter( settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides); cfg.settings = Some(settings); let Some(style) = style else { return Ok(()) }; + let indent = indent_settings(style); let (path, profile) = match style.formatter.as_deref() { Some("intellij") => { let path = data_dir.join("lsp/jdtls/intellij.xml"); std::fs::create_dir_all(path.parent().unwrap())?; - if std::fs::read_to_string(&path).ok().as_deref() != Some(XML) { - ide_vfs::write_atomic(&path, XML.as_bytes())?; + let xml = match indent { + Some(indent) => patch_profile_xml(XML.to_owned(), &indent), + None => XML.to_owned(), + }; + if std::fs::read_to_string(&path).ok().as_deref() != Some(xml.as_str()) { + ide_vfs::write_atomic(&path, xml.as_bytes())?; } (path, PROFILE) } @@ -54,9 +114,22 @@ pub fn configure_formatter( if !path.is_file() { return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display()))); } + // The user's profile defines the style; the override stays editor-only. (path, "") } - _ => return Ok(()), + _ => { + // Default formatter: with a Tabs and Indents override, hand jdtls a + // minimal generated profile carrying it — the format request cannot + // set indentation.size. Without an override the built-in style stays. + let Some(indent) = indent else { return Ok(()) }; + let path = data_dir.join("lsp/jdtls/indent.xml"); + std::fs::create_dir_all(path.parent().unwrap())?; + let xml = indent_profile_xml(&indent); + if std::fs::read_to_string(&path).ok().as_deref() != Some(xml.as_str()) { + ide_vfs::write_atomic(&path, xml.as_bytes())?; + } + (path, GENERATED_PROFILE) + } }; let mut settings = cfg.settings.take().unwrap_or_else(|| json!({})); settings["java"]["format"]["settings"] = formatter_overrides( @@ -144,6 +217,73 @@ mod tests { } } + #[test] + fn tabs_and_indents_override_reaches_the_formatter_profile() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + let data = dir.path().join("data"); + std::fs::create_dir_all(&root).unwrap(); + + // The IntelliJ profile is the bundled XML with the override patched in. + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { + formatter: Some("intellij".into()), + use_tab: Some(true), + tab_size: Some(1), + indent: Some(1), + ..Default::default() + }; + configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap(); + let settings = crate::handler::all_settings(&cfg)["java"]["format"]["settings"].clone(); + assert_eq!(settings["profile"], PROFILE); + let patched = std::fs::read_to_string(data.join("lsp/jdtls/intellij.xml")).unwrap(); + assert!(patched.contains(&format!(r#""#))); + assert!(patched.contains(&format!(r#""#))); + assert!(patched.contains(&format!(r#""#))); + + // The default formatter gets a minimal generated profile. + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { use_tab: Some(false), tab_size: Some(2), ..Default::default() }; + configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap(); + let settings = crate::handler::all_settings(&cfg)["java"]["format"]["settings"].clone(); + let path = data.join("lsp/jdtls/indent.xml"); + assert_eq!(settings["url"], crate::uri::path_to_uri_string(&path)); + assert_eq!(settings["profile"], GENERATED_PROFILE); + let xml = std::fs::read_to_string(&path).unwrap(); + assert!(xml.contains(&format!(r#""#))); + // An unset Indent follows the tab size, as in IDEA. + assert!(xml.contains(&format!(r#""#))); + + // Only Indent: the bundled tab settings stay. + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { formatter: Some("intellij".into()), indent: Some(2), ..Default::default() }; + configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap(); + let patched = std::fs::read_to_string(data.join("lsp/jdtls/intellij.xml")).unwrap(); + assert!(patched.contains(&format!(r#""#))); + assert!(patched.contains(&format!(r#""#))); + assert!(patched.contains(&format!(r#""#))); + } + + #[test] + fn tabs_and_indents_override_leaves_custom_profiles_alone() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + std::fs::create_dir_all(&root).unwrap(); + let custom = root.join("style.xml"); + std::fs::write(&custom, XML).unwrap(); + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { + formatter: Some("custom".into()), + formatter_path: Some("style.xml".into()), + use_tab: Some(true), + tab_size: Some(1), + indent: Some(1), + ..Default::default() + }; + configure_formatter(&mut cfg, Some(&style), &root, dir.path(), None).unwrap(); + assert_eq!(std::fs::read_to_string(&custom).unwrap(), XML); + } + #[test] fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 059b6c139..3197fb1f1 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -2,7 +2,7 @@ * The base extension set of every text editor, with compartments for the * parts that change at runtime (language, settings, contributions). */ -import { Compartment, EditorState, Prec, type Extension, type Range } from "@codemirror/state"; +import { Compartment, EditorState, Facet, Prec, type Extension, type Range } from "@codemirror/state"; import { Decoration, EditorView, @@ -154,13 +154,26 @@ export function hardWrapAt(settings: EditorSettings | undefined, info: LanguageI return (info && style.languages[info.id]?.hardWrapAt) || style.hardWrapAt; } -export function indentExtension(indent: EffectiveIndent): Extension { +/** `format`: the indent Reformat Code asks for (see `formatIndentFacet`); `null` keeps the editor's. */ +export function indentExtension(indent: EffectiveIndent, format: EffectiveIndent | null = null): Extension { return [ + formatIndentFacet.of(format), EditorState.tabSize.of(indent.tabSize), indentUnit.of(indent.insertSpaces ? " ".repeat(Math.max(1, indent.indentSize ?? indent.tabSize)) : "\t"), ]; } +/** + * Indentation explicit reformat requests use: the Code Style without the file's + * detected indents (detection affects editing, reformatting uses the configured + * style, as in IDEA). Set only when the language has an explicit Tabs and Indents + * override — unset, formatting keeps the editor's indent, the status bar choice + * included. + */ +export const formatIndentFacet = Facet.define({ + combine: (values) => [values.find((v) => v !== null) ?? null], +}); + export function fontExtension(settings: EditorSettings | undefined): Extension { const vars: string[] = []; const prefs = prefsOf(settings); @@ -297,6 +310,8 @@ export interface SetupOptions { docLength: number; /** Effective indentation of the document (detected / overridden); computed when absent. */ indent?: EffectiveIndent; + /** Indentation for Reformat Code (`formatIndentFacet`); the editor's when absent. */ + formatIndent?: EffectiveIndent | null; /** Soft wraps toggled for this file (View › Active Editor › Soft-Wrap). */ wrapOverride?: boolean | null; /** Extra extensions owned by the slot (update listeners, focus handlers). */ @@ -358,7 +373,7 @@ export function baseExtensions(o: SetupOptions): Extension { keymap.of([...closeBracketsKeymap, ...editorBaseKeymap, indentWithTab]), compartments.language.of(o.language ?? []), compartments.comments.of(commentTokens(o.info, o.ctx.language)), - compartments.indent.of(indentExtension(o.indent ?? effectiveIndent(o.settings, o.info))), + compartments.indent.of(indentExtension(o.indent ?? effectiveIndent(o.settings, o.info), o.formatIndent)), compartments.readOnly.of(readOnlyExtension(o.ctx.readOnly)), compartments.wrap.of(wrapExtension(o.settings, o.ctx.path, o.wrapOverride)), compartments.sticky.of(stickyExtension(o.settings)), diff --git a/src/editor/core/model/document.test.ts b/src/editor/core/model/document.test.ts index 1b066027e..79c356550 100644 --- a/src/editor/core/model/document.test.ts +++ b/src/editor/core/model/document.test.ts @@ -6,6 +6,7 @@ import { createMemoryBackend, type MemoryBackend } from "../backend"; import { _resetLanguages } from "../cm/languages"; import { DocumentManager, ModifiedRanges, saveCleanupChanges, useDocMeta } from "./document"; import { EditorSlot, type SlotHost } from "./slot"; +import { formatOptionsOf } from "@/editor/lsp/format"; const host: SlotHost = { onFocus: () => {}, @@ -37,6 +38,24 @@ function setup(files: Record, settings = {}) { afterEach(() => useDocMeta.setState({}, true)); describe("DocumentManager + slots", () => { + it("gives a new slot the Code Style indent for Reformat Code, not the detected one", async () => { + const settings = { + editor: { + codeStyle: { + languages: { java: { useTab: true, tabSize: 1, indent: 1 } }, + detectIndents: true, + editorconfig: false, + }, + }, + }; + const { manager } = setup({ "/p/A.java": "class A {\n int x;\n}\n" }, settings); + const model = await manager.acquire("/p/A.java"); + const first = new EditorSlot(model, "t1", host); + const split = new EditorSlot(model, "t2", host, { from: first }); + for (const slot of [first, split]) + expect(formatOptionsOf(slot.currentState())).toEqual({ tabSize: 1, insertSpaces: false }); + }); + it("shares one document between split slots and syncs edits to the backend", async () => { const { backend, manager, frame } = setup({ "/p/a.txt": "hello\n" }); const model = await manager.acquire("/p/a.txt"); diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index a6bd1f182..e0e8355ff 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -405,6 +405,19 @@ export class DocModel { return this.indentOverride ?? effectiveIndent(this.settings(), this.info, this.editorconfig, this.detectedIndent); } + /** + * Indentation explicit reformat requests use (`formatIndentFacet`): with an + * explicit per-language Tabs and Indents override, the Code Style without the + * file's detected indents — the override must reach the formatter, or the + * language server keeps its own style. Without an override formatting keeps + * the editor's indent. + */ + formatIndent() { + const lang = this.info ? prefsOf(this.settings()).codeStyle.languages[this.info.id] : undefined; + if (!lang || (lang.useTab === undefined && lang.tabSize === undefined && lang.indent === undefined)) return null; + return effectiveIndent(this.settings(), this.info, this.editorconfig, null); + } + /** Soft wraps toggled for this file (wins over settings until the file is closed). */ softWrapOverride: boolean | null = null; @@ -429,7 +442,7 @@ export class DocModel { const indent = this.effectiveIndent(); this.setMeta({ indent: { tabSize: indent.tabSize, insertSpaces: indent.insertSpaces } }); const effects = [ - compartments.indent.reconfigure(indentExtension(indent)), + compartments.indent.reconfigure(indentExtension(indent, this.formatIndent())), compartments.wrap.reconfigure(wrapExtension(editor, this.path, this.softWrapOverride)), compartments.sticky.reconfigure(stickyExtension(editor)), compartments.font.reconfigure(fontExtension(editor)), diff --git a/src/editor/core/model/slot.ts b/src/editor/core/model/slot.ts index bb818b1cd..54221ce6c 100644 --- a/src/editor/core/model/slot.ts +++ b/src/editor/core/model/slot.ts @@ -93,6 +93,7 @@ export class EditorSlot { language: model.language, docLength: model.sync.localText.length, indent: model.effectiveIndent(), + formatIndent: model.formatIndent(), wrapOverride: model.softWrapOverride, extra: this.slotExtensions(), }), diff --git a/src/editor/lsp/format.test.ts b/src/editor/lsp/format.test.ts index 8f21dd7c7..71f46be70 100644 --- a/src/editor/lsp/format.test.ts +++ b/src/editor/lsp/format.test.ts @@ -11,7 +11,42 @@ import { protectedRegions, type FormatGuardOptions, } from "@/platform/formatGuard"; -import { dedentsToColumnZero, formatDocument, formattedLines } from "./format"; +import { indentExtension } from "@/editor/core/cm/setup"; +import { dedentsToColumnZero, formatDocument, formatOptionsOf, formattedLines } from "./format"; + +describe("formatOptionsOf", () => { + const stateWith = (...args: Parameters) => + EditorState.create({ extensions: indentExtension(...args) }); + + it("asks for the Code Style override, not the indent detected in the file", () => { + // Code Style › Java: tab character, tab size 1; the file was indented with 4 spaces. + const state = stateWith({ tabSize: 4, insertSpaces: true }, { tabSize: 1, insertSpaces: false, indentSize: 1 }); + expect(formatOptionsOf(state)).toEqual({ tabSize: 1, insertSpaces: false }); + }); + + it("sends the tab size with tabs and the indent size with spaces", () => { + const editor = { tabSize: 8, insertSpaces: true }; + expect(formatOptionsOf(stateWith(editor, { tabSize: 4, insertSpaces: false, indentSize: 2 }))).toEqual({ + tabSize: 4, + insertSpaces: false, + }); + expect(formatOptionsOf(stateWith(editor, { tabSize: 4, insertSpaces: true, indentSize: 2 }))).toEqual({ + tabSize: 2, + insertSpaces: true, + }); + }); + + it("keeps the editor's indent without an override", () => { + expect(formatOptionsOf(stateWith({ tabSize: 4, insertSpaces: true, indentSize: 2 }))).toEqual({ + tabSize: 2, + insertSpaces: true, + }); + expect(formatOptionsOf(stateWith({ tabSize: 3, insertSpaces: false }))).toEqual({ + tabSize: 3, + insertSpaces: false, + }); + }); +}); describe("formattedLines", () => { const doc = Text.of(["fn main(){", "let x=1;", "}", "", "fn other() {}"]); diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 5d3031c3c..e3184a94d 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -24,6 +24,7 @@ import { import { useProject } from "@/platform/project"; import { CONTRIBUTED_FORMAT, contributedAt, whenSynced, type ContributedFormatter } from "@/editor/api"; import { docOf, lspRequest, reportLspError } from "./lsp"; +import { formatIndentFacet } from "../core/cm/setup"; import { offsetToPos, textEditsToChanges } from "./positions"; import { tx, txTemplate } from "@/platform/i18n"; import { showHint } from "./hint"; @@ -71,8 +72,17 @@ function formattedHint(view: EditorView, lines: number) { }); } -/** LSP `FormattingOptions`: with spaces the indent width is the indent unit, not the tab size. */ +/** + * LSP `FormattingOptions`: with spaces the indent width is the indent unit, not the tab size. + * An explicit Code Style override replaces the editor's indent; the server's formatter + * profile carries the same override, so both agree. + */ export function formatOptionsOf(state: EditorState): { tabSize: number; insertSpaces: boolean } { + const style = state.facet(formatIndentFacet)[0]; + if (style) + return style.insertSpaces + ? { tabSize: style.indentSize ?? style.tabSize, insertSpaces: true } + : { tabSize: style.tabSize, insertSpaces: false }; const unit = state.facet(indentUnit); return unit.includes("\t") ? { tabSize: state.tabSize, insertSpaces: false }