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.BufferedReader;
import java.io.IOException;
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.concurrent.TimeUnit;

import org.eclipse.core.resources.IFile;
import org.eclipse.core.runtime.ILog;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.Status;

/**
 * Runs Stylelint to validate CSS files.
 */
public class StylelintRunner {

	private static final ILog LOG = Platform.getLog(StylelintRunner.class);

	/**
	 * Validate a CSS file using Stylelint.
	 * 
	 * @param file the CSS file to validate
	 * @return validation results
	 */
	public static CSSValidationResult validate(IFile file) throws Exception {
		if (!NodeExecutor.isNpxAvailable()) {
			throw new IllegalStateException("npx is not available. Please install Node.js."); //$NON-NLS-1$
		}

		Path filePath = file.getLocation().toFile().toPath();

		// Run stylelint with JSON output for easier parsing
		NodeExecutor.ExecutionResult result = NodeExecutor.executeNpx(
				"stylelint", //$NON-NLS-1$
				filePath.toString(),
				"--formatter", "json" //$NON-NLS-1$ //$NON-NLS-2$
		);

		return parseStylelintOutput(result.stdout, result.exitCode);
	}

	/**
	 * Fix CSS issues automatically using Stylelint --fix.
	 * 
	 * @param file the CSS file to fix
	 * @return the fixed content, or original content if fixing failed
	 * @throws IOException if an I/O error occurs
	 * @throws InterruptedException if the thread is interrupted
	 */
	public static String fix(IFile file) throws IOException, InterruptedException {
		if (!NodeExecutor.isNpxAvailable()) {
			throw new IllegalStateException("npx is not available. Please install Node.js."); //$NON-NLS-1$
		}

		Path filePath = file.getLocation().toFile().toPath();
		String originalContent = Files.readString(filePath, StandardCharsets.UTF_8);

		// Run stylelint with --fix via stdin/stdout
		ProcessBuilder pb = new ProcessBuilder(
				"npx", "stylelint", //$NON-NLS-1$ //$NON-NLS-2$
				"--fix", //$NON-NLS-1$
				"--stdin-filename", file.getName() //$NON-NLS-1$
		);

		Process process = pb.start();

		try (OutputStream os = process.getOutputStream()) {
			os.write(originalContent.getBytes(StandardCharsets.UTF_8));
		}

		String fixed;
		try (BufferedReader reader = new BufferedReader(
				new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
			StringBuilder sb = new StringBuilder();
			String line;
			while ((line = reader.readLine()) != null) {
				sb.append(line).append("\n"); //$NON-NLS-1$
			}
			fixed = sb.toString();
		}

		String errorOutput;
		try (BufferedReader errorReader = new BufferedReader(
				new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) {
			StringBuilder errorSb = new StringBuilder();
			String errorLine;
			while ((errorLine = errorReader.readLine()) != null) {
				errorSb.append(errorLine).append("\n"); //$NON-NLS-1$
			}
			errorOutput = errorSb.toString();
		}

		boolean finished = process.waitFor(30, TimeUnit.SECONDS);
		if (!finished) {
			process.destroyForcibly();
			LOG.log(new Status(IStatus.WARNING, "sandbox_css_cleanup", //$NON-NLS-1$
					"stylelint --fix timed out after 30 seconds")); //$NON-NLS-1$
			return originalContent;
		}

		int exitCode = process.exitValue();

		if (exitCode != 0) {
			LOG.log(new Status(IStatus.WARNING, "sandbox_css_cleanup", //$NON-NLS-1$
					"stylelint --fix failed with exit code " + exitCode + " and error output:\n" + errorOutput)); //$NON-NLS-1$ //$NON-NLS-2$
			return originalContent;
		}

		return fixed.isEmpty() ? originalContent : fixed;
	}

	private static CSSValidationResult parseStylelintOutput(String jsonOutput, int exitCode) {
		List<CSSValidationResult.Issue> issues = new ArrayList<>();

		// Simple parsing - in production use proper JSON parser
		if (exitCode == 0) {
			return new CSSValidationResult(true, issues);
		}

		// Parse JSON array of results
		// Each result has: source, warnings[], errored
		// Each warning has: line, column, rule, severity, text

		try {
			// Simplified parsing - extract line/column/message
			// In real implementation, use Gson or Jackson
			if (jsonOutput.contains("\"errored\":true")) { //$NON-NLS-1$
				issues.add(new CSSValidationResult.Issue(
						1, 1, "error", "stylelint", "CSS validation errors found. Run stylelint for details." //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
				));
			}
		} catch (Exception e) {
			LOG.log(new Status(IStatus.WARNING, "sandbox_css_cleanup", //$NON-NLS-1$
					"Failed to parse stylelint output", e)); //$NON-NLS-1$
		}

		return new CSSValidationResult(false, issues);
	}

	/**
	 * 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;
		}
	}
}