StylelintRunner.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.math.BigDecimal;
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 Stylelint to validate and fix CSS files. */
public class StylelintRunner {

	private static final int LINT_PROBLEMS_EXIT_CODE = 2;

	/** Validate a CSS file using Stylelint and the configured config file. */
	public static CSSValidationResult validate(IFile file) throws Exception {
		String configPath = configuredStylelintConfig();
		return validate(file, configPath);
	}

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

		Path filePath = localPath(file);
		List<String> args = buildValidateArguments(filePath.toString(), configPath);
		NodeExecutor.ExecutionResult result = NodeExecutor.executeNpx(args.toArray(String[]::new));
		String report = extractJsonReport(result.stderr);
		if (report == null) {
			report = extractJsonReport(result.stdout);
		}

		CSSValidationResult validation;
		if (report != null) {
			try {
				validation = parseStylelintOutput(report);
			} catch (IllegalArgumentException e) {
				throw new IOException("Could not parse Stylelint JSON output: " + e.getMessage() //$NON-NLS-1$
						+ diagnosticSuffix(result), e);
			}
		} else {
			validation = new CSSValidationResult(true, List.of());
		}

		if (result.exitCode != 0 && result.exitCode != LINT_PROBLEMS_EXIT_CODE) {
			throw new IOException(toolFailure("Stylelint", result)); //$NON-NLS-1$
		}
		if (result.exitCode == LINT_PROBLEMS_EXIT_CODE && validation.getIssues().isEmpty()) {
			throw new IOException("Stylelint reported lint problems but returned no JSON diagnostics" //$NON-NLS-1$
					+ diagnosticSuffix(result));
		}
		return validation;
	}

	/** Fix CSS issues using Stylelint --fix and the configured config file. */
	public static String fix(IFile file) throws IOException, InterruptedException {
		String configPath = configuredStylelintConfig();
		return fix(file, configPath);
	}

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

		Path filePath = localPath(file);
		String originalContent = Files.readString(filePath, StandardCharsets.UTF_8);
		List<String> args = buildFixArguments(filePath.toString(), configPath);
		NodeExecutor.ExecutionResult result = NodeExecutor.executeNpxWithInput(
				originalContent, args.toArray(String[]::new));

		if (result.exitCode != 0 && result.exitCode != LINT_PROBLEMS_EXIT_CODE) {
			throw new IOException(toolFailure("Stylelint --fix", result)); //$NON-NLS-1$
		}
		return result.stdout.isEmpty() ? originalContent : result.stdout;
	}

	static List<String> buildValidateArguments(String filePath, String configPath) {
		List<String> args = new ArrayList<>(List.of(
				"stylelint", filePath, "--formatter", "json")); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
		appendConfig(args, configPath);
		return List.copyOf(args);
	}

	static List<String> buildFixArguments(String filePath, String configPath) {
		List<String> args = new ArrayList<>(List.of(
				"stylelint", "--stdin", "--stdin-filename", filePath, "--fix")); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
		appendConfig(args, configPath);
		return List.copyOf(args);
	}

	static CSSValidationResult parseStylelintOutput(String jsonOutput) {
		Object parsed = SimpleJsonParser.parse(jsonOutput);
		if (!(parsed instanceof List<?> results)) {
			throw new IllegalArgumentException("Expected a Stylelint JSON result array"); //$NON-NLS-1$
		}

		List<CSSValidationResult.Issue> issues = new ArrayList<>();
		for (Object resultValue : results) {
			if (!(resultValue instanceof Map<?, ?> result)) {
				continue;
			}
			collectWarnings(result.get("warnings"), issues); //$NON-NLS-1$
			collectSpecialIssues(result.get("parseErrors"), "error", "parse-error", issues); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
			collectSpecialIssues(result.get("invalidOptionWarnings"), "error", "invalid-option", issues); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
		}
		return new CSSValidationResult(issues.isEmpty(), issues);
	}

	static String extractJsonReport(String output) {
		if (output == null || output.isBlank()) {
			return null;
		}
		for (int start = output.indexOf('['); start >= 0; start = output.indexOf('[', start + 1)) {
			for (int end = output.lastIndexOf(']'); end > start; end = output.lastIndexOf(']', end - 1)) {
				String candidate = output.substring(start, end + 1);
				try {
					if (SimpleJsonParser.parse(candidate) instanceof List<?>) {
						return candidate;
					}
				} catch (IllegalArgumentException e) {
					// stderr can contain notices before the formatter payload.
				}
			}
		}
		return null;
	}

	/** Check if Stylelint is available. */
	public static boolean isStylelintAvailable() {
		try {
			NodeExecutor.ExecutionResult result = NodeExecutor.executeNpx("stylelint", "--version"); //$NON-NLS-1$ //$NON-NLS-2$
			return result.isSuccess();
		} catch (Exception e) {
			return false;
		}
	}

	private static void collectWarnings(Object warningValue, List<CSSValidationResult.Issue> issues) {
		if (!(warningValue instanceof List<?> warnings)) {
			return;
		}
		for (Object warningValueEntry : warnings) {
			if (!(warningValueEntry instanceof Map<?, ?> warning)) {
				continue;
			}
			issues.add(new CSSValidationResult.Issue(
					integer(warning.get("line"), 1), //$NON-NLS-1$
					integer(warning.get("column"), 1), //$NON-NLS-1$
					string(warning.get("severity"), "warning"), //$NON-NLS-1$ //$NON-NLS-2$
					string(warning.get("rule"), "stylelint"), //$NON-NLS-1$ //$NON-NLS-2$
					string(warning.get("text"), "Stylelint problem"))); //$NON-NLS-1$ //$NON-NLS-2$
		}
	}

	private static void collectSpecialIssues(Object value, String severity, String rule,
			List<CSSValidationResult.Issue> issues) {
		if (!(value instanceof List<?> entries)) {
			return;
		}
		for (Object entryValue : entries) {
			if (entryValue instanceof String text && !text.isBlank()) {
				issues.add(new CSSValidationResult.Issue(1, 1, severity, rule, text));
				continue;
			}
			if (!(entryValue instanceof Map<?, ?> entry)) {
				continue;
			}
			issues.add(new CSSValidationResult.Issue(
					integer(entry.get("line"), 1), //$NON-NLS-1$
					integer(entry.get("column"), 1), //$NON-NLS-1$
					severity,
					rule,
					string(entry.get("text"), string(entry.get("message"), "Stylelint problem")))); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
		}
	}

	private static int integer(Object value, int fallback) {
		if (value instanceof BigDecimal number) {
			try {
				return number.intValueExact();
			} catch (ArithmeticException e) {
				return fallback;
			}
		}
		return fallback;
	}

	private static String string(Object value, String fallback) {
		return value instanceof String text && !text.isBlank() ? text : fallback;
	}

	private static void appendConfig(List<String> args, String configPath) {
		if (configPath != null && !configPath.isBlank()) {
			args.add("--config"); //$NON-NLS-1$
			args.add(configPath);
		}
	}

	private static String configuredStylelintConfig() {
		String configured = CSSCleanupPlugin.getDefault().getPreferenceStore()
				.getString(CSSPreferenceConstants.STYLELINT_CONFIG);
		return configured == null ? "" : configured.trim(); //$NON-NLS-1$
	}

	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$
	}

	private static String diagnosticSuffix(NodeExecutor.ExecutionResult result) {
		String detail = !result.stderr.isBlank() ? result.stderr.trim() : result.stdout.trim();
		return detail.isBlank() ? "" : ". Tool output: " + detail; //$NON-NLS-1$ //$NON-NLS-2$
	}
}