fix(format): apply the Code Style indent override to reformat requests (#949)

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 <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-11-03 14:56:56 +08:00
1 джерело 18e16e76a4
коміт fcfe26474d
7 files changed
+242 -9

No files matched your search

+143 -3
Переглянути файл
@@ -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<Vec<(&'static str, String)>> {
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#"<setting id="{id}" value=""#);
let Some(at) = xml.find(&marker) else { continue };
let start = at + marker.len();
let Some(end) = xml[start..].find('"') else { continue };
xml.replace_range(start..start + end, value);
}
xml
}
/// A minimal Eclipse profile with just the override: unset keys keep jdtls's
/// built-in formatter defaults.
fn indent_profile_xml(settings: &[(&'static str, String)]) -> String {
let mut xml = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<profiles version="21">
<profile kind="CodeFormatterProfile" name="{}" version="21">
"#,
GENERATED_PROFILE
);
for (id, value) in settings {
xml.push_str(&format!(r#" <setting id="{id}" value="{value}"/>"#));
xml.push('\n');
}
xml.push_str(" </profile>\n</profiles>\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#"<setting id="{TAB_CHAR}" value="tab"/>"#)));
assert!(patched.contains(&format!(r#"<setting id="{TAB_SIZE}" value="1"/>"#)));
assert!(patched.contains(&format!(r#"<setting id="{INDENT_SIZE}" value="1"/>"#)));
// 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#"<setting id="{TAB_CHAR}" value="space"/>"#)));
// An unset Indent follows the tab size, as in IDEA.
assert!(xml.contains(&format!(r#"<setting id="{INDENT_SIZE}" value="2"/>"#)));
// 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#"<setting id="{INDENT_SIZE}" value="2"/>"#)));
assert!(patched.contains(&format!(r#"<setting id="{TAB_CHAR}" value="space"/>"#)));
assert!(patched.contains(&format!(r#"<setting id="{TAB_SIZE}" value="4"/>"#)));
}
#[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();
+18 -3
Переглянути файл
@@ -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<EffectiveIndent | null>({
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)),
+19
Переглянути файл
@@ -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<string, string>, 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");
+14 -1
Переглянути файл
@@ -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)),
+1
Переглянути файл
@@ -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(),
}),
+36 -1
Переглянути файл
@@ -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<typeof indentExtension>) =>
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() {}"]);
+11 -1
Переглянути файл
@@ -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 }