diff --git a/server/buildSrc/build.gradle b/server/buildSrc/build.gradle index 1bb820e3b..f512a8296 100644 --- a/server/buildSrc/build.gradle +++ b/server/buildSrc/build.gradle @@ -24,5 +24,10 @@ dependencies { implementation 'com.diffplug.spotless:spotless-lib-extra:3.3.1' implementation 'org.eclipse.jdt:org.eclipse.jdt.core:3.46.0' // must match the //DEPS version in scripts/src/DependencyOverrideCheck.java - implementation 'org.apache.maven:maven-artifact:3.9.9' + implementation 'org.apache.maven:maven-artifact:3.9.16' +} + +java { + sourceCompatibility = 25 + targetCompatibility = 25 } diff --git a/server/scripts/config-properties-report.sh b/server/scripts/config-properties-report.sh new file mode 100755 index 000000000..72542355e --- /dev/null +++ b/server/scripts/config-properties-report.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# Lists every Spring config property key read via @Value / @ConfigurationProperties in +# src/main/java, with defaults and source locations. See scripts/src/ConfigPropertiesReport.java. + +set -eu + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +SERVER_ROOT=$( dirname "${SCRIPT_DIR}" ) + +cd "${SERVER_ROOT}" + +jbang scripts/src/ConfigPropertiesReport.java "$@" diff --git a/server/scripts/src/AddBracesFix.java b/server/scripts/src/AddBracesFix.java index d1f9f5eb2..5ad337359 100644 --- a/server/scripts/src/AddBracesFix.java +++ b/server/scripts/src/AddBracesFix.java @@ -12,7 +12,7 @@ *****************************************************************************/ ///usr/bin/env jbang "$0" "$@" ; exit $? -//JAVA 21+ +//JAVA 25+ //DEPS org.eclipse.jdt:org.eclipse.jdt.core:3.46.0 //SOURCES AddBracesFixCore.java diff --git a/server/scripts/src/ClosingBraceFix.java b/server/scripts/src/ClosingBraceFix.java index c8c3124d5..3f1d23b74 100644 --- a/server/scripts/src/ClosingBraceFix.java +++ b/server/scripts/src/ClosingBraceFix.java @@ -12,7 +12,7 @@ *****************************************************************************/ ///usr/bin/env jbang "$0" "$@" ; exit $? -//JAVA 21+ +//JAVA 25+ //DEPS org.eclipse.jdt:org.eclipse.jdt.core:3.46.0 //SOURCES ClosingBraceFixCore.java diff --git a/server/scripts/src/ConfigPropertiesReport.java b/server/scripts/src/ConfigPropertiesReport.java new file mode 100644 index 000000000..bb6af442b --- /dev/null +++ b/server/scripts/src/ConfigPropertiesReport.java @@ -0,0 +1,421 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 25+ + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +// Scans src/main/java for `@Value("${...}")` and `@ConfigurationProperties(...)` usages and prints +// every Spring config property key it finds, with its default (if any) and source location. There +// is no spring-configuration-metadata.json in this project (no annotationProcessor configured), so +// this is a regex-based stand-in - keys/defaults are discovered fresh each run, nothing here is a +// list to keep in sync. +// +// Deliberately not covered: cross-referencing application*.yml/.properties for actually-configured +// values, and recursing into @ConfigurationProperties field types beyond one level (e.g. +// RemoteScannerProperties' Map keys are operator-chosen at runtime and +// aren't enumerable from source). +void main() throws IOException { + var entries = new TreeMap(); + var configPropsClasses = new ArrayList(); + + try (Stream paths = Files.walk(Path.of("src/main/java"))) { + for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) { + var raw = Files.readString(path, StandardCharsets.UTF_8); + var blanked = blankComments(blankStringLiterals(raw)); + var file = path.getFileName().toString(); + scanValueAnnotations(raw, blanked, file, entries); + scanConfigurationProperties(raw, blanked, file, entries, configPropsClasses); + } + } + + System.out.println("# Configuration properties"); + System.out.println(); + System.out.println( + "Generated by `scripts/config-properties-report.sh` from `@Value` and " + + "`@ConfigurationProperties` usages in `src/main/java` - regenerate rather than editing by " + + "hand."); + System.out.println(); + System.out.println(entries.size() + " property keys found."); + System.out.println(); + System.out.println("| Property | Default | Source | Note |"); + System.out.println("|---|---|---|---|"); + for (PropertyEntry entry : entries.values()) { + var defaultCell = entry.defaultValue == null || entry.defaultValue.isEmpty() + ? "-" + : "`" + mdEscape(entry.defaultValue) + "`"; + var sourceCell = + String.join(", ", entry.sources.stream().map(s -> "`" + s + "`").toList()); + var noteCell = entry.note == null ? "" : mdEscape(entry.note); + System.out.println( + "| `" + entry.key + "` | " + defaultCell + " | " + sourceCell + " | " + noteCell + " |"); + } + + if (!configPropsClasses.isEmpty()) { + System.out.println(); + System.out.println("## `@ConfigurationProperties` classes"); + System.out.println(); + System.out.println("| Prefix | Source | Note |"); + System.out.println("|---|---|---|"); + for (ConfigClassEntry entry : configPropsClasses) { + var noteCell = entry.note() == null ? "" : mdEscape(entry.note()); + System.out.println( + "| `" + entry.prefix() + "` | `" + entry.source() + "` | " + noteCell + " |"); + } + } +} + +record PropertyEntry(String key, String defaultValue, Set sources, String note) {} + +record ConfigClassEntry(String prefix, String source, String note) {} + +String mdEscape(String text) { + return text.replace("|", "\\|"); +} + +// ---- @Value("${key:default}") ---- + +Pattern VALUE_ANNOTATION = Pattern.compile("@Value\\s*\\("); +// Deliberately line-bound and avoids a `(?:X|Y)*` alternation group repeated per char - Java's +// regex engine recurses per repetition of that shape, which stack-overflows on long spans (e.g. an +// unbalanced quote inside a javadoc comment matching across a large chunk of the file). +Pattern QUOTED_SEGMENT = Pattern.compile("\"([^\"\\\\\\n]*(?:\\\\.[^\"\\\\\\n]*)*)\""); +// Same quoted-string alternative as QUOTED_SEGMENT, plus a bare CONSTANT_NAME alternative, matched +// in argRaw's actual left-to-right order (see scanValueAnnotations). +Pattern QUOTED_OR_IDENTIFIER = + Pattern.compile("\"([^\"\\\\\\n]*(?:\\\\.[^\"\\\\\\n]*)*)\"|\\b([A-Z][A-Z0-9_]*)\\b"); +// One level of brace nesting, e.g. ${outer:${inner:default}} - covers every nested-default and +// SpEL-wrapped case found in this codebase; the SpEL syntax around it is simply ignored text. +Pattern PLACEHOLDER = Pattern.compile("\\$\\{((?:[^${}]|\\$\\{[^{}]*\\})*)\\}"); + +void scanValueAnnotations( + String raw, String blanked, String file, TreeMap entries) { + var matcher = VALUE_ANNOTATION.matcher(blanked); + while (matcher.find()) { + var close = findMatchingParen(blanked, matcher.end() - 1); + if (close < 0) { + continue; + } + var argRaw = raw.substring(matcher.end(), close); + + // Walk quoted segments and bare CONSTANT identifiers in the order they appear (not as two + // separate passes) so "${key:" + CONST + "}" reconstructs correctly - CONST's own text never + // appears inside a quoted segment, so a later blind replace() on the joined string can't find + // it to substitute. + var joinedBuilder = new StringBuilder(); + var quotedCount = 0; + String note = null; + var partsMatcher = QUOTED_OR_IDENTIFIER.matcher(argRaw); + while (partsMatcher.find()) { + if (partsMatcher.group(1) != null) { + joinedBuilder.append(partsMatcher.group(1)); + quotedCount++; + } else { + var name = partsMatcher.group(2); + var constantValueMatcher = + Pattern.compile(Pattern.quote(name) + "\\s*=\\s*\"([^\"]*)\"").matcher(raw); + if (constantValueMatcher.find()) { + joinedBuilder.append(constantValueMatcher.group(1)); + } else { + note = "unresolved constant: " + name; + } + } + } + if (quotedCount == 0) { + continue; + } + var joined = joinedBuilder.toString(); + + var placeholderMatcher = PLACEHOLDER.matcher(joined); + if (!placeholderMatcher.find()) { + continue; + } + var content = placeholderMatcher.group(1); + var colon = content.indexOf(':'); + var key = colon < 0 ? content : content.substring(0, colon); + var defaultValue = colon < 0 ? null : content.substring(colon + 1); + + var line = lineNumber(raw, matcher.start()); + addEntry(entries, key, defaultValue, file + ":" + line, note); + } +} + +// ---- @ConfigurationProperties(...) ---- + +Pattern CONFIG_PROPS_ANNOTATION = Pattern.compile("@ConfigurationProperties\\s*\\("); +Pattern PREFIX_NAMED = Pattern.compile("prefix\\s*=\\s*\"([^\"]*)\""); +Pattern PREFIX_LITERAL = Pattern.compile("\"([^\"]*)\""); +Pattern PREFIX_NAMED_CONSTANT = Pattern.compile("prefix\\s*=\\s*([A-Za-z_][\\w.]*)"); +Pattern PREFIX_CONSTANT = Pattern.compile("([A-Za-z_][\\w.]*)"); +Pattern NEXT_BEAN_METHOD = Pattern.compile("@Bean\\b"); +Pattern PRECEDING_BEAN = Pattern.compile("@Bean\\s*$"); +Pattern NEXT_CLASS = Pattern.compile("\\bclass\\s+(\\w+)"); +Pattern NEXT_RECORD = Pattern.compile("\\brecord\\s+(\\w+)\\s*\\("); +Set SIMPLE_TYPES = Set.of( + "String", "Boolean", "Integer", "Long", "Double", "Duration", "HttpStatus", "List", "Set", "Map", + "boolean", "int", "long", "double", "float", "short", "byte", "char"); +Pattern MODIFIERS = Pattern.compile("^(?:(?:private|protected|public|final)\\s+)+"); +Pattern TRAILING_NAME = Pattern.compile("(\\w+)$"); + +void scanConfigurationProperties( + String raw, + String blanked, + String file, + TreeMap entries, + List configPropsClasses) { + var matcher = CONFIG_PROPS_ANNOTATION.matcher(blanked); + while (matcher.find()) { + var close = findMatchingParen(blanked, matcher.end() - 1); + if (close < 0) { + continue; + } + var arg = raw.substring(matcher.end(), close); + + String prefix; + String note = null; + var named = PREFIX_NAMED.matcher(arg); + var literal = PREFIX_LITERAL.matcher(arg); + if (named.find()) { + prefix = named.group(1); + } else if (literal.find()) { + prefix = literal.group(1); + } else { + var namedConstant = PREFIX_NAMED_CONSTANT.matcher(arg); + var bareConstant = PREFIX_CONSTANT.matcher(arg); + var expr = namedConstant.find() + ? namedConstant.group(1) + : bareConstant.find() ? bareConstant.group(1) : arg.strip(); + var name = expr.contains(".") ? expr.substring(expr.lastIndexOf('.') + 1) : expr; + var constantValueMatcher = + Pattern.compile(Pattern.quote(name) + "\\s*=\\s*\"([^\"]*)\"").matcher(raw); + if (constantValueMatcher.find()) { + prefix = constantValueMatcher.group(1); + } else { + prefix = expr; + note = "unresolved constant reference"; + } + } + + // @Bean can be stacked either before or after @ConfigurationProperties on the same method + // (MirrorConfig has it first), so check immediately-before as well as forward. + var immediatelyPrecededByBean = + PRECEDING_BEAN.matcher(blanked.substring(0, matcher.start())).find(); + var afterAnnotation = blanked.substring(close + 1); + var beanMatcher = NEXT_BEAN_METHOD.matcher(afterAnnotation); + var classMatcher = NEXT_CLASS.matcher(afterAnnotation); + var recordMatcher = NEXT_RECORD.matcher(afterAnnotation); + var beanAt = beanMatcher.find() ? beanMatcher.start() : Integer.MAX_VALUE; + var classAt = classMatcher.find() ? classMatcher.start() : Integer.MAX_VALUE; + var recordAt = recordMatcher.find() ? recordMatcher.start() : Integer.MAX_VALUE; + + var line = lineNumber(raw, matcher.start()); + var loc = file + ":" + line; + configPropsClasses.add(new ConfigClassEntry(prefix, loc, note)); + + if (immediatelyPrecededByBean || (beanAt < classAt && beanAt < recordAt)) { + addEntry(entries, prefix, null, loc, "@Bean-returned collection, prefix is the bound key"); + } else if (recordAt < classAt) { + var openParen = close + 1 + recordMatcher.end() - 1; + var closeParen = findMatchingParen(blanked, openParen); + if (closeParen > 0) { + for (var field : splitTopLevel(raw.substring(openParen + 1, closeParen), ',')) { + addField(entries, prefix, field.strip(), loc); + } + } + } else if (classAt < Integer.MAX_VALUE) { + var openBrace = afterAnnotation.indexOf('{', classMatcher.end()); + if (openBrace >= 0) { + var absoluteOpenBrace = close + 1 + openBrace; + var closeBrace = findMatchingBrace(blanked, absoluteOpenBrace); + if (closeBrace > 0) { + var bodyBlanked = blanked.substring(absoluteOpenBrace + 1, closeBrace); + for (var chunk : splitTopLevel(bodyBlanked, ';')) { + var trimmed = stripLeadingAnnotations(chunk.strip()).strip(); + // A method's signature never has a top-level '=' before its '(' - a field's + // does only inside its initializer, which addField also strips before + // looking at the declaration, so checking pre-'=' text tells them apart. + var beforeInit = trimmed.split("=", 2)[0]; + if (trimmed.isEmpty() || trimmed.contains("static") || beforeInit.contains("(")) { + continue; + } + addField(entries, prefix, trimmed, loc); + } + } + } + } + } +} + +void addField(TreeMap entries, String prefix, String declaration, String loc) { + // Drop any initializer first ("= Duration.ofMinutes(5)", "= List.of(\"svg\")") - otherwise its + // tokens (which may themselves look like a type-then-identifier pair, e.g. a qualified constant + // reference) get mistaken for the field's own type/name. + var withoutInit = declaration.split("=", 2)[0].strip(); + var nameMatcher = TRAILING_NAME.matcher(withoutInit); + if (!nameMatcher.find()) { + return; + } + var name = nameMatcher.group(1); + // Generic type args can contain top-level spaces after a comma (`Map field`), so + // the type is "everything before the trailing identifier", not a naive whitespace split. + var type = MODIFIERS.matcher(withoutInit.substring(0, nameMatcher.start()).strip()).replaceFirst(""); + if (type.isEmpty()) { + return; + } + var bareType = type.contains("<") ? type.substring(0, type.indexOf('<')) : type; + var note = SIMPLE_TYPES.contains(bareType) ? null : "nested type, see " + bareType + " in this file"; + addEntry(entries, prefix + "." + kebabCase(name), null, loc, note); +} + +String stripLeadingAnnotations(String declaration) { + var result = declaration.strip(); + while (result.startsWith("@")) { + var paren = result.indexOf('('); + var space = result.indexOf(' '); + if (paren >= 0 && (space < 0 || paren < space)) { + var close = findMatchingParen(result, paren); + result = close > 0 ? result.substring(close + 1).strip() : result; + } else if (space > 0) { + result = result.substring(space + 1).strip(); + } else { + break; + } + } + return result; +} + +String kebabCase(String camelCase) { + return camelCase.replaceAll("([a-z0-9])([A-Z])", "$1-$2").toLowerCase(); +} + +void addEntry( + TreeMap entries, String key, String defaultValue, String source, String note) { + var existing = entries.get(key); + if (existing == null) { + var sources = new LinkedHashSet(); + sources.add(source); + entries.put(key, new PropertyEntry(key, defaultValue, sources, note)); + } else { + existing.sources().add(source); + } +} + +// ---- small structural helpers ---- + +/** Java string literals can't contain a raw newline or unescaped quote, so this is a safe, + * non-multiline regex replace - it exists only so brace/paren counting elsewhere isn't fooled by + * literal braces/parens inside a string value (e.g. a default JSON body). */ +String blankStringLiterals(String source) { + return QUOTED_SEGMENT.matcher(source).replaceAll(m -> "\"" + "x".repeat(m.group(1).length()) + "\""); +} + +/** Blanks `//` and `/* */` comments (length- and newline-preserving, so indices/line numbers + * computed against the result still line up with the raw source). Needed for correctness, not just + * robustness: a javadoc sentence containing a literal semicolon (e.g. "OIDC ID token; by default + * ...") would otherwise be mistaken for a field terminator by the class-body field scan below. Runs + * after string-blanking so a `//`/`/*` inside a string's (now blanked) content can't be mistaken for + * a comment start either. */ +String blankComments(String source) { + var result = new StringBuilder(source.length()); + var n = source.length(); + var i = 0; + while (i < n) { + if (i + 1 < n && source.charAt(i) == '/' && source.charAt(i + 1) == '/') { + while (i < n && source.charAt(i) != '\n') { + result.append(' '); + i++; + } + } else if (i + 1 < n && source.charAt(i) == '/' && source.charAt(i + 1) == '*') { + result.append(" "); + i += 2; + while (i + 1 < n && !(source.charAt(i) == '*' && source.charAt(i + 1) == '/')) { + result.append(source.charAt(i) == '\n' ? '\n' : ' '); + i++; + } + if (i + 1 < n) { + result.append(" "); + i += 2; + } + } else { + result.append(source.charAt(i)); + i++; + } + } + return result.toString(); +} + +int findMatchingParen(String text, int openIdx) { + return findMatching(text, openIdx, '(', ')'); +} + +int findMatchingBrace(String text, int openIdx) { + return findMatching(text, openIdx, '{', '}'); +} + +int findMatching(String text, int openIdx, char open, char close) { + var depth = 0; + for (var i = openIdx; i < text.length(); i++) { + var c = text.charAt(i); + if (c == open) { + depth++; + } else if (c == close) { + depth--; + if (depth == 0) { + return i; + } + } + } + return -1; +} + +List splitTopLevel(String text, char separator) { + var result = new ArrayList(); + var depth = 0; + var start = 0; + for (var i = 0; i < text.length(); i++) { + var c = text.charAt(i); + if (c == '<' || c == '(' || c == '{') { + depth++; + } else if (c == '>' || c == ')' || c == '}') { + depth--; + } else if (c == separator && depth == 0) { + result.add(text.substring(start, i)); + start = i + 1; + } + } + if (start < text.length()) { + result.add(text.substring(start)); + } + return result; +} + +int lineNumber(String text, int index) { + var line = 1; + for (var i = 0; i < index; i++) { + if (text.charAt(i) == '\n') { + line++; + } + } + return line; +} diff --git a/server/scripts/src/DependencyOverrideCheck.java b/server/scripts/src/DependencyOverrideCheck.java index 90fbb0e41..fe0829d06 100644 --- a/server/scripts/src/DependencyOverrideCheck.java +++ b/server/scripts/src/DependencyOverrideCheck.java @@ -12,7 +12,8 @@ *****************************************************************************/ ///usr/bin/env jbang "$0" "$@" ; exit $? -//DEPS org.apache.maven:maven-artifact:3.9.9 +//DEPS org.apache.maven:maven-artifact:3.9.16 +//JAVA 25+ import java.io.StringReader; import java.nio.charset.StandardCharsets; diff --git a/server/scripts/src/ImportSort.java b/server/scripts/src/ImportSort.java index 263851080..82156c949 100644 --- a/server/scripts/src/ImportSort.java +++ b/server/scripts/src/ImportSort.java @@ -13,6 +13,7 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? //DEPS com.diffplug.spotless:spotless-lib:3.3.1 +//JAVA 25+ import com.diffplug.spotless.java.ImportOrderStep;