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 + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class EditorConfigResolverTest { + + @TempDir + Path tempDir; + + @Test + void noEditorConfig() { + EditorConfigResolver resolver = new EditorConfigResolver(tempDir); + Map props = resolver.resolve(tempDir.resolve("pom.xml")); + assertThat(props).isEmpty(); + } + + @Test + void singleEditorConfigAtRoot() throws IOException { + Files.write(tempDir.resolve(".editorconfig"), Arrays.asList( + "root = true", + "", + "[*]", + "indent_style = space", + "indent_size = 4" + )); + EditorConfigResolver resolver = new EditorConfigResolver(tempDir); + Map props = resolver.resolve(tempDir.resolve("pom.xml")); + assertThat(props) + .containsEntry("indent_style", "space") + .containsEntry("indent_size", "4"); + } + + @Test + void xmlSpecificSection() throws IOException { + Files.write(tempDir.resolve(".editorconfig"), Arrays.asList( + "root = true", + "", + "[*]", + "indent_size = 4", + "", + "[*.xml]", + "indent_size = 2" + )); + EditorConfigResolver resolver = new EditorConfigResolver(tempDir); + Map props = resolver.resolve(tempDir.resolve("pom.xml")); + assertThat(props).containsEntry("indent_size", "2"); + } + + @Test + void childOverridesParent() throws IOException { + Files.write(tempDir.resolve(".editorconfig"), Arrays.asList( + "root = true", + "", + "[*]", + "indent_size = 4" + )); + Path subDir = Files.createDirectories(tempDir.resolve("src")); + Files.write(subDir.resolve(".editorconfig"), Arrays.asList( + "[*]", + "indent_size = 2" + )); + EditorConfigResolver resolver = new EditorConfigResolver(tempDir); + Map props = resolver.resolve(subDir.resolve("pom.xml")); + assertThat(props).containsEntry("indent_size", "2"); + } + + @Test + void childInheritsFromParent() throws IOException { + Files.write(tempDir.resolve(".editorconfig"), Arrays.asList( + "root = true", + "", + "[*]", + "indent_style = space", + "indent_size = 4" + )); + Path subDir = Files.createDirectories(tempDir.resolve("src")); + Files.write(subDir.resolve(".editorconfig"), Arrays.asList( + "[*.xml]", + "indent_size = 2" + )); + EditorConfigResolver resolver = new EditorConfigResolver(tempDir); + Map props = resolver.resolve(subDir.resolve("pom.xml")); + assertThat(props) + .containsEntry("indent_style", "space") + .containsEntry("indent_size", "2"); + } + + @Test + void rootTrueStopsWalking() throws IOException { + Files.write(tempDir.resolve(".editorconfig"), Arrays.asList( + "root = true", + "", + "[*]", + "indent_size = 4" + )); + Path subDir = Files.createDirectories(tempDir.resolve("sub")); + Files.write(subDir.resolve(".editorconfig"), Arrays.asList( + "root = true", + "", + "[*]", + "indent_size = 2" + )); + EditorConfigResolver resolver = new EditorConfigResolver(tempDir); + Map props = resolver.resolve(subDir.resolve("file.xml")); + // Should only see the sub/.editorconfig since it has root=true + assertThat(props).containsEntry("indent_size", "2"); + } + + @Test + void braceExpansionPattern() throws IOException { + Files.write(tempDir.resolve(".editorconfig"), Arrays.asList( + "root = true", + "", + "[*.{xml,xsl}]", + "indent_size = 2" + )); + EditorConfigResolver resolver = new EditorConfigResolver(tempDir); + assertThat(resolver.resolve(tempDir.resolve("file.xml"))).containsEntry("indent_size", "2"); + assertThat(resolver.resolve(tempDir.resolve("file.xsl"))).containsEntry("indent_size", "2"); + assertThat(resolver.resolve(tempDir.resolve("file.java"))).isEmpty(); + } + + @Test + void sameDirectoryReturnsConsistentResults() throws IOException { + Files.write(tempDir.resolve(".editorconfig"), Arrays.asList( + "root = true", + "", + "[*]", + "indent_size = 4" + )); + EditorConfigResolver resolver = new EditorConfigResolver(tempDir); + Map first = resolver.resolve(tempDir.resolve("a.xml")); + Map second = resolver.resolve(tempDir.resolve("b.xml")); + assertThat(first).isEqualTo(second); + } + + @Test + void tabSettings() throws IOException { + Files.write(tempDir.resolve(".editorconfig"), Arrays.asList( + "root = true", + "", + "[*]", + "indent_style = tab", + "tab_width = 4" + )); + EditorConfigResolver resolver = new EditorConfigResolver(tempDir); + Map props = resolver.resolve(tempDir.resolve("pom.xml")); + assertThat(props) + .containsEntry("indent_style", "tab") + .containsEntry("tab_width", "4"); + } + + @Test + void endOfLineProperty() throws IOException { + Files.write(tempDir.resolve(".editorconfig"), Arrays.asList( + "root = true", + "", + "[*]", + "end_of_line = crlf" + )); + EditorConfigResolver resolver = new EditorConfigResolver(tempDir); + Map props = resolver.resolve(tempDir.resolve("pom.xml")); + assertThat(props).containsEntry("end_of_line", "crlf"); + } +} diff --git a/rewrite-core/src/test/java/org/openrewrite/config/EditorConfigStylesTest.java b/rewrite-core/src/test/java/org/openrewrite/config/EditorConfigStylesTest.java new file mode 100644 index 00000000000..e3d722083e4 --- /dev/null +++ b/rewrite-core/src/test/java/org/openrewrite/config/EditorConfigStylesTest.java @@ -0,0 +1,123 @@ +/* + * 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 + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 org.openrewrite.style.GeneralFormatStyle; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class EditorConfigStylesTest { + + @Test + void useTabCharacterTab() { + Map props = new HashMap<>(); + props.put("indent_style", "tab"); + assertThat(EditorConfigStyles.useTabCharacter(props)).isTrue(); + } + + @Test + void useTabCharacterSpace() { + Map props = new HashMap<>(); + props.put("indent_style", "space"); + assertThat(EditorConfigStyles.useTabCharacter(props)).isFalse(); + } + + @Test + void useTabCharacterUnset() { + assertThat(EditorConfigStyles.useTabCharacter(new HashMap<>())).isNull(); + } + + @Test + void indentSizeNumeric() { + Map props = new HashMap<>(); + props.put("indent_size", "4"); + assertThat(EditorConfigStyles.indentSize(props)).isEqualTo(4); + } + + @Test + void indentSizeTab() { + Map props = new HashMap<>(); + props.put("indent_size", "tab"); + props.put("tab_width", "8"); + assertThat(EditorConfigStyles.indentSize(props)).isEqualTo(8); + } + + @Test + void indentSizeUnset() { + assertThat(EditorConfigStyles.indentSize(new HashMap<>())).isNull(); + } + + @Test + void tabSizeExplicit() { + Map props = new HashMap<>(); + props.put("tab_width", "8"); + props.put("indent_size", "4"); + assertThat(EditorConfigStyles.tabSize(props)).isEqualTo(8); + } + + @Test + void tabSizeFallsBackToIndentSize() { + Map props = new HashMap<>(); + props.put("indent_size", "4"); + assertThat(EditorConfigStyles.tabSize(props)).isEqualTo(4); + } + + @Test + void tabSizeUnset() { + assertThat(EditorConfigStyles.tabSize(new HashMap<>())).isNull(); + } + + @Test + void generalFormatStyleCrlf() { + Map props = new HashMap<>(); + props.put("end_of_line", "crlf"); + GeneralFormatStyle style = EditorConfigStyles.generalFormatStyle(props); + assertThat(style).isNotNull(); + assertThat(style.isUseCRLFNewLines()).isTrue(); + } + + @Test + void generalFormatStyleLf() { + Map props = new HashMap<>(); + props.put("end_of_line", "lf"); + GeneralFormatStyle style = EditorConfigStyles.generalFormatStyle(props); + assertThat(style).isNotNull(); + assertThat(style.isUseCRLFNewLines()).isFalse(); + } + + @Test + void generalFormatStyleUnset() { + assertThat(EditorConfigStyles.generalFormatStyle(new HashMap<>())).isNull(); + } + + @Test + void invalidIndentSizeIgnored() { + Map props = new HashMap<>(); + props.put("indent_size", "abc"); + assertThat(EditorConfigStyles.indentSize(props)).isNull(); + } + + @Test + void zeroIndentSizeIgnored() { + Map props = new HashMap<>(); + props.put("indent_size", "0"); + assertThat(EditorConfigStyles.indentSize(props)).isNull(); + } +} diff --git a/rewrite-xml/src/main/java/org/openrewrite/xml/XmlParser.java b/rewrite-xml/src/main/java/org/openrewrite/xml/XmlParser.java index 0bf118b910a..8c648a70ca4 100755 --- a/rewrite-xml/src/main/java/org/openrewrite/xml/XmlParser.java +++ b/rewrite-xml/src/main/java/org/openrewrite/xml/XmlParser.java @@ -22,18 +22,23 @@ import org.openrewrite.InMemoryExecutionContext; import org.openrewrite.Parser; import org.openrewrite.SourceFile; +import org.openrewrite.config.EditorConfigResolver; import org.openrewrite.internal.EncodingDetectingInputStream; + +import org.openrewrite.style.NamedStyles; import org.openrewrite.tree.ParseError; import org.openrewrite.tree.ParsingEventListener; import org.openrewrite.tree.ParsingExecutionContextView; import org.openrewrite.xml.internal.XmlParserVisitor; import org.openrewrite.xml.internal.grammar.XMLLexer; import org.openrewrite.xml.internal.grammar.XMLParser; +import org.openrewrite.xml.style.EditorConfigStyleMapper; import org.openrewrite.xml.tree.Xml; import java.nio.file.Path; import java.util.Arrays; import java.util.HashSet; +import java.util.Map; import java.util.Set; import java.util.stream.Stream; @@ -76,7 +81,9 @@ public class XmlParser implements Parser { @Override public Stream parseInputs(Iterable sourceFiles, @Nullable Path relativeTo, ExecutionContext ctx) { - ParsingEventListener parsingListener = ParsingExecutionContextView.view(ctx).getParsingListener(); + ParsingExecutionContextView view = ParsingExecutionContextView.view(ctx); + ParsingEventListener parsingListener = view.getParsingListener(); + EditorConfigResolver editorConfigResolver = view.getEditorConfigResolver(); return acceptedInputs(sourceFiles).map(input -> { parsingListener.startedParsing(input); Path path = input.getRelativePath(relativeTo); @@ -98,6 +105,17 @@ public Stream parseInputs(Iterable sourceFiles, @Nullable Pat is.getCharset(), is.isCharsetBomMarked() ).visitDocument(parser.document()); + + if (editorConfigResolver != null && !input.isSynthetic() && input.getPath().isAbsolute()) { + Map ecProps = editorConfigResolver.resolve(input.getPath()); + if (!ecProps.isEmpty()) { + NamedStyles ecStyles = EditorConfigStyleMapper.fromEditorConfig(ecProps); + if (ecStyles != null) { + document = document.withMarkers(document.getMarkers().add(ecStyles)); + } + } + } + parsingListener.parsed(input, document); return requirePrintEqualsInput(document, input, relativeTo, ctx); } catch (Throwable t) { diff --git a/rewrite-xml/src/main/java/org/openrewrite/xml/style/EditorConfigStyleMapper.java b/rewrite-xml/src/main/java/org/openrewrite/xml/style/EditorConfigStyleMapper.java new file mode 100644 index 00000000000..fdb64004bda --- /dev/null +++ b/rewrite-xml/src/main/java/org/openrewrite/xml/style/EditorConfigStyleMapper.java @@ -0,0 +1,69 @@ +/* + * 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 + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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