// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Immutable;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace Aspire.Hosting.Java;
/// <summary>
/// Determines which Java release a project targets, so the generated container image builds and runs it
/// on a matching JDK and JRE.
/// </summary>
/// <remarks>
/// Detection is best effort and never fails: an unreadable or unrecognised build file falls back to
/// <see cref="DefaultJavaVersion"/>. Callers can always override the images with
/// <c>WithDockerfileBaseImage</c>.
/// </remarks>
internal static partial class JavaVersionDetector
{
/// <summary>
/// The Java release used when a project's build files declare none. Chosen as a broadly supported
/// long-term support release, which is what current Spring Boot and Quarkus releases target by
/// default, rather than the newest one.
/// </summary>
internal const string DefaultJavaVersion = "21";
/// <param name="appDirectory">The application directory to read build files from.</param>
/// <param name="tool">
/// The build tool that actually builds the application, when it is known. A directory can hold both a
/// POM and a Gradle script — a repository part-way through a migration, for example — and the two can
/// name different releases. Reading the tool that does not run would tag the image for a release the
/// application was never compiled for, which surfaces at runtime as
/// <c>UnsupportedClassVersionError</c> rather than as a build failure. This is an ordering rather than
/// a filter: Gradle projects often leave the release to a toolchain block that is not read here, and a
/// sibling POM is still better evidence than the default.
/// </param>
/// <param name="buildArgs">The resolved arguments passed to the selected build tool, when known.</param>
public static string Detect(string appDirectory, JavaBuildTool? tool = null, string[]? buildArgs = null)
{
string?[] candidates = tool is JavaBuildTool.Gradle
?
[
DetectFromGradle(Path.Combine(appDirectory, "build.gradle")),
DetectFromGradle(Path.Combine(appDirectory, "build.gradle.kts")),
DetectFromPom(Path.Combine(appDirectory, "pom.xml"), buildArgs: null),
]
:
[
DetectFromPom(
Path.Combine(appDirectory, "pom.xml"),
tool is JavaBuildTool.Maven ? buildArgs : null),
DetectFromGradle(Path.Combine(appDirectory, "build.gradle")),
DetectFromGradle(Path.Combine(appDirectory, "build.gradle.kts")),
];
return Array.Find(candidates, candidate => candidate is not null) ?? DefaultJavaVersion;
}
/// <summary>
/// Reads the target release from a Maven POM.
/// </summary>
/// <remarks>
/// Three spellings are common and all appear in Spring Initializr output:
/// <code language="xml">
/// <properties>
/// <java.version>21</java.version>
/// <maven.compiler.release>21</maven.compiler.release>
/// </properties>
/// <!-- or, on the compiler plugin itself -->
/// <configuration><release>21</release></configuration>
/// </code>
/// Legacy POMs write <c>1.8</c> rather than <c>8</c>; both map to the <c>8</c> image tag.
/// Property references such as <c><release>${java.version}</release></c> are not expanded —
/// the literal properties are checked first, so the reference is only reached when nothing else matched.
/// </remarks>
private static string? DetectFromPom(string pomPath, string[]? buildArgs)
{
if (!File.Exists(pomPath))
{
return null;
}
XDocument document;
try
{
document = XDocument.Load(pomPath);
}
catch (Exception ex) when (ex is System.Xml.XmlException or IOException or UnauthorizedAccessException)
{
// A malformed or unreadable POM is the build tool's problem to report, not a reason to fail
// publishing before the container build has even started.
return null;
}
// Element names are matched without their namespace so both the Maven 4 POM namespace and the
// long-standing http://maven.apache.org/POM/4.0.0 namespace are handled.
//
// Every project element with a matching name is considered, not just the first: a POM
// often declares <release>${java.version}</release> on the compiler plugin and a literal elsewhere,
// and stopping at the unresolvable property reference would fall back to the default version instead.
// Within each declaration kind, explicitly selected profile values are checked first. If none of
// those profiles exist in this POM, active-by-default profile values are checked before the project's
// base values because Maven applies them as overrides in its effective model. Other profile activation
// rules are not evaluated because they can depend on the JDK, operating system, system properties,
// files, or settings.
// Ordered by how directly each one decides the bytecode version, most direct first, because the
// runtime image has to be at least what the compiler actually emitted.
//
// java.version is last despite being the most recognisable. It is not a Maven property at all:
// it works only because spring-boot-starter-parent maps it onto the real one with
// <maven.compiler.release>${java.version}</maven.compiler.release>. A POM that sets both
// java.version and maven.compiler.release overrides that mapping, so Maven compiles to the
// latter and reading java.version would pick a runtime too old to load the classes.
// https://docs.spring.io/spring-boot/maven-plugin/using.html
var profiles = GetProfiles(document).ToArray();
var profileSelection = ParseMavenProfileSelection(buildArgs);
var explicitlyActivatedProfiles = profiles
.Where(profile => GetProfileId(profile) is { } id
&& profileSelection.Activated.Contains(id)
&& !profileSelection.Deactivated.Contains(id))
.ToArray();
var activeProfiles = explicitlyActivatedProfiles.Length > 0
? explicitlyActivatedProfiles
: profiles
.Where(profile => IsActiveByDefaultProfile(profile)
&& (GetProfileId(profile) is not { } id || !profileSelection.Deactivated.Contains(id)))
.ToArray();
foreach (var (name, mustBePluginConfiguration) in ((string, bool)[])
[
// Explicit plugin configuration beats the property that merely supplies the parameter's
// default, and release beats target within the plugin.
// https://maven.apache.org/plugins/maven-compiler-plugin/compile-mojo.html
//
// <release> and <target> are only meaningful inside the compiler plugin's <configuration>.
// Matched merely by having a <configuration> parent they would also pick up unrelated
// plugins: maven-antrun-plugin's canonical configuration is literally
// <configuration><target>...</target></configuration>, holding Ant XML rather than a Java
// release, and any plugin is free to name a <release> of its own.
("release", true),
("maven.compiler.release", false),
("target", true),
("maven.compiler.target", false),
("java.version", false),
])
{
IEnumerable<XElement>[] candidateGroups =
[
// Maven merges active profiles in declaration order, with later profile values taking precedence.
activeProfiles.SelectMany(profile => profile.Descendants()).Reverse(),
document.Descendants().Where(element => !IsInProfile(element)),
];
foreach (var candidateElements in candidateGroups)
{
foreach (var element in candidateElements.Where(e => string.Equals(e.Name.LocalName, name, StringComparison.Ordinal)))
{
if (mustBePluginConfiguration && !IsCompilerPluginConfiguration(element.Parent))
{
continue;
}
if (Normalize(element.Value) is { } version)
{
return version;
}
}
}
}
return null;
}
/// <summary>
/// Reads the profiles declared directly by this Maven project.
/// </summary>
private static IEnumerable<XElement> GetProfiles(XDocument document) =>
document.Root?
.Elements()
.Where(element => string.Equals(element.Name.LocalName, "profiles", StringComparison.Ordinal))
.SelectMany(element => element.Elements().Where(IsProfile))
?? [];
/// <summary>
/// Reads a Maven profile's direct <c><id></c> child.
/// </summary>
private static string? GetProfileId(XElement profile) =>
profile.Elements()
.FirstOrDefault(element => string.Equals(element.Name.LocalName, "id", StringComparison.Ordinal))
?.Value.Trim() is { Length: > 0 } id
? id
: null;
/// <summary>
/// Reads Maven's explicit profile activation and deactivation arguments.
/// </summary>
private static MavenProfileSelection ParseMavenProfileSelection(string[]? buildArgs)
{
var activated = new HashSet<string>(StringComparer.Ordinal);
var deactivated = new HashSet<string>(StringComparer.Ordinal);
if (buildArgs is null)
{
return new MavenProfileSelection(activated, deactivated);
}
for (var index = 0; index < buildArgs.Length; index++)
{
var argument = buildArgs[index];
string? expression = argument switch
{
"-P" or "--activate-profiles" when index + 1 < buildArgs.Length => buildArgs[++index],
_ when argument.StartsWith("-P=", StringComparison.Ordinal) => argument[3..],
_ when argument.StartsWith("-P", StringComparison.Ordinal) => argument[2..],
_ when argument.StartsWith("--activate-profiles=", StringComparison.Ordinal) =>
argument["--activate-profiles=".Length..],
_ => null,
};
if (expression is null)
{
continue;
}
// Maven accepts `-Pprod`, `-P prod`, `-P=prod`, `--activate-profiles prod`, and
// `--activate-profiles=prod`. Each value can be comma-separated, `!`/`-` deactivates a
// profile, and Maven 4's `?` prefix makes an activation optional. Parse only these argv shapes
// so malformed or unrelated switches remain Maven's responsibility.
// https://maven.apache.org/guides/introduction/introduction-to-profiles.html
foreach (var item in expression.Split(','))
{
var profileExpression = item.Trim();
if (profileExpression.Length == 0)
{
continue;
}
var isDeactivation = profileExpression[0] is '!' or '-';
if (isDeactivation)
{
profileExpression = profileExpression[1..];
}
if (profileExpression.Length > 0 && profileExpression[0] == '?')
{
profileExpression = profileExpression[1..];
}
if (profileExpression.Length == 0)
{
continue;
}
(isDeactivation ? deactivated : activated).Add(profileExpression);
}
}
return new MavenProfileSelection(activated, deactivated);
}
/// <summary>
/// Determines whether a Maven profile is active by default.
/// </summary>
/// <remarks>
/// Maven expresses this activation as:
/// <code>
/// <profile>
/// <activation><activeByDefault>true</activeByDefault></activation>
/// </profile>
/// </code>
/// Other activation forms are not evaluated because they can depend on the JDK, operating system,
/// system properties, files, or command-line profile selection.
/// </remarks>
private static bool IsActiveByDefaultProfile(XElement profile)
{
var activation = profile.Elements().FirstOrDefault(e =>
string.Equals(e.Name.LocalName, "activation", StringComparison.Ordinal));
return activation?.Elements().Any(e =>
string.Equals(e.Name.LocalName, "activeByDefault", StringComparison.Ordinal)
&& bool.TryParse(e.Value.Trim(), out var activeByDefault)
&& activeByDefault) is true;
}
private static bool IsInProfile(XElement element) => element.Ancestors().Any(IsProfile);
private static bool IsProfile(XElement element) =>
string.Equals(element.Name.LocalName, "profile", StringComparison.Ordinal);
private readonly record struct MavenProfileSelection(
HashSet<string> Activated,
HashSet<string> Deactivated);
/// <summary>
/// Determines whether an element is a <c><configuration></c> belonging to
/// <c>maven-compiler-plugin</c>.
/// </summary>
/// <remarks>
/// Both places a compiler configuration can appear are accepted, because both really set the release:
/// <code>
/// <plugin>
/// <artifactId>maven-compiler-plugin</artifactId>
/// <configuration><release>21</release></configuration> <!-- plugin level -->
/// <executions><execution>
/// <configuration><release>21</release></configuration> <!-- execution level -->
/// </execution></executions>
/// </plugin>
/// </code>
/// The plugin is identified by <c>artifactId</c> alone: <c>groupId</c> defaults to
/// <c>org.apache.maven.plugins</c> and is routinely omitted for the core plugins.
/// See https://maven.apache.org/plugins/maven-compiler-plugin/compile-mojo.html.
/// </remarks>
private static bool IsCompilerPluginConfiguration(XElement? configuration)
{
if (!string.Equals(configuration?.Name.LocalName, "configuration", StringComparison.Ordinal))
{
return false;
}
for (var ancestor = configuration!.Parent; ancestor is not null; ancestor = ancestor.Parent)
{
if (!string.Equals(ancestor.Name.LocalName, "plugin", StringComparison.Ordinal))
{
continue;
}
return ancestor.Elements().Any(e =>
string.Equals(e.Name.LocalName, "artifactId", StringComparison.Ordinal)
&& string.Equals(e.Value.Trim(), "maven-compiler-plugin", StringComparison.Ordinal));
}
return false;
}
/// <summary>
/// Reads the target release from a Gradle build script.
/// </summary>
/// <remarks>
/// Groovy and Kotlin DSL spellings that appear in Spring Initializr and Gradle's own documentation:
/// <code>
/// java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
/// java.sourceCompatibility = JavaVersion.VERSION_21
/// sourceCompatibility = '17'
/// targetCompatibility = 1.8
/// </code>
/// Toolchain declarations take precedence over compatibility settings. Within each supported category,
/// the final assignment is used because Gradle scripts can reassign the same setting. This remains a
/// best-effort textual detector rather than a complete evaluation of the Gradle model.
/// </remarks>
private static string? DetectFromGradle(string buildScriptPath)
{
if (!File.Exists(buildScriptPath))
{
return null;
}
string contents;
try
{
contents = File.ReadAllText(buildScriptPath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
var script = StripComments(contents);
var foreignProjectBlocks = FindForeignProjectBlocks(script);
var assignment = LastActiveToolchainMatch(script, foreignProjectBlocks)
?? LastActiveMatch(TargetCompatibilityRegex(), script, excludedBlocks: foreignProjectBlocks)
?? LastActiveMatch(SourceCompatibilityRegex(), script, excludedBlocks: foreignProjectBlocks);
return Normalize(assignment?.Groups["version"].Value.Replace('_', '.'));
}
/// <summary>
/// Finds the final toolchain assignment that configures the application Java extension.
/// </summary>
private static Match? LastActiveToolchainMatch(
GradleScript script,
ImmutableArray<GradleBlock> foreignProjectBlocks)
{
var lastMatch = LastActiveMatch(DirectToolchainRegex(), script, excludedBlocks: foreignProjectBlocks);
// Groovy also accepts a qualified top-level block:
// java.toolchain { languageVersion = JavaLanguageVersion.of(25) }
// FindNamedBlocks balances the block and limits this spelling to the script's top level, so a
// similarly shaped foreign project or task block cannot override the application toolchain.
foreach (var toolchainBlock in FindNamedBlocks(script, "java.toolchain", 0, script.Text.Length))
{
var match = LastActiveMatch(
LanguageVersionRegex(),
script,
toolchainBlock.ContentStart,
toolchainBlock.ContentEnd,
foreignProjectBlocks);
if (match is not null && (lastMatch is null || match.Index > lastMatch.Index))
{
lastMatch = match;
}
}
var applicationBlocks = FindNamedBlocks(script, "java", 0, script.Text.Length, directOnly: false)
.Concat(FindConfiguredJavaBlocks(script, 0, script.Text.Length))
.Where(block => !IsInsideAnyBlock(block.ContentStart, foreignProjectBlocks))
.OrderBy(block => block.ContentStart);
foreach (var javaBlock in applicationBlocks)
{
var scopedMatch = LastActiveMatch(
ScopedToolchainRegex(),
script,
javaBlock.ContentStart,
javaBlock.ContentEnd,
foreignProjectBlocks);
if (scopedMatch is not null && (lastMatch is null || scopedMatch.Index > lastMatch.Index))
{
lastMatch = scopedMatch;
}
foreach (var toolchainBlock in FindNamedBlocks(script, "toolchain", javaBlock.ContentStart, javaBlock.ContentEnd))
{
var match = LastActiveMatch(
LanguageVersionRegex(),
script,
toolchainBlock.ContentStart,
toolchainBlock.ContentEnd,
foreignProjectBlocks);
if (match is not null && (lastMatch is null || match.Index > lastMatch.Index))
{
lastMatch = match;
}
}
}
return lastMatch;
}
/// <summary>
/// The last match that starts outside a string literal, or <c>null</c> when every match is inside one.
/// </summary>
/// <remarks>
/// The patterns run over the raw script rather than a parsed model, so a version-shaped fragment quoted
/// inside a string would otherwise be read as a declaration and win by appearing first:
/// <code>
/// println("JavaLanguageVersion.of(17)")
/// java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
/// </code>
/// That selects a Java 17 image for a Java 21 application, which fails at runtime with
/// <c>UnsupportedClassVersionError</c> rather than at publish time.
/// <para>
/// Only the match's start is tested, never the whole match. A declaration always begins outside the
/// quotes while its value may legitimately sit inside them — <c>sourceCompatibility = '17'</c> is the
/// ordinary Groovy spelling — so rejecting matches that merely overlap a literal would stop detecting
/// the quoted form that <see cref="SourceCompatibilityRegex"/> exists to read.
/// </para>
/// </remarks>
private static Match? LastActiveMatch(
Regex regex,
GradleScript script,
int start = 0,
int? end = null,
ImmutableArray<GradleBlock> excludedBlocks = default)
{
Match? lastActiveMatch = null;
var endOffset = end ?? script.Text.Length;
for (var match = regex.Match(script.Text, start); match.Success && match.Index < endOffset; match = match.NextMatch())
{
if (match.Index + match.Length <= endOffset
&& !script.IsInsideStringLiteral(match.Index)
&& !IsInsideAnyBlock(match.Index, excludedBlocks)
&& !HasForeignProjectReceiver(script, match.Index))
{
lastActiveMatch = match;
}
}
return lastActiveMatch;
}
private static ImmutableArray<GradleBlock> FindForeignProjectBlocks(GradleScript script)
{
var blocks = ImmutableArray.CreateBuilder<GradleBlock>();
// Exclude scopes that configure a different project:
// project(":legacy") { java { ... } }
// subprojects { targetCompatibility = JavaVersion.VERSION_17 }
// configure(subprojects) { sourceCompatibility = JavaVersion.VERSION_17 }
// Keep allprojects { ... } because Gradle applies it to the current/root project too.
// https://docs.gradle.org/current/userguide/multi_project_builds.html
for (var index = 0; index < script.Text.Length; index++)
{
if (script.IsInsideStringLiteral(index))
{
continue;
}
var openingBrace = -1;
if (IsIdentifierAt(script.Text, "subprojects", index))
{
openingBrace = SkipWhitespace(script.Text, index + "subprojects".Length);
}
else if (IsIdentifierAt(script.Text, "configure", index))
{
var openingParenthesis = SkipWhitespace(script.Text, index + "configure".Length);
if (openingParenthesis >= script.Text.Length || script.Text[openingParenthesis] is not '(')
{
continue;
}
var closingParenthesis = FindClosingParenthesis(script, openingParenthesis);
if (closingParenthesis < 0)
{
break;
}
var configuredTarget = script.Text.AsSpan(
openingParenthesis + 1,
closingParenthesis - openingParenthesis - 1).Trim();
if (!configuredTarget.Equals("subprojects", StringComparison.Ordinal))
{
continue;
}
openingBrace = SkipWhitespace(script.Text, closingParenthesis + 1);
}
else if (IsIdentifierAt(script.Text, "project", index))
{
var openingParenthesis = SkipWhitespace(script.Text, index + "project".Length);
if (openingParenthesis >= script.Text.Length || script.Text[openingParenthesis] is not '(')
{
continue;
}
var closingParenthesis = FindClosingParenthesis(script, openingParenthesis);
if (closingParenthesis < 0)
{
break;
}
openingBrace = SkipWhitespace(script.Text, closingParenthesis + 1);
}
if (openingBrace < 0
|| openingBrace >= script.Text.Length
|| script.Text[openingBrace] is not '{')
{
continue;
}
var closingBrace = FindClosingBrace(script, openingBrace, script.Text.Length);
if (closingBrace < 0)
{
break;
}
blocks.Add(new GradleBlock(openingBrace + 1, closingBrace));
index = closingBrace;
}
return blocks.ToImmutable();
}
private static bool HasForeignProjectReceiver(GradleScript script, int memberOffset)
{
// Gradle lets a build configure a single foreign project without opening a block:
// project(":legacy").sourceCompatibility = JavaVersion.VERSION_17
// project(":legacy").targetCompatibility = JavaVersion.VERSION_17
// project(":legacy").java.toolchain.languageVersion = JavaLanguageVersion.of(17)
// Only suppress a supported declaration when its matched receiver immediately follows project(...);
// unrelated statements later in the script still belong to the current project.
// https://docs.gradle.org/current/userguide/multi_project_builds.html
for (var index = 0; index < memberOffset; index++)
{
if (script.IsInsideStringLiteral(index) || !IsIdentifierAt(script.Text, "project", index))
{
continue;
}
var openingParenthesis = SkipWhitespace(script.Text, index + "project".Length);
if (openingParenthesis >= script.Text.Length || script.Text[openingParenthesis] is not '(')
{
continue;
}
var closingParenthesis = FindClosingParenthesis(script, openingParenthesis);
if (closingParenthesis < 0 || closingParenthesis >= memberOffset)
{
continue;
}
var dot = SkipWhitespace(script.Text, closingParenthesis + 1);
if (dot >= memberOffset || script.Text[dot] is not '.')
{
index = closingParenthesis;
continue;
}
if (SkipWhitespace(script.Text, dot + 1) == memberOffset)
{
return true;
}
index = closingParenthesis;
}
return false;
}
private static int FindClosingParenthesis(GradleScript script, int openingParenthesis)
{
var depth = 1;
for (var index = openingParenthesis + 1; index < script.Text.Length; index++)
{
if (script.IsInsideStringLiteral(index))
{
continue;
}
if (script.Text[index] is '(')
{
depth++;
}
else if (script.Text[index] is ')' && --depth == 0)
{
return index;
}
}
return -1;
}
private static int SkipWhitespace(string text, int start)
{
while (start < text.Length && char.IsWhiteSpace(text[start]))
{
start++;
}
return start;
}
private static bool IsInsideAnyBlock(int offset, ImmutableArray<GradleBlock> blocks) =>
!blocks.IsDefaultOrEmpty
&& blocks.Any(block => offset >= block.ContentStart && offset < block.ContentEnd);
/// <summary>
/// Finds named Gradle blocks at the top level of the specified script range.
/// </summary>
private static ImmutableArray<GradleBlock> FindNamedBlocks(
GradleScript script,
string name,
int start,
int end,
bool directOnly = true)
{
var blocks = ImmutableArray.CreateBuilder<GradleBlock>();
var depth = 0;
for (var index = start; index < end; index++)
{
if (script.IsInsideStringLiteral(index))
{
continue;
}
if (script.Text[index] is '{')
{
depth++;
continue;
}
if (script.Text[index] is '}')
{
depth--;
continue;
}
if ((directOnly ? depth != 0 : depth < 0) || !IsIdentifierAt(script.Text, name, index))
{
continue;
}
var openingBrace = index + name.Length;
while (openingBrace < end && char.IsWhiteSpace(script.Text[openingBrace]))
{
openingBrace++;
}
if (openingBrace >= end || script.Text[openingBrace] is not '{')
{
continue;
}
var closingBrace = FindClosingBrace(script, openingBrace, end);
if (closingBrace < 0)
{
break;
}
blocks.Add(new GradleBlock(openingBrace + 1, closingBrace));
index = closingBrace;
}
return blocks.ToImmutable();
}
private static ImmutableArray<GradleBlock> FindConfiguredJavaBlocks(
GradleScript script,
int start,
int end)
{
var blocks = ImmutableArray.CreateBuilder<GradleBlock>();
foreach (Match match in ConfiguredJavaPluginExtensionRegex().Matches(script.Text))
{
if (match.Index < start
|| match.Index + match.Length >= end
|| script.IsInsideStringLiteral(match.Index))
{
continue;
}
var openingBrace = match.Index + match.Length;
while (openingBrace < end && char.IsWhiteSpace(script.Text[openingBrace]))
{
openingBrace++;
}
if (openingBrace >= end || script.Text[openingBrace] is not '{')
{
continue;
}
var closingBrace = FindClosingBrace(script, openingBrace, end);
if (closingBrace >= 0)
{
blocks.Add(new GradleBlock(openingBrace + 1, closingBrace));
}
}
return blocks.ToImmutable();
}
/// <summary>
/// Finds the brace that closes the block beginning at <paramref name="openingBrace"/>.
/// </summary>
private static int FindClosingBrace(GradleScript script, int openingBrace, int end)
{
var depth = 1;
for (var index = openingBrace + 1; index < end; index++)
{
if (script.IsInsideStringLiteral(index))
{
continue;
}
if (script.Text[index] is '{')
{
depth++;
}
else if (script.Text[index] is '}' && --depth == 0)
{
return index;
}
}
return -1;
}
/// <summary>
/// Determines whether <paramref name="name"/> is a complete identifier at <paramref name="offset"/>.
/// </summary>
private static bool IsIdentifierAt(string text, string name, int offset)
{
if (!text.AsSpan(offset).StartsWith(name, StringComparison.Ordinal)
|| offset > 0 && (IsGradleIdentifierCharacter(text[offset - 1]) || text[offset - 1] is '.'))
{
return false;
}
var end = offset + name.Length;
return end >= text.Length || !IsGradleIdentifierCharacter(text[end]);
}
private static bool IsGradleIdentifierCharacter(char value) =>
char.IsLetterOrDigit(value) || value is '_' or '$';
/// <summary>
/// The interior bounds of a balanced Gradle configuration block.
/// </summary>
private readonly record struct GradleBlock(int ContentStart, int ContentEnd);
/// <summary>
/// A Gradle build script with its comments removed, and the string literals that survived located.
/// </summary>
/// <param name="Text">The script with every comment blanked out.</param>
/// <param name="StringSpans">
/// The half-open interiors of the string literals in <paramref name="Text"/>, ascending and
/// non-overlapping, which lets a lookup binary search rather than rescan.
/// </param>
private readonly record struct GradleScript(string Text, ImmutableArray<Range> StringSpans)
{
internal bool IsInsideStringLiteral(int offset)
{
// Ascending and non-overlapping, so the last span starting at or before the offset is the only
// one that can contain it.
var low = 0;
var high = StringSpans.Length - 1;
var candidate = -1;
while (low <= high)
{
var middle = low + ((high - low) / 2);
if (StringSpans[middle].Start.Value <= offset)
{
candidate = middle;
low = middle + 1;
}
else
{
high = middle - 1;
}
}
return candidate >= 0 && offset < StringSpans[candidate].End.Value;
}
}
/// <summary>
/// Blanks out line and block comments so a commented-out setting cannot be mistaken for an active one,
/// and locates the string literals that survive so a quoted one cannot be either.
/// </summary>
/// <remarks>
/// The version patterns are applied to the raw script rather than a parsed model, so without this a
/// leftover line wins over the setting that is actually in effect:
/// <code>
/// java {
/// toolchain {
/// // languageVersion = JavaLanguageVersion.of(17)
/// languageVersion = JavaLanguageVersion.of(21)
/// }
/// }
/// </code>
/// String literals are tracked so that the <c>//</c> inside a repository URL, and any <c>/*</c> inside
/// a string, are not treated as comment starts — the latter would otherwise swallow the rest of the
/// file. Single, double, and triple-quoted forms are recognized, covering both the Groovy and the
/// Kotlin DSL. Groovy slashy and dollar-slashy strings are also tracked because braces in their regular
/// expressions must not change the application-block depth.
/// </remarks>
private static GradleScript StripComments(string contents)
{
var builder = new StringBuilder(contents.Length);
var spans = ImmutableArray.CreateBuilder<Range>();
var index = 0;
while (index < contents.Length)
{
var current = contents[index];
if (current is '$'
&& index + 1 < contents.Length
&& contents[index + 1] is '/')
{
builder.Append("$/");
index += 2;
var interiorStart = builder.Length;
var terminated = false;
while (index < contents.Length)
{
if (contents[index] is '$'
&& index + 1 < contents.Length
&& contents[index + 1] is '$' or '/')
{
builder.Append(contents, index, 2);
index += 2;
continue;
}
if (contents.AsSpan(index).StartsWith("/$", StringComparison.Ordinal))
{
spans.Add(new Range(interiorStart, builder.Length));
builder.Append("/$");
index += 2;
terminated = true;
break;
}
builder.Append(contents[index]);
index++;
}
if (!terminated)
{
spans.Add(new Range(interiorStart, builder.Length));
}
continue;
}
if (current is '/' && index + 1 < contents.Length)
{
if (contents[index + 1] is '/')
{
while (index < contents.Length && contents[index] is not ('\n' or '\r'))
{
index++;
}
continue;
}
if (contents[index + 1] is '*')
{
var end = contents.IndexOf("*/", index + 2, StringComparison.Ordinal);
index = end < 0 ? contents.Length : end + 2;
// A newline stands in for the comment so that the text on either side of a block
// comment cannot be joined into a single line and match as one declaration.
builder.Append('\n');
continue;
}
if (IsSlashyStringStart(contents, index))
{
builder.Append('/');
index++;
var interiorStart = builder.Length;
var terminated = false;
while (index < contents.Length)
{
if (contents[index] is '\\' && index + 1 < contents.Length)
{
builder.Append(contents, index, 2);
index += 2;
continue;
}
if (contents[index] is '/')
{
spans.Add(new Range(interiorStart, builder.Length));
builder.Append('/');
index++;
terminated = true;
break;
}
builder.Append(contents[index]);
index++;
}
if (!terminated)
{
spans.Add(new Range(interiorStart, builder.Length));
}
continue;
}
}
if (current is '"' or '\'')
{
var delimiter = new string(current, contents.AsSpan(index).StartsWith(new string(current, 3), StringComparison.Ordinal) ? 3 : 1);
builder.Append(delimiter);
index += delimiter.Length;
// Recorded in output coordinates, and covering only the interior: a declaration starts
// outside the quotes, so keeping the delimiters active is what lets the value of
// sourceCompatibility = '17' stay readable while its surroundings stay inert.
var interiorStart = builder.Length;
// The literal's contents are copied through unchanged - only comments are removed - so a
// version written as a quoted string, such as sourceCompatibility = '17', still matches.
while (index < contents.Length)
{
if (contents[index] is '\\' && index + 1 < contents.Length)
{
builder.Append(contents, index, 2);
index += 2;
continue;
}
if (contents.AsSpan(index).StartsWith(delimiter, StringComparison.Ordinal))
{
spans.Add(new Range(interiorStart, builder.Length));
builder.Append(delimiter);
index += delimiter.Length;
break;
}
builder.Append(contents[index]);
index++;
}
// An unterminated literal runs to the end of the file, which is what the Groovy and Kotlin
// compilers see too. Closing it at the last character keeps the rest of the script inert
// rather than letting a stray quote re-activate it.
if (index >= contents.Length && (spans.Count == 0 || spans[^1].End.Value != builder.Length))
{
spans.Add(new Range(interiorStart, builder.Length));
}
continue;
}
builder.Append(current);
index++;
}
return new GradleScript(builder.ToString(), spans.ToImmutable());
}
private static bool IsSlashyStringStart(string contents, int slashIndex)
{
var index = slashIndex - 1;
while (index >= 0 && char.IsWhiteSpace(contents[index]))
{
index--;
}
if (index < 0)
{
return true;
}
if (contents[index] is '=' or '(' or '[' or '{' or ',' or ':' or ';'
or '!' or '&' or '|' or '?' or '+' or '-' or '*' or '%' or '^' or '~' or '<' or '>')
{
return true;
}
if (!IsGradleIdentifierCharacter(contents[index]))
{
return false;
}
var wordEnd = index + 1;
while (index >= 0 && IsGradleIdentifierCharacter(contents[index]))
{
index--;
}
var word = contents.AsSpan(index + 1, wordEnd - index - 1);
return word is "assert" or "case" or "in" or "return" or "throw" or "yield";
}
/// <summary>
/// Maps a declared release to the numeric form used in container image tags.
/// </summary>
/// <remarks>
/// Java 8 and earlier are written <c>1.8</c> in build files but tagged <c>8</c> in images
/// (<c>eclipse-temurin:8-jre</c>). Anything that is not a plain release number is rejected so a
/// property reference such as <c>${java.version}</c> cannot end up inside a <c>FROM</c> instruction.
/// </remarks>
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
value = value.Trim();
if (value.StartsWith("1.", StringComparison.Ordinal))
{
value = value[2..];
}
return value.Length > 0 && value.All(char.IsAsciiDigit) ? value : null;
}
// Matches direct application assignments outside a block:
// java.toolchain.languageVersion = JavaLanguageVersion.of(21)
// java.toolchain.languageVersion.set(JavaLanguageVersion.of(21))
[GeneratedRegex(@"(?<![\w$.])java\s*\.\s*toolchain\s*\.\s*languageVersion\s*(?:=|\.set\()\s*JavaLanguageVersion\.of\(\s*(?<version>\d+)\s*\)")]
private static partial Regex DirectToolchainRegex();
[GeneratedRegex(@"(?<![\w$.])toolchain\s*\.\s*languageVersion\s*(?:=|\.set\()\s*JavaLanguageVersion\.of\(\s*(?<version>\d+)\s*\)")]
private static partial Regex ScopedToolchainRegex();
[GeneratedRegex(@"(?<![\w$.])(?:extensions\s*\.\s*)?configure\s*<\s*(?:org\.gradle\.api\.plugins\.)?JavaPluginExtension\s*>")]
private static partial Regex ConfiguredJavaPluginExtensionRegex();
// Applied only inside a balanced java { toolchain { ... } } scope.
[GeneratedRegex(@"\blanguageVersion\s*(?:=|\.set\()\s*JavaLanguageVersion\.of\(\s*(?<version>\d+)\s*\)")]
private static partial Regex LanguageVersionRegex();
// Matches JavaVersion.VERSION_21, VERSION_1_8, and numeric/string compatibility forms.
[GeneratedRegex(@"\btargetCompatibility\s*(?:=|\.set\()\s*(?:JavaVersion\.VERSION_(?<version>\d+(?:_\d+)?)|['""]?(?<version>\d+(?:\.\d+)?)['""]?)")]
private static partial Regex TargetCompatibilityRegex();
[GeneratedRegex(@"\bsourceCompatibility\s*(?:=|\.set\()\s*(?:JavaVersion\.VERSION_(?<version>\d+(?:_\d+)?)|['""]?(?<version>\d+(?:\.\d+)?)['""]?)")]
private static partial Regex SourceCompatibilityRegex();
}