NodeExecutor.java

/*******************************************************************************
 * Copyright (c) 2026 Carsten Hammer.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     Carsten Hammer
 *******************************************************************************/
package org.sandbox.jdt.internal.css.core;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;

import org.eclipse.core.runtime.ILog;
import org.eclipse.core.runtime.Platform;

/**
 * Utility for executing Node.js/npm commands.
 */
public class NodeExecutor {

	static final String NODE_INSTALL_DIRECTORY_PROPERTY = "sandbox.css.node.installDirectory"; //$NON-NLS-1$
	static final String NODE_MODULES_DIRECTORY_PROPERTY = "sandbox.css.node.modulesDirectory"; //$NON-NLS-1$

	private static final String PRETTIER_COMMAND = "prettier"; //$NON-NLS-1$
	private static final String STYLELINT_COMMAND = "stylelint"; //$NON-NLS-1$
	private static final int TIMEOUT_SECONDS = 30;
	private static final ILog LOG = Platform.getLog(NodeExecutor.class);

	/**
	 * Check if Node.js is available on the system or in the Maven-owned test toolchain.
	 */
	public static boolean isNodeAvailable() {
		return isCommandAvailable(List.of(nodeExecutable(
				System.getProperty("os.name"), System.getProperty(NODE_INSTALL_DIRECTORY_PROPERTY)), //$NON-NLS-1$
				"--version")); //$NON-NLS-1$
	}

	/**
	 * Check if the npm command runner or the Maven-owned CSS tools are available.
	 */
	public static boolean isNpxAvailable() {
		String installDirectory = System.getProperty(NODE_INSTALL_DIRECTORY_PROPERTY);
		String modulesDirectory = System.getProperty(NODE_MODULES_DIRECTORY_PROPERTY);
		if (hasPinnedConfiguration(installDirectory, modulesDirectory)) {
			if (!hasText(installDirectory) || !hasText(modulesDirectory)) {
				return false;
			}
			Path modules = Path.of(modulesDirectory);
			return isNodeAvailable()
					&& Files.isRegularFile(toolScript(modules, PRETTIER_COMMAND))
					&& Files.isRegularFile(toolScript(modules, STYLELINT_COMMAND));
		}
		return isCommandAvailable(List.of(npxExecutable(System.getProperty("os.name")), "--version")); //$NON-NLS-1$ //$NON-NLS-2$
	}

	/** Execute an npx command and return stdout/stderr without providing stdin. */
	public static ExecutionResult executeNpx(String... args) throws IOException, InterruptedException {
		return executeNpxInternal(null, args);
	}

	/** Execute an npx command with UTF-8 input supplied on stdin. */
	public static ExecutionResult executeNpxWithInput(String input, String... args)
			throws IOException, InterruptedException {
		return executeNpxInternal(input == null ? "" : input, args); //$NON-NLS-1$
	}

	private static ExecutionResult executeNpxInternal(String input, String... args)
			throws IOException, InterruptedException {
		List<String> command = buildNpxCommand(
				System.getProperty("os.name"), //$NON-NLS-1$
				System.getProperty(NODE_INSTALL_DIRECTORY_PROPERTY),
				System.getProperty(NODE_MODULES_DIRECTORY_PROPERTY),
				args);

		ProcessBuilder pb = new ProcessBuilder(command);
		pb.redirectErrorStream(false);

		Process process = pb.start();
		try (StreamGobbler outputGobbler = new StreamGobbler(process.getInputStream());
				StreamGobbler errorGobbler = new StreamGobbler(process.getErrorStream())) {
			outputGobbler.start();
			errorGobbler.start();

			try (OutputStream processInput = process.getOutputStream()) {
				if (input != null) {
					processInput.write(input.getBytes(StandardCharsets.UTF_8));
				}
			}

			boolean finished;
			try {
				finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
			} catch (InterruptedException e) {
				process.destroyForcibly();
				Thread.currentThread().interrupt();
				throw e;
			}
			if (!finished) {
				process.destroyForcibly();
				throw new IOException("Process timed out after " + TIMEOUT_SECONDS + " seconds"); //$NON-NLS-1$ //$NON-NLS-2$
			}

			awaitCompleteOutput(outputGobbler, errorGobbler);
			return new ExecutionResult(process.exitValue(), outputGobbler.getOutput(), errorGobbler.getOutput());
		} finally {
			process.destroy();
		}
	}

