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 }