Skip to content

Commit 87b5465

Browse files
committed
Add .editorconfig support for XML style detection
Resolves #3666 — adds infrastructure to read .editorconfig files and apply indent_style, indent_size, tab_width, and end_of_line settings as NamedStyles markers on parsed XML documents. Callers opt in by placing an EditorConfigResolver on the ParsingExecutionContextView; the parser stays free of implicit disk I/O.
1 parent d8dfde6 commit 87b5465

11 files changed

Lines changed: 1158 additions & 1 deletion

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.config;
17+
18+
import java.io.IOException;
19+
import java.nio.file.Files;
20+
import java.nio.file.Path;
21+
import java.util.*;
22+
23+
/**
24+
* Parses a single {@code .editorconfig} file into a structured representation.
25+
*/
26+
public class EditorConfigParser {
27+
28+
public static class EditorConfigFile {
29+
private final boolean root;
30+
private final List<Section> sections;
31+
32+
public EditorConfigFile(boolean root, List<Section> sections) {
33+
this.root = root;
34+
this.sections = sections;
35+
}
36+
37+
public boolean isRoot() {
38+
return root;
39+
}
40+
41+
public List<Section> getSections() {
42+
return sections;
43+
}
44+
}
45+
46+
public static class Section {
47+
private final String pattern;
48+
private final Map<String, String> properties;
49+
50+
public Section(String pattern, Map<String, String> properties) {
51+
this.pattern = pattern;
52+
this.properties = properties;
53+
}
54+
55+
public String getPattern() {
56+
return pattern;
57+
}
58+
59+
public Map<String, String> getProperties() {
60+
return properties;
61+
}
62+
}
63+
64+
public EditorConfigFile parse(Path path) throws IOException {
65+
List<String> lines = Files.readAllLines(path);
66+
return parse(lines);
67+
}
68+
69+
public EditorConfigFile parse(List<String> lines) {
70+
boolean root = false;
71+
List<Section> sections = new ArrayList<>();
72+
String currentPattern = null;
73+
Map<String, String> currentProperties = null;
74+
75+
for (String rawLine : lines) {
76+
String line = rawLine.trim();
77+
78+
// Skip empty lines and comments
79+
if (line.isEmpty() || line.charAt(0) == '#' || line.charAt(0) == ';') {
80+
continue;
81+
}
82+
83+
// Section header
84+
if (line.startsWith("[") && line.endsWith("]")) {
85+
if (currentPattern != null && currentProperties != null) {
86+
sections.add(new Section(currentPattern, currentProperties));
87+
}
88+
currentPattern = line.substring(1, line.length() - 1).trim();
89+
currentProperties = new LinkedHashMap<>();
90+
continue;
91+
}
92+
93+
// Key-value pair
94+
int eqIdx = line.indexOf('=');
95+
if (eqIdx < 0) {
96+
continue;
97+
}
98+
String key = line.substring(0, eqIdx).trim().toLowerCase(Locale.ENGLISH);
99+
String value = line.substring(eqIdx + 1).trim().toLowerCase(Locale.ENGLISH);
100+
101+
if (currentPattern == null) {
102+
// Preamble (before any section)
103+
if ("root".equals(key) && "true".equals(value)) {
104+
root = true;
105+
}
106+
} else {
107+
currentProperties.put(key, value);
108+
}
109+
}
110+
111+
// Flush last section
112+
if (currentPattern != null && currentProperties != null) {
113+
sections.add(new Section(currentPattern, currentProperties));
114+
}
115+
116+
return new EditorConfigFile(root, sections);
117+
}
118+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.config;
17+
18+
import org.openrewrite.PathUtils;
19+
import org.openrewrite.internal.lang.Nullable;
20+
21+
import java.io.IOException;
22+
import java.nio.file.Files;
23+
import java.nio.file.Path;
24+
import java.util.*;
25+
26+
/**
27+
* Resolves effective {@code .editorconfig} properties for a given file by walking
28+
* directories upward from the file's location to the project root, collecting and
29+
* merging matching sections. Child directories override parent directories.
30+
*/
31+
public class EditorConfigResolver {
32+
private static final String EDITOR_CONFIG_FILE = ".editorconfig";
33+
34+
private final @Nullable Path projectRoot;
35+
private final EditorConfigParser parser = new EditorConfigParser();
36+
private final Map<Path, EditorConfigParser.EditorConfigFile> parsedFileCache = new HashMap<>();
37+
private final Map<Path, List<EditorConfigParser.EditorConfigFile>> configsPerDirectory = new HashMap<>();
38+
39+
public EditorConfigResolver(@Nullable Path projectRoot) {
40+
this.projectRoot = projectRoot != null ? projectRoot.toAbsolutePath().normalize() : null;
41+
}
42+
43+
/**
44+
* Resolve the effective editorconfig properties for the given file path.
45+
*
46+
* @param filePath absolute path to the file
47+
* @return merged properties map, or empty if no .editorconfig applies
48+
*/
49+
public Map<String, String> resolve(Path filePath) {
50+
Path absPath = filePath.toAbsolutePath().normalize();
51+
Path dir = absPath.getParent();
52+
if (dir == null) {
53+
return Collections.emptyMap();
54+
}
55+
56+
String fileName = absPath.getFileName().toString();
57+
List<EditorConfigParser.EditorConfigFile> configs = collectConfigs(dir);
58+
59+
// Merge matching sections
60+
Map<String, String> merged = new LinkedHashMap<>();
61+
for (EditorConfigParser.EditorConfigFile config : configs) {
62+
for (EditorConfigParser.Section section : config.getSections()) {
63+
if (PathUtils.matchesGlob(fileName, section.getPattern())) {
64+
merged.putAll(section.getProperties());
65+
}
66+
}
67+
}
68+
return merged;
69+
}
70+
71+
private List<EditorConfigParser.EditorConfigFile> collectConfigs(Path dir) {
72+
List<EditorConfigParser.EditorConfigFile> cached = configsPerDirectory.get(dir);
73+
if (cached != null) {
74+
return cached;
75+
}
76+
77+
List<EditorConfigParser.EditorConfigFile> configs = new ArrayList<>();
78+
Path current = dir;
79+
while (current != null) {
80+
Path ecFile = current.resolve(EDITOR_CONFIG_FILE);
81+
if (Files.isRegularFile(ecFile)) {
82+
EditorConfigParser.EditorConfigFile parsed = parseFile(ecFile);
83+
if (parsed != null) {
84+
configs.add(parsed);
85+
if (parsed.isRoot()) {
86+
break;
87+
}
88+
}
89+
}
90+
if (projectRoot != null && current.equals(projectRoot)) {
91+
break;
92+
}
93+
Path parent = current.getParent();
94+
if (parent != null && parent.equals(current)) {
95+
break;
96+
}
97+
current = parent;
98+
}
99+
100+
// Reverse so parents come first, children override
101+
Collections.reverse(configs);
102+
configsPerDirectory.put(dir, configs);
103+
return configs;
104+
}
105+
106+
private @Nullable EditorConfigParser.EditorConfigFile parseFile(Path ecFile) {
107+
return parsedFileCache.computeIfAbsent(ecFile, f -> {
108+
try {
109+
return parser.parse(f);
110+
} catch (IOException e) {
111+
return null;
112+
}
113+
});
114+
}
115+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.config;
17+
18+
import org.jspecify.annotations.Nullable;
19+
import org.openrewrite.style.GeneralFormatStyle;
20+
21+
import java.util.Map;
22+
23+
/**
24+
* Static helpers for mapping universal {@code .editorconfig} properties to
25+
* language-neutral style values. Language modules compose these into their
26+
* own style objects.
27+
*/
28+
public final class EditorConfigStyles {
29+
private EditorConfigStyles() {
30+
}
31+
32+
/**
33+
* @return {@code true} for tabs, {@code false} for spaces, {@code null} if unset
34+
*/
35+
public static @Nullable Boolean useTabCharacter(Map<String, String> props) {
36+
String value = props.get("indent_style");
37+
if ("tab".equals(value)) {
38+
return true;
39+
} else if ("space".equals(value)) {
40+
return false;
41+
}
42+
return null;
43+
}
44+
45+
/**
46+
* @return the indent size, or {@code null} if unset. Handles the special case
47+
* where {@code indent_size=tab} means use the {@code tab_width} value.
48+
*/
49+
public static @Nullable Integer indentSize(Map<String, String> props) {
50+
String value = props.get("indent_size");
51+
if (value == null) {
52+
return null;
53+
}
54+
if ("tab".equals(value)) {
55+
return tabSize(props);
56+
}
57+
return parsePositiveInt(value);
58+
}
59+
60+
/**
61+
* @return the tab size, or {@code null} if unset. Falls back to {@code indent_size}
62+
* if {@code tab_width} is not explicitly set.
63+
*/
64+
public static @Nullable Integer tabSize(Map<String, String> props) {
65+
String value = props.get("tab_width");
66+
if (value != null) {
67+
return parsePositiveInt(value);
68+
}
69+
// Per spec, tab_width defaults to indent_size when not set
70+
String indentSizeValue = props.get("indent_size");
71+
if (indentSizeValue != null && !"tab".equals(indentSizeValue)) {
72+
return parsePositiveInt(indentSizeValue);
73+
}
74+
return null;
75+
}
76+
77+
/**
78+
* @return a {@link GeneralFormatStyle} based on {@code end_of_line}, or {@code null} if unset
79+
*/
80+
public static @Nullable GeneralFormatStyle generalFormatStyle(Map<String, String> props) {
81+
String value = props.get("end_of_line");
82+
if ("crlf".equals(value)) {
83+
return new GeneralFormatStyle(true);
84+
} else if ("lf".equals(value) || "cr".equals(value)) {
85+
return new GeneralFormatStyle(false);
86+
}
87+
return null;
88+
}
89+
90+
private static @Nullable Integer parsePositiveInt(String value) {
91+
try {
92+
int parsed = Integer.parseInt(value);
93+
return parsed > 0 ? parsed : null;
94+
} catch (NumberFormatException e) {
95+
return null;
96+
}
97+
}
98+
}

rewrite-core/src/main/java/org/openrewrite/tree/ParsingExecutionContextView.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import org.jspecify.annotations.Nullable;
1919
import org.openrewrite.DelegatingExecutionContext;
2020
import org.openrewrite.ExecutionContext;
21+
import org.openrewrite.config.EditorConfigResolver;
2122

2223
import java.nio.charset.Charset;
2324

@@ -26,6 +27,8 @@ public class ParsingExecutionContextView extends DelegatingExecutionContext {
2627

2728
private static final String CHARSET = "org.openrewrite.parser.charset";
2829

30+
private static final String EDITOR_CONFIG_RESOLVER = "org.openrewrite.parser.editorConfigResolver";
31+
2932
public ParsingExecutionContextView(ExecutionContext delegate) {
3033
super(delegate);
3134
}
@@ -54,4 +57,13 @@ public ParsingExecutionContextView setCharset(@Nullable Charset charset) {
5457
public @Nullable Charset getCharset() {
5558
return getMessage(CHARSET);
5659
}
60+
61+
public ParsingExecutionContextView setEditorConfigResolver(EditorConfigResolver resolver) {
62+
putMessage(EDITOR_CONFIG_RESOLVER, resolver);
63+
return this;
64+
}
65+
66+
public @Nullable EditorConfigResolver getEditorConfigResolver() {
67+
return getMessage(EDITOR_CONFIG_RESOLVER);
68+
}
5769
}

0 commit comments

Comments
 (0)