Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/extension-e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,16 @@ jobs:
cliBinary: aspire
useXvfb: true
installJava: true
- name: Linux
shardName: java-starter-project-model
spec: out/test-e2e/test-e2e/javaStarterProjectModel.e2e.test.js
runner: ubuntu-latest
rid: linux-x64
archivePattern: aspire-cli-linux-x64*.tar.gz
cliBinary: aspire
useXvfb: true
installJava: true
timeoutMinutes: 110
- name: Linux
shardName: workspace-target-proof
spec: out/test-e2e/test-e2e/workspaceTargetProof.e2e.test.js
Expand Down
4 changes: 2 additions & 2 deletions extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -634,12 +634,12 @@
"explorer/context": [
{
"command": "aspire-vscode.runAppHostCommand",
"when": "resourceFilename =~ /apphost\\.(cs|ts|mts|cts|js|mjs|cjs|rs)$/i",
"when": "resourceFilename =~ /apphost\\.(cs|ts|mts|cts|js|mjs|cjs|rs|java)$/i",
"group": "aspire_actions@1"
},
{
"command": "aspire-vscode.debugAppHostCommand",
"when": "resourceFilename =~ /apphost\\.(cs|ts|mts|cts|js|mjs|cjs|rs)$/i",
"when": "resourceFilename =~ /apphost\\.(cs|ts|mts|cts|js|mjs|cjs|rs|java)$/i",
"group": "aspire_actions@2"
}
],
Expand Down
81 changes: 73 additions & 8 deletions extension/scripts/run-e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ const matchedTestSpecs = verifyExtesterFeedOnly ? [] : findSpecMatches(testSpec)
const enableJavaE2E = process.env.ASPIRE_EXTENSION_E2E_ENABLE_JAVA
? process.env.ASPIRE_EXTENSION_E2E_ENABLE_JAVA === 'true'
: matchedTestSpecs.length > 0 && matchedTestSpecs.every(isJavaSpecPath);
const useJavaStarterWorkspace = enableJavaE2E
&& matchedTestSpecs.length > 0
&& matchedTestSpecs.every(specPath => path.basename(specPath).toLowerCase().startsWith('javastarterprojectmodel.'));
// redhat.java supplies the language server, which is what produces workspace diagnostics and the
// classpath the debug adapter launches against. vscjava.vscode-java-debug supplies the `java` debug
// adapter the Aspire debugger delegates to, and vscjava.vscode-java-dependency is a hard activation
Expand Down Expand Up @@ -614,7 +617,7 @@ async function main() {
validateCliPath(cliPath);
const appHostSdkVersion = resolveAppHostSdkVersion(cliPath);
prepareWorkspaceFixture(cliPath, appHostSdkVersion);
copyJavaPlaygroundIntoWorkspace(bundledCliPath);
prepareJavaWorkspace(bundledCliPath, appHostSdkVersion);
restoreWorkspaceFixture();
const vsixPath = process.env.ASPIRE_EXTENSION_E2E_VSIX
? path.resolve(process.env.ASPIRE_EXTENSION_E2E_VSIX)
Expand Down Expand Up @@ -900,7 +903,7 @@ function copyJavaPlaygroundIntoWorkspace(bundledCliPath) {
// `.aspire/` is generated rather than checked in, so it has to exist before the copy: it is what
// the AppHost's `import aspire.*` statements resolve against, and the generated sources are the
// very thing the diagnostics test measures.
ensureJavaAppHostSdkGenerated(bundledCliPath, source);
ensureJavaAppHostSdkGenerated(bundledCliPath, path.join(source, 'JavaSpringBoot.AppHost.Java'));

logStep('Copying the Java Spring Boot playground into the E2E workspace');
fs.cpSync(source, workspaceRoot, {
Expand Down Expand Up @@ -939,6 +942,70 @@ function copyJavaPlaygroundIntoWorkspace(bundledCliPath) {
fs.rmSync(path.join(workspaceRoot, 'aspire.config.json'), { force: true });
}

function prepareJavaWorkspace(bundledCliPath, appHostSdkVersion) {
if (!enableJavaE2E) {
return;
}

if (!useJavaStarterWorkspace) {
copyJavaPlaygroundIntoWorkspace(bundledCliPath);
return;
}

assertWorkspaceRootIsNotGitIgnored();
logStep('Generating the Java starter in the E2E workspace');

for (const entry of fs.readdirSync(workspaceRoot)) {
fs.rmSync(path.join(workspaceRoot, entry), { recursive: true, force: true });
}

const result = spawnSync(bundledCliPath, [
'new',
'aspire-java-starter',
'--name',
'JavaStarter',
'--output',
workspaceRoot,
'--version',
appHostSdkVersion,
'--localhost-tld',
'false',
'--suppress-agent-init',
'--non-interactive',
'--nologo',
], {
cwd: extensionRoot,
env: getAspireCliEnvironment(),
shell: false,
encoding: 'utf8',
timeout: 600000,
});

if (result.error) {
throw new Error(`Unable to generate the Java starter: ${result.error.message}`);
}

if (result.status !== 0) {
throw new Error(`Generating the Java starter failed with code ${result.status ?? `signal ${result.signal ?? 'unknown'}`}.
stdout:
${result.stdout}
stderr:
${result.stderr}`);
}

fs.writeFileSync(workspaceMarkerFile, `${runId}\n`);
Comment thread
adamint marked this conversation as resolved.
Outdated
ensureJavaAppHostSdkGenerated(bundledCliPath, workspaceRoot);

const settingsPath = path.join(workspaceRoot, '.vscode', 'settings.json');
const settings = fs.existsSync(settingsPath) ? JSON.parse(fs.readFileSync(settingsPath, 'utf8')) : {};
settings['aspire.aspireCliExecutablePath'] = bundledCliPath;
settings['aspire.enableAutoRestore'] = false;
settings['aspire.enableSettingsFileCreationPromptOnStartup'] = false;
settings['aspire.appHostDiscoveryTimeoutMs'] = 120000;
settings['java.configuration.updateBuildConfiguration'] = 'automatic';
fs.writeFileSync(settingsPath, JSON.stringify(settings, undefined, 2));
}

/**
* Fails when the Java workspace root is excluded by a .gitignore rule.
*
Expand Down Expand Up @@ -970,14 +1037,12 @@ function assertWorkspaceRootIsNotGitIgnored() {
}

/**
* Makes sure the playground's generated Aspire Java SDK exists before it is copied.
* Makes sure the Java AppHost's generated Aspire SDK exists before VS Code opens the workspace.
*
* `aspire restore` is run in the playground itself rather than in the copied workspace because the
* generator assemblies resolve relative to the repository's package feed; the same command run
* against a copy under a temporary directory fails to discover the Java code generator.
* `aspire restore` runs in the repository-local AppHost because the generator assemblies resolve
* relative to the repository's package feed; a temporary-directory copy cannot discover them.
*/
function ensureJavaAppHostSdkGenerated(bundledCliPath, playgroundRoot) {
const appHostDirectory = path.join(playgroundRoot, 'JavaSpringBoot.AppHost.Java');
function ensureJavaAppHostSdkGenerated(bundledCliPath, appHostDirectory) {
const generatedModules = path.join(appHostDirectory, '.aspire', 'modules');
if (fs.existsSync(generatedModules) && fs.readdirSync(generatedModules).length > 0) {
return;
Expand Down
2 changes: 2 additions & 0 deletions extension/src/dcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ export interface JavaLaunchConfiguration extends ExecutableLaunchConfiguration {
type: "java";
request?: "launch" | "attach";
working_directory?: string;
// Absolute JVM launcher selected by the CLI. Absent for older CLIs that only send "java".
java_exec?: string;
// A fully qualified class name, optionally prefixed with a Java module name
// ("[module/]com.example.Api"), or the path of the .java source file declaring main. Absent when
// the IDE should resolve the entry point itself. A JAR path is never valid here; an executable
Expand Down
5 changes: 5 additions & 0 deletions extension/src/debugger/AspireDebugSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ export function getLoggableDebugConfiguration(debugConfig: AspireResourceExtende
function redactedJavaLaunchFields(debugConfig: AspireResourceExtendedDebugConfiguration): Record<string, unknown> {
const redacted: Record<string, unknown> = {};

if (debugConfig.javaExec !== undefined) {
redacted.javaExec = '<redacted>';
}

if (debugConfig.vmArgs !== undefined) {
redacted.vmArgs = '<redacted>';
}
Expand Down Expand Up @@ -1340,6 +1344,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche
main_class: javaCommand!.mainClass,
class_paths: resolveJavaClassPaths(javaCommand!.classPaths, path.dirname(projectFile)),
working_directory: path.dirname(projectFile),
...(javaCommand!.javaExec ? { java_exec: javaCommand!.javaExec } : {}),
// build_tool is deliberately absent: it only drives a language server project reimport,
// and the classpath is sent explicitly here, so the launch never depends on one.
...(javaCommand!.vmArgs.length > 0 ? { vm_args: javaCommand!.vmArgs } : {})
Expand Down
24 changes: 21 additions & 3 deletions extension/src/debugger/languages/java.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@ export const javaDebuggerExtension: ResourceDebuggerExtension = {
debugConfiguration.cwd = launchConfig.working_directory;
}

if (launchConfig.java_exec) {
debugConfiguration.javaExec = launchConfig.java_exec;
}

// vscjava.vscode-java-debug requires mainClass to start a launch session, and accepts a fully
// qualified class name, optionally prefixed with a module name, or the path of a .java source
// file.
Expand Down Expand Up @@ -311,7 +315,7 @@ export const javaDebuggerExtension: ResourceDebuggerExtension = {
// before the main class, so the main class is located as the first non-option argument that is not
// itself an option's value. Returns null when the command does not match, which keeps an
// unrecognised command on the non-debug launch path instead of starting a JVM with wrong arguments.
export function parseJavaAppHostCommand(args: string[]): { mainClass: string; classPaths: string[]; vmArgs: string[]; appHostArgs: string[] } | null {
export function parseJavaAppHostCommand(args: string[]): { mainClass: string; classPaths: string[]; vmArgs: string[]; appHostArgs: string[]; javaExec?: string } | null {
// args[0] is the "java" executable itself, prepended by the CLI.
if (args.length < 2) {
return null;
Expand All @@ -322,8 +326,13 @@ export function parseJavaAppHostCommand(args: string[]): { mainClass: string; cl
// than the JVM's and the first bare token is a goal or task, not a main class. Without this check
// "exec:java" would be handed to the debug adapter as the class to launch.
// The path may be absolute (a JAVA_HOME-qualified launcher), so compare only the file name.
// Bare "java" is the legacy wire shape. Any path must be absolute because only the CLI's
// resolved launcher is authoritative; retaining a relative path would make the adapter resolve
// it against a different working directory.
const executable = args[0].split(/[\\/]/).pop() ?? args[0];
if (executable.toLowerCase().replace(/\.exe$/, '') !== 'java') {
const normalizedExecutable = executable.toLowerCase().replace(/\.(exe|com|bat|cmd)$/, '');
const isBareJava = args[0].toLowerCase() === 'java';
if (normalizedExecutable !== 'java' || (!isBareJava && !isAbsolutePath(args[0]))) {
Comment on lines +333 to +335
return null;
}

Expand All @@ -335,6 +344,9 @@ export function parseJavaAppHostCommand(args: string[]): { mainClass: string; cl
// anything looking wrong. The "--name=value" spelling is a single token and needs no entry here.
// https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html
const valueTakingOptions = new Set([
'--module-path', '-p',
'--upgrade-module-path',
'--add-modules',
'--limit-modules',
'--add-exports', '--add-opens', '--add-reads',
'--patch-module',
Expand Down Expand Up @@ -388,7 +400,13 @@ export function parseJavaAppHostCommand(args: string[]): { mainClass: string; cl

// First bare token after the options is the main class; everything after it is the
// application's own arguments, which the JVM never interprets.
return { mainClass: arg, classPaths, vmArgs, appHostArgs: args.slice(i + 1) };
return {
mainClass: arg,
classPaths,
vmArgs,
appHostArgs: args.slice(i + 1),
...(isAbsolutePath(args[0]) ? { javaExec: args[0] } : {})
};
}

return null;
Expand Down
99 changes: 88 additions & 11 deletions extension/src/editor/parsers/javaAppHostParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,22 +170,99 @@ function isCreateBuilderCall(node: TreeSitterNode): boolean {
}

/**
* Finds the entry point, covering both shapes an AppHost can take: a JEP 512 implicitly declared
* `void main()`, and a conventional `public static void main(String[])` inside a class, which is what
* a Maven or Gradle AppHost project uses. See https://openjdk.org/jeps/512.
* Finds the entry point using the Java 25 launch protocol: a `String[]` main takes precedence over a
* no-argument main, and either can be static or an instance method. See https://openjdk.org/jeps/512.
*/
function findMainMethod(rootNode: TreeSitterNode): TreeSitterNode | undefined {
let result: TreeSitterNode | undefined;
visit(rootNode, node => {
if (node.type === 'method_declaration' && node.childForFieldName('name')?.text === 'main') {
result = node;
return false;
const builderInvocation = findInvocation(rootNode, isCreateBuilderCall);
const scope = builderInvocation ? findOutermostTypeBody(builderInvocation, rootNode) : rootNode;
let noArgumentMain: TreeSitterNode | undefined;
let stringArrayMain: TreeSitterNode | undefined;
for (const node of scope.namedChildren) {
if (node.type !== 'method_declaration' || !isLaunchableMainMethod(node)) {
continue;
}

return true;
});
if (getMainParameterShape(node) === 'stringArray') {
stringArrayMain = node;
break;
}

return result;
noArgumentMain ??= node;
}

return stringArrayMain ?? noArgumentMain;
}

function findOutermostTypeBody(node: TreeSitterNode, rootNode: TreeSitterNode): TreeSitterNode {
let outermostType: TreeSitterNode | undefined;
for (let current = node.parent; current && current !== rootNode; current = current.parent) {
if (isTypeDeclaration(current)) {
outermostType = current;
}
}

return outermostType?.childForFieldName('body') ?? rootNode;
}

function isTypeDeclaration(node: TreeSitterNode): boolean {
return node.type === 'class_declaration'
|| node.type === 'enum_declaration'
|| node.type === 'interface_declaration'
|| node.type === 'record_declaration';
}

function isLaunchableMainMethod(node: TreeSitterNode): boolean {
if (node.childForFieldName('name')?.text !== 'main'
|| node.childForFieldName('type')?.type !== 'void_type'
|| node.childForFieldName('type_parameters')
|| !node.childForFieldName('body')) {
return false;
}

const modifiers = node.namedChildren.find(child => child.type === 'modifiers');
if (modifiers?.children.some(child => child.type === 'private')) {
return false;
}

return getMainParameterShape(node) !== undefined;
}

type MainParameterShape = 'none' | 'stringArray';

function getMainParameterShape(node: TreeSitterNode): MainParameterShape | undefined {
const parameters = node.childForFieldName('parameters')?.namedChildren ?? [];
if (parameters.length === 0) {
return 'none';
}

if (parameters.length !== 1) {
return undefined;
}

const parameter = parameters[0];
if (parameter.type === 'formal_parameter') {
const type = parameter.childForFieldName('type')?.text;
const trailingDimensions = parameter.childForFieldName('dimensions')?.text ?? '';
return isStringArrayType(`${type ?? ''}${trailingDimensions}`) ? 'stringArray' : undefined;
}

if (parameter.type === 'spread_parameter') {
const type = parameter.namedChildren.find(child =>
child.type === 'type_identifier' || child.type === 'scoped_type_identifier');
return isStringType(type?.text) ? 'stringArray' : undefined;
}

return undefined;
}

function isStringArrayType(type: string): boolean {
const normalizedType = type.replaceAll(/\s/g, '');
return normalizedType === 'String[]' || normalizedType === 'java.lang.String[]';
}

function isStringType(type: string | undefined): boolean {
return type === 'String' || type === 'java.lang.String';
}

function getFirstArgument(node: TreeSitterNode): TreeSitterNode | undefined {
Expand Down
12 changes: 12 additions & 0 deletions extension/src/test-e2e/helpers/java.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ import { openAspireView } from './vscode';

export const JAVA_APP_HOST_DIRECTORY = 'JavaSpringBoot.AppHost.Java';
export const JAVA_APP_HOST_SOURCE = path.join(JAVA_APP_HOST_DIRECTORY, 'AppHost.java');
export const JAVA_STARTER_APP_HOST_SOURCE = 'AppHost.java';

export function getJavaAppHostSourcePath(): string {
return path.join(getWorkspaceRoot(), JAVA_APP_HOST_SOURCE);
}

export function getJavaStarterAppHostSourcePath(): string {
return path.join(getWorkspaceRoot(), JAVA_STARTER_APP_HOST_SOURCE);
}

/**
* Brings the window to the state every Java spec needs, and fails loudly when it cannot.
*
Expand All @@ -27,6 +32,13 @@ export async function prepareJavaWorkspace(): Promise<void> {
await waitForRepositoryIdle();
}

export async function prepareJavaStarterWorkspace(): Promise<void> {
await openAspireView();
await assertJavaCapabilityAdvertised();
await waitForWorkspaceAppHostCandidate(getJavaStarterAppHostSourcePath());
await waitForRepositoryIdle();
}

/**
* Waits for discovery to surface the single-file Java AppHost.
*
Expand Down
Loading
Loading