File: JavaLanguageSupport.cs
Web Access
Project: src\src\Aspire.Hosting.CodeGeneration.Java\Aspire.Hosting.CodeGeneration.Java.csproj (Aspire.Hosting.CodeGeneration.Java)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics.CodeAnalysis;
 
using Aspire.TypeSystem;
 
namespace Aspire.Hosting.CodeGeneration.Java;
 
/// <summary>
/// Provides language support for Java AppHosts.
/// Implements scaffolding, detection, and runtime configuration.
/// </summary>
internal sealed class JavaLanguageSupport : ILanguageSupport
{
    /// <summary>
    /// The language/runtime identifier for Java.
    /// </summary>
    private const string LanguageId = "java";
 
    /// <summary>
    /// The code generation target language. This maps to the ICodeGenerator.Language property.
    /// </summary>
    private const string CodeGenTarget = "Java";
 
    private const string LanguageDisplayName = "Java";
 
    /// <summary>
    /// AppHost locations, in priority order: the flat single-file layout, and the standard Maven and
    /// Gradle source root that a build-tool project uses.
    /// </summary>
    /// <remarks>
    /// The flat layout is listed first and remains the default so an AppHost that predates build-tool
    /// support keeps working unchanged, and so the common case needs nothing but a JDK.
    /// </remarks>
    private static readonly string[] s_detectionPatterns =
    [
        "AppHost.java",
        "src/main/java/AppHost.java"
    ];
 
    /// <inheritdoc />
    public string Language => LanguageId;
 
    /// <inheritdoc />
    public Dictionary<string, string> Scaffold(ScaffoldRequest request)
    {
        var files = new Dictionary<string, string>();
 
        files[".gitignore"] = """
            .java-build/
            .aspire/
            """;
 
        files["AppHost.java"] = """
            // Aspire Java AppHost
            // For more information, see: https://aspire.dev
            
            import aspire.*;
 
            void main(String[] args) throws Exception {
                var builder = DistributedApplication.CreateBuilder(args);
 
                // Add your resources here, for example:
                // var redis = builder.addRedis("cache");
                // var postgres = builder.addPostgres("db");
 
                builder.build().run();
            }
            """;
 
        // Create apphost.run.json with random ports
        var random = request.PortSeed.HasValue
            ? new Random(request.PortSeed.Value)
            : Random.Shared;
 
        var httpsPort = random.Next(10000, 65000);
        var httpPort = random.Next(10000, 65000);
        var otlpPort = random.Next(10000, 65000);
        var resourceServicePort = random.Next(10000, 65000);
 
        files["apphost.run.json"] = $$"""
            {
              "profiles": {
                "https": {
                  "applicationUrl": "https://localhost:{{httpsPort}};http://localhost:{{httpPort}}",
                  "environmentVariables": {
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:{{otlpPort}}",
                    "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:{{resourceServicePort}}"
                  }
                }
              }
            }
            """;
 
        // Without a pom.xml or build.gradle the Java language server treats the folder as an
        // "invisible project" and only puts the workspace root on the source path, so every
        // reference to the generated SDK under .aspire/modules resolves to "cannot be resolved to a
        // type": no completion, no navigation, and no breakpoint binding in the AppHost. Declaring
        // both source roots is what makes a build-tool-free AppHost a real editing experience.
        // The setting is ignored once a build file exists, because the build tool then owns the
        // project model, so this stays correct if the user later adopts Maven or Gradle.
        // https://github.com/redhat-developer/vscode-java/wiki/Java-Project-Settings
        files[".vscode/settings.json"] = """
            {
              "java.project.sourcePaths": [
                ".",
                ".aspire/modules"
              ],
              "java.compile.nullAnalysis.mode": "disabled"
            }
            """;
 
        return files;
    }
 
    /// <inheritdoc />
    public DetectionResult Detect(string directoryPath)
    {
        foreach (var pattern in s_detectionPatterns)
        {
            // The patterns are written with forward slashes because they are also a wire contract,
            // so they have to be translated before touching the file system on Windows.
            var relativePath = pattern.Replace('/', Path.DirectorySeparatorChar);
 
            if (File.Exists(Path.Combine(directoryPath, relativePath)))
            {
                return DetectionResult.Found(LanguageId, relativePath);
            }
        }
 
        return DetectionResult.NotFound;
    }
 
