PrettierRunner.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.IOException;
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.Map;

import org.eclipse.core.resources.IFile;
import org.sandbox.jdt.internal.css.CSSCleanupPlugin;
import org.sandbox.jdt.internal.css.preferences.CSSPreferenceConstants;

/** Runs Prettier to format CSS files. */
public class PrettierRunner {

	/** Check if Prettier is available via npx. */
	public static boolean isPrettierAvailable() {
		if (!NodeExecutor.isNpxAvailable()) {
			return false;
		}
		try {
			NodeExecutor.ExecutionResult result = NodeExecutor.executeNpx("prettier", "--version"); //$NON-NLS-1$ //$NON-NLS-2$
			return result.isSuccess();
		} catch (Exception e) {
			return false;
		}
	}

	/** Format a CSS file using Prettier and the configured JSON options. */
	public static String format(IFile file) throws IOException, InterruptedException {
		String optionsJson = CSSCleanupPlugin.getDefault().getPreferenceStore()
				.getString(CSSPreferenceConstants.PRETTIER_OPTIONS);
		return format(file, optionsJson);
	}

	static String format(IFile file, String optionsJson) throws IOException, InterruptedException {
		if (!NodeExecutor.isNpxAvailable()) {
			throw new IllegalStateException("npx is not available. Please install Node.js and npm."); //$NON-NLS-1$
		}

		Path sourcePath = localPath(file);
		String normalizedOptions = normalizeAndValidateOptions(optionsJson);
		String originalContent = Files.readString(sourcePath, StandardCharsets.UTF_8);
		Path configFile = null;
		try {
			if (!"{}".equals(normalizedOptions)) { //$NON-NLS-1$
				configFile = createTemporaryConfig(sourcePath, normalizedOptions);
			}
			List<String> args = buildArguments(sourcePath.toString(), configFile != null ? configFile.toString() : null);
			NodeExecutor.ExecutionResult result = NodeExecutor.executeNpxWithInput(
					originalContent, args.toArray(String[]::new));
			if (!result.isSuccess()) {
				throw new IOException(toolFailure("Prettier", result)); //$NON-NLS-1$
			}
			if (result.stdout.isBlank()) {
				throw new IOException("Prettier returned no formatted CSS output"); //$NON-NLS-1$
			}
			return result.stdout;
		} finally {
			if (configFile != null) {
				Files.deleteIfExists(configFile);
			}
		}
	}

	/** Validates that the preference is a JSON object and canonicalizes blank/empty input. */
	public static String normalizeAndValidateOptions(String optionsJson) {
		String normalized = optionsJson == null || optionsJson.isBlank() ? "{}" : optionsJson.trim(); //$NON-NLS-1$
		Map<String, Object> parsed = SimpleJsonParser.parseObject(normalized);
		return parsed.isEmpty() ? "{}" : normalized; //$NON-NLS-1$
	}

	static List<String> buildArguments(String filePath, String configPath) {
		List<String> args = new ArrayList<>(List.of(
				"prettier", "--stdin-filepath", filePath)); //$NON-NLS-1$ //$NON-NLS-2$
		if (configPath != null && !configPath.isBlank()) {
			args.add("--config"); //$NON-NLS-1$
			args.add(configPath);
		}
		return List.copyOf(args);
	}

	private static Path createTemporaryConfig(Path sourcePath, String optionsJson) throws IOException {
		Path parent = sourcePath.getParent();
		Path configFile = parent != null
				? Files.createTempFile(parent, ".sandbox-prettier-", ".json") //$NON-NLS-1$ //$NON-NLS-2$
				: Files.createTempFile("sandbox-prettier-", ".json"); //$NON-NLS-1$ //$NON-NLS-2$
		Files.writeString(configFile, optionsJson, StandardCharsets.UTF_8);
		return configFile;
	}

	private static Path localPath(IFile file) throws IOException {
		if (file.getLocation() == null) {
			throw new IOException("CSS file has no local filesystem location: " + file.getFullPath()); //$NON-NLS-1$
		}
		return file.getLocation().toFile().toPath();
	}

	private static String toolFailure(String tool, NodeExecutor.ExecutionResult result) {
		String detail = !result.stderr.isBlank() ? result.stderr.trim() : result.stdout.trim();
		return detail.isBlank()
				? tool + " failed with exit code " + result.exitCode //$NON-NLS-1$
				: tool + " failed with exit code " + result.exitCode + ": " + detail; //$NON-NLS-1$ //$NON-NLS-2$
	}
}