	static List<String> buildNpxCommand(String osName, String installDirectory, String modulesDirectory,
			String... args) {
		if (args == null || args.length == 0 || !hasText(args[0])) {
			throw new IllegalArgumentException("An npm command is required"); //$NON-NLS-1$
		}

		boolean pinned = hasPinnedConfiguration(installDirectory, modulesDirectory);
		if (!pinned) {
			List<String> command = new ArrayList<>(args.length + 1);
			command.add(npxExecutable(osName));
			command.addAll(List.of(args));
			return command;
		}
		if (!hasText(installDirectory) || !hasText(modulesDirectory)) {
			throw new IllegalStateException(
					"Both Maven-owned Node toolchain directories must be configured together"); //$NON-NLS-1$
		}

		List<String> command = new ArrayList<>(args.length + 1);
		command.add(nodeExecutable(osName, installDirectory));
		command.add(toolScript(Path.of(modulesDirectory), args[0]).toString());
		for (int index = 1; index < args.length; index++) {
			command.add(args[index]);
		}
		return command;
	}

	static String npxExecutable(String osName) {
		return isWindows(osName) ? "npx.cmd" : "npx"; //$NON-NLS-1$ //$NON-NLS-2$
	}

	static String nodeExecutable(String osName, String installDirectory) {
		if (!hasText(installDirectory)) {
			return "node"; //$NON-NLS-1$
		}
		String executable = isWindows(osName) ? "node.exe" : "node"; //$NON-NLS-1$ //$NON-NLS-2$
		return Path.of(installDirectory).resolve("node").resolve(executable).toString(); //$NON-NLS-1$
	}

	static Path toolScript(Path nodeModulesDirectory, String command) {
		return switch (command) {
			case PRETTIER_COMMAND -> nodeModulesDirectory.resolve(PRETTIER_COMMAND).resolve("bin") //$NON-NLS-1$
					.resolve("prettier.cjs"); //$NON-NLS-1$
			case STYLELINT_COMMAND -> nodeModulesDirectory.resolve(STYLELINT_COMMAND).resolve("bin") //$NON-NLS-1$
					.resolve("stylelint.mjs"); //$NON-NLS-1$
			default -> throw new IllegalArgumentException("Unsupported pinned npm command: " + command); //$NON-NLS-1$
		};
	}

	private static boolean isCommandAvailable(List<String> command) {
		Process process = null;
		try {
			process = new ProcessBuilder(command).start();
			boolean finished = process.waitFor(5, TimeUnit.SECONDS);
			if (!finished) {
				process.destroyForcibly();
				return false;
			}
			return process.exitValue() == 0;
		} catch (IOException | InterruptedException e) {
			if (process != null) {
				process.destroyForcibly();
			}
			if (e instanceof InterruptedException) {
				Thread.currentThread().interrupt();
			}
			return false;
		}
	}

	private static boolean hasPinnedConfiguration(String installDirectory, String modulesDirectory) {
		return hasText(installDirectory) || hasText(modulesDirectory);
	}

	private static boolean hasText(String value) {
		return value != null && !value.isBlank();
	}

	private static boolean isWindows(String osName) {
		return osName != null && osName.toLowerCase(Locale.ROOT).startsWith("windows"); //$NON-NLS-1$
	}

	private static void awaitCompleteOutput(StreamGobbler outputGobbler, StreamGobbler errorGobbler)
			throws InterruptedException {
		try {
			// The process has exited, so both streams will reach EOF. Do not return
			// partially consumed JSON merely because a fixed join timeout elapsed.
			outputGobbler.join();
			errorGobbler.join();
		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
			throw e;
		}
	}

	/**
	 * Helper class to read stream output in a separate thread to avoid deadlock.
	 * Implements AutoCloseable to ensure the input stream is closed even if the thread doesn't run.
	 */
	private static class StreamGobbler extends Thread implements AutoCloseable {
		private final InputStream inputStream;
		private final StringBuilder output = new StringBuilder();
		private volatile boolean streamConsumed = false;

		StreamGobbler(InputStream inputStream) {
			this.inputStream = inputStream;
			setDaemon(true);
		}

		@Override
		public void run() {
			try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
				streamConsumed = true;
				String line;
				while ((line = reader.readLine()) != null) {
					output.append(line).append("\n"); //$NON-NLS-1$
				}
			} catch (IOException e) {
				LOG.log(new org.eclipse.core.runtime.Status(org.eclipse.core.runtime.IStatus.WARNING,
						"sandbox_css_cleanup", "Error reading stream", e)); //$NON-NLS-1$ //$NON-NLS-2$
			}
		}

		@Override
		public void close() {
			if (!streamConsumed) {
				try {
					inputStream.close();
				} catch (IOException e) {
					// Process destruction also closes the stream.
				}
			}
		}

		String getOutput() {
			return output.toString();
		}
	}

	/** Result of a command execution. */
	public static class ExecutionResult {
		public final int exitCode;
		public final String stdout;
		public final String stderr;

		public ExecutionResult(int exitCode, String stdout, String stderr) {
			this.exitCode = exitCode;
			this.stdout = stdout;
			this.stderr = stderr;
		}

		public boolean isSuccess() {
			return exitCode == 0;
		}
	}
}