    /// <summary>
    /// Directory that the generated SDK sources and the AppHost are compiled into.
    /// </summary>
    private const string BuildOutputDirectory = ".java-build";
 
    /// <summary>
    /// Compiler options used to build the AppHost.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The scaffolded AppHost is a compact source file with an instance <c>main</c> method, which
    /// requires Java 25. That feature was previewed in Java 21 through 24 (JEP 445, 463, 477, and
    /// 495) and finalized in Java 25 by <see href="https://openjdk.org/jeps/512">JEP 512</see>, so
    /// <c>--enable-preview</c> is deliberately absent: passing it here compiles no preview feature
    /// and only risks stamping the class files with the preview minor version (65535), which binds
    /// them to one exact JDK release and forces the flag at run time too.
    /// </para>
    /// <para>
    /// <c>--release</c> is used rather than <c>--source</c> because only <c>--release</c> also
    /// constrains the visible API surface. With <c>--source</c> alone a newer JDK still compiles
    /// against its own class library, so an AppHost can bind to APIs that do not exist in Java 25
    /// and then fail at run time on a conforming Java 25 runtime.
    /// </para>
    /// </remarks>
    private static readonly string[] s_javacOptions = ["--release", "25"];
 
    /// <summary>
    /// Argument file listing the generated SDK sources, produced by the code generator.
    /// </summary>
    /// <remarks>
    /// Passed to <c>javac</c> as an <c>@</c> argument file. javac expands these itself, so this
    /// works without a shell and stays well under the command-line length limit even though the
    /// generated SDK is hundreds of files.
    /// </remarks>
    private const string GeneratedSourcesListPath = $"{GeneratedSourcesDirectory}/sources.txt";
 
    /// <summary>
    /// Directory the generated SDK sources are written to.
    /// </summary>
    private const string GeneratedSourcesDirectory = ".aspire/modules";
 
    /// <summary>
    /// Class that the scaffolded AppHost compiles to.
    /// </summary>
    /// <remarks>
    /// The AppHost is declared in the default package, so this is also its fully qualified name.
    /// </remarks>
    private const string AppHostClassName = "AppHost";
 
    /// <summary>
    /// Name of the file written after a successful compile, used to skip the next one.
    /// </summary>
    internal const string CompileStampFileName = ".aspire-compile-stamp";
 
