diff --git a/rewrite-core/src/main/java/org/openrewrite/config/EditorConfigParser.java b/rewrite-core/src/main/java/org/openrewrite/config/EditorConfigParser.java
new file mode 100644
index 00000000000..160d5761091
--- /dev/null
+++ b/rewrite-core/src/main/java/org/openrewrite/config/EditorConfigParser.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.config;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+
+/**
+ * Parses a single {@code .editorconfig} file into a structured representation.
+ */
+public class EditorConfigParser {
+
+ public static class EditorConfigFile {
+ private final boolean root;
+ private final List sections;
+
+ public EditorConfigFile(boolean root, List sections) {
+ this.root = root;
+ this.sections = sections;
+ }
+
+ public boolean isRoot() {
+ return root;
+ }
+
+ public List getSections() {
+ return sections;
+ }
+ }
+
+ public static class Section {
+ private final String pattern;
+ private final Map properties;
+
+ public Section(String pattern, Map properties) {
+ this.pattern = pattern;
+ this.properties = properties;
+ }
+
+ public String getPattern() {
+ return pattern;
+ }
+
+ public Map getProperties() {
+ return properties;
+ }
+ }
+
+ public EditorConfigFile parse(Path path) throws IOException {
+ List lines = Files.readAllLines(path);
+ return parse(lines);
+ }
+
+ public EditorConfigFile parse(List lines) {
+ boolean root = false;
+ List sections = new ArrayList<>();
+ String currentPattern = null;
+ Map currentProperties = null;
+
+ for (String rawLine : lines) {
+ String line = rawLine.trim();
+
+ // Skip empty lines and comments
+ if (line.isEmpty() || line.charAt(0) == '#' || line.charAt(0) == ';') {
+ continue;
+ }
+
+ // Section header
+ if (line.startsWith("[") && line.endsWith("]")) {
+ if (currentPattern != null && currentProperties != null) {
+ sections.add(new Section(currentPattern, currentProperties));
+ }
+ currentPattern = line.substring(1, line.length() - 1).trim();
+ currentProperties = new LinkedHashMap<>();
+ continue;
+ }
+
+ // Key-value pair
+ int eqIdx = line.indexOf('=');
+ if (eqIdx < 0) {
+ continue;
+ }
+ String key = line.substring(0, eqIdx).trim().toLowerCase(Locale.ENGLISH);
+ String value = line.substring(eqIdx + 1).trim().toLowerCase(Locale.ENGLISH);
+
+ if (currentPattern == null) {
+ // Preamble (before any section)
+ if ("root".equals(key) && "true".equals(value)) {
+ root = true;
+ }
+ } else {
+ currentProperties.put(key, value);
+ }
+ }
+
+ // Flush last section
+ if (currentPattern != null && currentProperties != null) {
+ sections.add(new Section(currentPattern, currentProperties));
+ }
+
+ return new EditorConfigFile(root, sections);
+ }
+}
diff --git a/rewrite-core/src/main/java/org/openrewrite/config/EditorConfigResolver.java b/rewrite-core/src/main/java/org/openrewrite/config/EditorConfigResolver.java
new file mode 100644
index 00000000000..ad56f64db42
--- /dev/null
+++ b/rewrite-core/src/main/java/org/openrewrite/config/EditorConfigResolver.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.config;
+
+import org.openrewrite.PathUtils;
+import org.openrewrite.internal.lang.Nullable;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+
+/**
+ * Resolves effective {@code .editorconfig} properties for a given file by walking
+ * directories upward from the file's location to the project root, collecting and
+ * merging matching sections. Child directories override parent directories.
+ */
+public class EditorConfigResolver {
+ private static final String EDITOR_CONFIG_FILE = ".editorconfig";
+
+ private final @Nullable Path projectRoot;
+ private final EditorConfigParser parser = new EditorConfigParser();
+ private final Map parsedFileCache = new HashMap<>();
+ private final Map> configsPerDirectory = new HashMap<>();
+
+ public EditorConfigResolver(@Nullable Path projectRoot) {
+ this.projectRoot = projectRoot != null ? projectRoot.toAbsolutePath().normalize() : null;
+ }
+
+ /**
+ * Resolve the effective editorconfig properties for the given file path.
+ *
+ * @param filePath absolute path to the file
+ * @return merged properties map, or empty if no .editorconfig applies
+ */
+ public Map resolve(Path filePath) {
+ Path absPath = filePath.toAbsolutePath().normalize();
+ Path dir = absPath.getParent();
+ if (dir == null) {
+ return Collections.emptyMap();
+ }
+
+ String fileName = absPath.getFileName().toString();
+ List configs = collectConfigs(dir);
+
+ // Merge matching sections
+ Map merged = new LinkedHashMap<>();
+ for (EditorConfigParser.EditorConfigFile config : configs) {
+ for (EditorConfigParser.Section section : config.getSections()) {
+ if (PathUtils.matchesGlob(fileName, section.getPattern())) {
+ merged.putAll(section.getProperties());
+ }
+ }
+ }
+ return merged;
+ }
+
+ private List collectConfigs(Path dir) {
+ List cached = configsPerDirectory.get(dir);
+ if (cached != null) {
+ return cached;
+ }
+
+ List configs = new ArrayList<>();
+ Path current = dir;
+ while (current != null) {
+ Path ecFile = current.resolve(EDITOR_CONFIG_FILE);
+ if (Files.isRegularFile(ecFile)) {
+ EditorConfigParser.EditorConfigFile parsed = parseFile(ecFile);
+ if (parsed != null) {
+ configs.add(parsed);
+ if (parsed.isRoot()) {
+ break;
+ }
+ }
+ }
+ if (projectRoot != null && current.equals(projectRoot)) {
+ break;
+ }
+ Path parent = current.getParent();
+ if (parent != null && parent.equals(current)) {
+ break;
+ }
+ current = parent;
+ }
+
+ // Reverse so parents come first, children override
+ Collections.reverse(configs);
+ configsPerDirectory.put(dir, configs);
+ return configs;
+ }
+
+ private @Nullable EditorConfigParser.EditorConfigFile parseFile(Path ecFile) {
+ return parsedFileCache.computeIfAbsent(ecFile, f -> {
+ try {
+ return parser.parse(f);
+ } catch (IOException e) {
+ return null;
+ }
+ });
+ }
+}
diff --git a/rewrite-core/src/main/java/org/openrewrite/config/EditorConfigStyles.java b/rewrite-core/src/main/java/org/openrewrite/config/EditorConfigStyles.java
new file mode 100644
index 00000000000..8e6a56b16da
--- /dev/null
+++ b/rewrite-core/src/main/java/org/openrewrite/config/EditorConfigStyles.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.config;
+
+import org.jspecify.annotations.Nullable;
+import org.openrewrite.style.GeneralFormatStyle;
+
+import java.util.Map;
+
+/**
+ * Static helpers for mapping universal {@code .editorconfig} properties to
+ * language-neutral style values. Language modules compose these into their
+ * own style objects.
+ */
+public final class EditorConfigStyles {
+ private EditorConfigStyles() {
+ }
+
+ /**
+ * @return {@code true} for tabs, {@code false} for spaces, {@code null} if unset
+ */
+ public static @Nullable Boolean useTabCharacter(Map props) {
+ String value = props.get("indent_style");
+ if ("tab".equals(value)) {
+ return true;
+ } else if ("space".equals(value)) {
+ return false;
+ }
+ return null;
+ }
+
+ /**
+ * @return the indent size, or {@code null} if unset. Handles the special case
+ * where {@code indent_size=tab} means use the {@code tab_width} value.
+ */
+ public static @Nullable Integer indentSize(Map props) {
+ String value = props.get("indent_size");
+ if (value == null) {
+ return null;
+ }
+ if ("tab".equals(value)) {
+ return tabSize(props);
+ }
+ return parsePositiveInt(value);
+ }
+
+ /**
+ * @return the tab size, or {@code null} if unset. Falls back to {@code indent_size}
+ * if {@code tab_width} is not explicitly set.
+ */
+ public static @Nullable Integer tabSize(Map props) {
+ String value = props.get("tab_width");
+ if (value != null) {
+ return parsePositiveInt(value);
+ }
+ // Per spec, tab_width defaults to indent_size when not set
+ String indentSizeValue = props.get("indent_size");
+ if (indentSizeValue != null && !"tab".equals(indentSizeValue)) {
+ return parsePositiveInt(indentSizeValue);
+ }
+ return null;
+ }
+
+ /**
+ * @return a {@link GeneralFormatStyle} based on {@code end_of_line}, or {@code null} if unset
+ */
+ public static @Nullable GeneralFormatStyle generalFormatStyle(Map props) {
+ String value = props.get("end_of_line");
+ if ("crlf".equals(value)) {
+ return new GeneralFormatStyle(true);
+ } else if ("lf".equals(value) || "cr".equals(value)) {
+ return new GeneralFormatStyle(false);
+ }
+ return null;
+ }
+
+ private static @Nullable Integer parsePositiveInt(String value) {
+ try {
+ int parsed = Integer.parseInt(value);
+ return parsed > 0 ? parsed : null;
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+}
diff --git a/rewrite-core/src/main/java/org/openrewrite/tree/ParsingExecutionContextView.java b/rewrite-core/src/main/java/org/openrewrite/tree/ParsingExecutionContextView.java
index 50270d48f58..72dfffbd9e5 100644
--- a/rewrite-core/src/main/java/org/openrewrite/tree/ParsingExecutionContextView.java
+++ b/rewrite-core/src/main/java/org/openrewrite/tree/ParsingExecutionContextView.java
@@ -18,6 +18,7 @@
import org.jspecify.annotations.Nullable;
import org.openrewrite.DelegatingExecutionContext;
import org.openrewrite.ExecutionContext;
+import org.openrewrite.config.EditorConfigResolver;
import java.nio.charset.Charset;
@@ -26,6 +27,8 @@ public class ParsingExecutionContextView extends DelegatingExecutionContext {
private static final String CHARSET = "org.openrewrite.parser.charset";
+ private static final String EDITOR_CONFIG_RESOLVER = "org.openrewrite.parser.editorConfigResolver";
+
public ParsingExecutionContextView(ExecutionContext delegate) {
super(delegate);
}
@@ -54,4 +57,13 @@ public ParsingExecutionContextView setCharset(@Nullable Charset charset) {
public @Nullable Charset getCharset() {
return getMessage(CHARSET);
}
+
+ public ParsingExecutionContextView setEditorConfigResolver(EditorConfigResolver resolver) {
+ putMessage(EDITOR_CONFIG_RESOLVER, resolver);
+ return this;
+ }
+
+ public @Nullable EditorConfigResolver getEditorConfigResolver() {
+ return getMessage(EDITOR_CONFIG_RESOLVER);
+ }
}
diff --git a/rewrite-core/src/test/java/org/openrewrite/config/EditorConfigParserTest.java b/rewrite-core/src/test/java/org/openrewrite/config/EditorConfigParserTest.java
new file mode 100644
index 00000000000..2bd3c488ba2
--- /dev/null
+++ b/rewrite-core/src/test/java/org/openrewrite/config/EditorConfigParserTest.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.config;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class EditorConfigParserTest {
+
+ private final EditorConfigParser parser = new EditorConfigParser();
+
+ @Test
+ void emptyFile() {
+ EditorConfigParser.EditorConfigFile result = parser.parse(Collections.emptyList());
+ assertThat(result.isRoot()).isFalse();
+ assertThat(result.getSections()).isEmpty();
+ }
+
+ @Test
+ void rootTrue() {
+ EditorConfigParser.EditorConfigFile result = parser.parse(Arrays.asList(
+ "root = true",
+ "",
+ "[*]",
+ "indent_style = space"
+ ));
+ assertThat(result.isRoot()).isTrue();
+ assertThat(result.getSections()).hasSize(1);
+ }
+
+ @Test
+ void rootFalseByDefault() {
+ EditorConfigParser.EditorConfigFile result = parser.parse(Arrays.asList(
+ "[*]",
+ "indent_size = 4"
+ ));
+ assertThat(result.isRoot()).isFalse();
+ }
+
+ @Test
+ void multipleSections() {
+ EditorConfigParser.EditorConfigFile result = parser.parse(Arrays.asList(
+ "[*]",
+ "indent_style = space",
+ "indent_size = 4",
+ "",
+ "[*.xml]",
+ "indent_size = 2"
+ ));
+ assertThat(result.getSections()).hasSize(2);
+ assertThat(result.getSections().get(0).getPattern()).isEqualTo("*");
+ assertThat(result.getSections().get(0).getProperties()).containsEntry("indent_style", "space");
+ assertThat(result.getSections().get(0).getProperties()).containsEntry("indent_size", "4");
+ assertThat(result.getSections().get(1).getPattern()).isEqualTo("*.xml");
+ assertThat(result.getSections().get(1).getProperties()).containsEntry("indent_size", "2");
+ }
+
+ @Test
+ void commentsAreSkipped() {
+ EditorConfigParser.EditorConfigFile result = parser.parse(Arrays.asList(
+ "# This is a comment",
+ "; This is also a comment",
+ "[*]",
+ "# inline comment line",
+ "indent_style = space"
+ ));
+ assertThat(result.getSections()).hasSize(1);
+ assertThat(result.getSections().get(0).getProperties()).hasSize(1);
+ }
+
+ @Test
+ void whitespaceAroundEquals() {
+ EditorConfigParser.EditorConfigFile result = parser.parse(Arrays.asList(
+ "[*]",
+ "indent_style=tab",
+ "indent_size =\t4",
+ " tab_width = 8 "
+ ));
+ assertThat(result.getSections().get(0).getProperties())
+ .containsEntry("indent_style", "tab")
+ .containsEntry("indent_size", "4")
+ .containsEntry("tab_width", "8");
+ }
+
+ @Test
+ void malformedLinesAreSkipped() {
+ EditorConfigParser.EditorConfigFile result = parser.parse(Arrays.asList(
+ "[*]",
+ "no_equals_sign",
+ "indent_size = 4"
+ ));
+ assertThat(result.getSections().get(0).getProperties()).hasSize(1);
+ assertThat(result.getSections().get(0).getProperties()).containsEntry("indent_size", "4");
+ }
+
+ @Test
+ void keysAreLowercased() {
+ EditorConfigParser.EditorConfigFile result = parser.parse(Arrays.asList(
+ "[*]",
+ "Indent_Style = Space",
+ "INDENT_SIZE = 4"
+ ));
+ assertThat(result.getSections().get(0).getProperties())
+ .containsEntry("indent_style", "space")
+ .containsEntry("indent_size", "4");
+ }
+
+ @Test
+ void braceExpansionPattern() {
+ EditorConfigParser.EditorConfigFile result = parser.parse(Arrays.asList(
+ "[*.{xml,xsl}]",
+ "indent_size = 2"
+ ));
+ assertThat(result.getSections().get(0).getPattern()).isEqualTo("*.{xml,xsl}");
+ }
+}
diff --git a/rewrite-core/src/test/java/org/openrewrite/config/EditorConfigResolverTest.java b/rewrite-core/src/test/java/org/openrewrite/config/EditorConfigResolverTest.java
new file mode 100644
index 00000000000..f6aaa714e51
--- /dev/null
+++ b/rewrite-core/src/test/java/org/openrewrite/config/EditorConfigResolverTest.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.xml.style;
+
+import org.jspecify.annotations.Nullable;
+import org.openrewrite.Tree;
+import org.openrewrite.config.EditorConfigStyles;
+import org.openrewrite.style.GeneralFormatStyle;
+import org.openrewrite.style.NamedStyles;
+import org.openrewrite.style.Style;
+
+import java.util.*;
+
+/**
+ * Converts resolved {@code .editorconfig} properties to XML-specific {@link NamedStyles}.
+ */
+public final class EditorConfigStyleMapper {
+ private EditorConfigStyleMapper() {
+ }
+
+ /**
+ * Creates a {@link NamedStyles} from editorconfig properties containing XML-relevant styles.
+ *
+ * @return a NamedStyles marker, or {@code null} if no relevant properties are present
+ */
+ public static @Nullable NamedStyles fromEditorConfig(Map properties) {
+ List