    /// <summary>
    /// Sets the up-to-date check that lets an unchanged AppHost skip <c>javac</c> entirely when supported.
    /// </summary>
    /// <remarks>
    /// <para>
    /// javac given an explicit list of sources recompiles every one of them, so without this the
    /// generated SDK — several hundred files — is recompiled on every launch even when nothing
    /// changed. Every other supported language either has an incremental compiler of its own or no
    /// compile step at all, so this is what brings Java to the same startup cost.
    /// </para>
    /// <para>
    /// The inputs are exactly javac's source roots: the AppHost file, the sources beside it, the
    /// generated SDK, and <c>src/main/java</c> for the layout where the AppHost sits at the project
    /// root. The AppHost directory is recursive because javac is given no <c>-sourcepath</c>, so its
    /// source path defaults to the user class path and therefore to the AppHost directory: a helper
    /// class in a package beside the AppHost is compiled implicitly, and rewriting it in place moves
    /// no ancestor's timestamp. The scan prunes the trees that cannot hold a package - dot
    /// directories, <c>node_modules</c>, and javac's own output - so the sibling trees in a typical
    /// solution do not give back the time this saves.
    /// </para>
    /// </remarks>
    /// <param name="commandSpec">The compile command to update.</param>
    /// <param name="classOutputDirectory">Directory javac writes classes to, which is where the stamp lives.</param>
    [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "The installed CLI roots the force-shared contract when this property exists.")]
    [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The installed CLI roots the force-shared contract when this property exists.")]
    internal static void SetCompileUpToDateCheckIfSupported(object commandSpec, string classOutputDirectory)
    {
        // Aspire.TypeSystem is force-shared from the installed CLI. A newer codegen assembly can
        // therefore run against an older CommandSpec that has the same assembly identity but does not
        // expose this additive property. Probe by name so the optimization is skipped and the older CLI
        // compiles on every launch rather than failing to load the Java language support.
        var upToDateCheckProperty = commandSpec.GetType().GetProperty(nameof(CommandSpec.UpToDateCheck));
 
        if (upToDateCheckProperty is null)
        {
            return;
        }
 
        var expectedTypeName = $"{typeof(CommandSpec).Namespace}.{nameof(CommandUpToDateCheck)}";
        var upToDateCheckType = upToDateCheckProperty.PropertyType;
        var inputsProperty = upToDateCheckType.GetProperty(nameof(CommandUpToDateCheck.Inputs));
        var fileExtensionsProperty = upToDateCheckType.GetProperty(nameof(CommandUpToDateCheck.FileExtensions));
        var stampFileProperty = upToDateCheckType.GetProperty(nameof(CommandUpToDateCheck.StampFile));
 
        if (upToDateCheckProperty.SetMethod is null ||
            !upToDateCheckProperty.SetMethod.IsPublic ||
            upToDateCheckType.Assembly != typeof(CommandSpec).Assembly ||
            upToDateCheckType.FullName != expectedTypeName ||
            upToDateCheckType.IsAbstract ||
            upToDateCheckType.GetConstructor(Type.EmptyTypes) is null ||
            inputsProperty?.PropertyType != typeof(string[]) ||
            inputsProperty.SetMethod is null ||
            !inputsProperty.SetMethod.IsPublic ||
            fileExtensionsProperty?.PropertyType != typeof(string[]) ||
            fileExtensionsProperty.SetMethod is null ||
            !fileExtensionsProperty.SetMethod.IsPublic ||
            stampFileProperty?.PropertyType != typeof(string) ||
            stampFileProperty.SetMethod is null ||
            !stampFileProperty.SetMethod.IsPublic)
        {
            throw new MissingMemberException(
                $"The runtime {nameof(CommandSpec.UpToDateCheck)} contract does not match {expectedTypeName}.");
        }
 
        var upToDateCheck = Activator.CreateInstance(upToDateCheckType)
            ?? throw new MissingMemberException($"The runtime type {expectedTypeName} could not be created.");
 
        inputsProperty.SetValue(upToDateCheck, new[]
        {
            "{appHostFile}",
            "./**",
            $"{GeneratedSourcesDirectory}/**",
            "src/main/java/**"
        });
        // Only sources are inputs. Without this the .class files javac writes beside the sources in
        // the flat layout would invalidate the very check they were produced under.
        fileExtensionsProperty.SetValue(upToDateCheck, new[] { ".java" });
        stampFileProperty.SetValue(upToDateCheck, Path.Combine(classOutputDirectory, CompileStampFileName));
        upToDateCheckProperty.SetValue(commandSpec, upToDateCheck);
    }
 
    /// <inheritdoc />
    public RuntimeSpec GetRuntimeSpec()
    {
        var compile = new CommandSpec
        {
            // No shell. javac creates the destination directory itself, so there is nothing
            // left that needed one, and running without a shell means arguments are not
            // re-split: a project under a path such as "C:\My Projects" works unchanged, on
            // Windows and Unix alike, from a single spec.
            Command = "javac",
            Args = [.. s_javacOptions, "-d", BuildOutputDirectory, $"@{GeneratedSourcesListPath}", "{appHostFile}"]
        };
        SetCompileUpToDateCheckIfSupported(compile, BuildOutputDirectory);
 
        return new RuntimeSpec
        {
            Language = LanguageId,
            DisplayName = LanguageDisplayName,
            CodeGenLanguage = CodeGenTarget,
            DetectionPatterns = s_detectionPatterns,
            // Compilation is a pre-execute step rather than part of Execute so that Execute is a plain
            // JVM launch. That is what lets the AppHost be debugged (the IDE starts the JVM itself and
            // would otherwise start a shell), and it lets --no-build skip the compile.
            // A Maven or Gradle AppHost replaces both commands via JavaAppHostToolchainResolver.
            InstallDependencies = null,
            PreExecute = [compile],
            // Debugging the AppHost itself goes through the same Java debug adapter the resources use.
            // The CLI only takes this path when the extension reports the capability, so a CLI-only
            // run is unaffected.
            ExtensionLaunchCapability = LanguageId,
            Execute = new CommandSpec
            {
                Command = "java",
                // {args} is deliberately absent. When no argument contains that placeholder the CLI
                // appends its arguments as separate argv entries, whereas substituting the placeholder
                // joins them into a single space-separated string the AppHost would have to re-split.
                Args = ["-cp", BuildOutputDirectory, AppHostClassName]
            }
        };
    }
}