diff --git a/bundles/nl.esi.xtext.common.lang.tests/META-INF/MANIFEST.MF b/bundles/nl.esi.xtext.common.lang.tests/META-INF/MANIFEST.MF index 48847a1..3711be2 100644 --- a/bundles/nl.esi.xtext.common.lang.tests/META-INF/MANIFEST.MF +++ b/bundles/nl.esi.xtext.common.lang.tests/META-INF/MANIFEST.MF @@ -9,8 +9,10 @@ Bundle-ActivationPolicy: lazy Require-Bundle: nl.esi.xtext.common.lang, org.eclipse.xtext.testing, org.eclipse.xtext.xbase.testing, - org.eclipse.xtext.xbase.lib;bundle-version="2.36.0" + org.eclipse.xtext.xbase.lib;bundle-version="2.36.0", + org.eclipse.core.runtime Import-Package: org.junit.jupiter.api;version="[5.1.0,6.0.0)", org.junit.jupiter.api.extension;version="[5.1.0,6.0.0)" Bundle-RequiredExecutionEnvironment: JavaSE-21 -Export-Package: nl.esi.xtext.common.lang.tests;x-internal=true +Export-Package: nl.esi.xtext.common.lang.tests;x-internal=true, + nl.esi.xtext.common.lang.tests.reporting;x-internal:=true diff --git a/bundles/nl.esi.xtext.common.lang.tests/src/nl/esi/xtext/common/lang/tests/reporting/StatusReportHelperTest.xtend b/bundles/nl.esi.xtext.common.lang.tests/src/nl/esi/xtext/common/lang/tests/reporting/StatusReportHelperTest.xtend new file mode 100644 index 0000000..40f8822 --- /dev/null +++ b/bundles/nl.esi.xtext.common.lang.tests/src/nl/esi/xtext/common/lang/tests/reporting/StatusReportHelperTest.xtend @@ -0,0 +1,255 @@ +/** + * Copyright (c) 2024, 2026 TNO-ESI + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package nl.esi.xtext.common.lang.tests.reporting + +import com.google.inject.Inject +import java.util.List +import nl.esi.xtext.common.lang.base.ModelContainer +import nl.esi.xtext.common.lang.tests.BaseInjectorProvider +import nl.esi.xtext.common.lang.reporting.Location +import nl.esi.xtext.common.lang.reporting.Severity +import nl.esi.xtext.common.lang.reporting.StatusReportHelper +import nl.esi.xtext.common.lang.reporting.StatusReport +import org.eclipse.xtext.testing.InjectWith +import org.eclipse.xtext.testing.extensions.InjectionExtension +import org.eclipse.xtext.testing.util.ParseHelper +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.^extension.ExtendWith + +/** + * Test cases for StatusReportHelper utility class. + * Tests validation of EObjects and Resources using EMF Diagnostician. + */ +@ExtendWith(InjectionExtension) +@InjectWith(BaseInjectorProvider) +class StatusReportHelperTest { + @Inject + ParseHelper parseHelper + + /** + * Test validation of a valid model (no imports, just a simple import statement). + * Expected: validation should succeed with OK severity. + */ + @Test + def void testValidateValidModel() { + val result = parseHelper.parse(''' + import "dummy.base_lang" + ''') + Assertions.assertNotNull(result) + + val statusReport = StatusReportHelper.validate(result) + Assertions.assertNotNull(statusReport) + Assertions.assertEquals(Severity.OK, statusReport.getSeverityLevel(), "Validation should succeed with OK severity") + } + + /** + * Test validation of a valid model with named elements. + * Expected: validation should succeed without errors. + */ + @Test + def void testValidateModelWithNamedElements() { + val result = parseHelper.parse(''' + import "dummy.base_lang" + TestElement + AnotherElement + ''') + Assertions.assertNotNull(result) + + val statusReport = StatusReportHelper.validate(result) + Assertions.assertNotNull(statusReport) + Assertions.assertFalse(statusReport.isError(), "Validation should not report errors") + Assertions.assertTrue(statusReport.getSeverityLevel() == Severity.OK, "Validation should be OK") + } + + /** + * Test validation of a model resource (via eResource). + * Expected: validation should complete successfully. + */ + @Test + def void testValidateResource() { + val result = parseHelper.parse(''' + import "dummy.base_lang" + Same + Same + ''') + Assertions.assertNotNull(result) + val resource = result.eResource + Assertions.assertNotNull(resource) + + val statusReport = StatusReportHelper.validate(resource) + Assertions.assertNotNull(statusReport) + Assertions.assertTrue(statusReport.getChildReports().isPresent(), "There should be validation errors") + Assertions.assertTrue(statusReport.getChildReports().get().size > 0, "There should be child reports") + } + + /** + * Test GSON serialization/deserialization. + */ + @Test + def void testGson() { + val result = parseHelper.parse(''' + import "dummy.base_lang" + Same + Same + ''') + Assertions.assertNotNull(result) + val resource = result.eResource + Assertions.assertNotNull(resource) + + val statusReport = StatusReportHelper.validate(resource) + + // serialize with gson + val json = StatusReportHelper.toJson(statusReport) + val statusReport2 = StatusReportHelper.fromJson(json) + Assertions.assertEquals(statusReport.getSeverityLevel(), statusReport2.getSeverityLevel()) + } + + /** + * Test that Severity enum contains proper severity constants. + * Expected: all severity constants should be accessible via Severity enum. + */ + @Test + def void testStatusReportSeverityConstants() { + Assertions.assertEquals(0x0, Severity.OK.value) + Assertions.assertEquals(0x1, Severity.INFO.value) + Assertions.assertEquals(0x2, Severity.WARNING.value) + Assertions.assertEquals(0x4, Severity.ERROR.value) + Assertions.assertEquals(0x8, Severity.CANCEL.value) + } + + /** + * Test StatusReport.isError() method. + * Expected: should return true for ERROR and CANCEL, false for others. + */ + @Test + def void testStatusReportIsError() { + val okMessage = createStatusReport(Severity.OK, "Test", null) + Assertions.assertFalse(okMessage.isError(), "OK should not be an error") + + val errorMessage = createStatusReport(Severity.ERROR, "Test", null) + Assertions.assertTrue(errorMessage.isError(), "ERROR should be an error") + + val cancelMessage = createStatusReport(Severity.CANCEL, "Test", null) + Assertions.assertTrue(cancelMessage.isError(), "CANCEL should be an error") + } + + /** + * Test that StatusReport fields are accessible and correct. + */ + @Test + def void testStatusReportFields() { + var location = new Location(1, 2, 3, 4, "5") + var statusReport = new StatusReport( + null, + Severity.INFO, + "Test message", + "test.source", + 42, + "Some details about the validation", + location, + null, + null + ) + + Assertions.assertEquals(Severity.INFO, statusReport.getSeverityLevel()) + Assertions.assertTrue(statusReport.getLocation().isPresent()) + var loc = statusReport.getLocation().get() + Assertions.assertEquals(1, loc.startLine().intValue()) + Assertions.assertEquals(2, loc.endLine().intValue()) + Assertions.assertEquals(3, loc.offset().intValue()) + Assertions.assertEquals(4, loc.length().intValue()) + Assertions.assertEquals("5", loc.text()) + Assertions.assertTrue(statusReport.getDetails().isPresent()) + Assertions.assertEquals("Some details about the validation", statusReport.getDetails().get()) + } + + /** + * Test that severity is elevated to the highest severity found in children. + * Expected: parent severity should be elevated from OK to ERROR when children contain ERROR. + */ + @Test + def void testSeverityElevationFromChildren() { + // Create child messages with different severities + val child1 = createStatusReport(Severity.WARNING, "Warning message", null) + val child2 = createStatusReport(Severity.ERROR, "Error message", null) + val child3 = createStatusReport(Severity.INFO, "Info message", null) + + // Create parent with OK severity but ERROR children + val parent = new StatusReport(null, Severity.OK, "test", null, null, null, null, #[child1, child2, child3], null) + + // Verify severity was elevated to ERROR (the highest in children) + Assertions.assertEquals(Severity.ERROR, parent.getSeverityLevel(), "Parent severity should be elevated to ERROR") + Assertions.assertTrue(parent.isError(), "Parent should be an error") + } + + /** + * Test that severity is elevated to the highest severity when it's the minimum initially. + * Expected: parent severity should be elevated from INFO to CANCEL when children contain CANCEL. + */ + @Test + def void testSeverityElevationToCancel() { + // Create child with CANCEL severity + val child = createStatusReport(Severity.CANCEL, "Cancelled", null) + + // Create parent with INFO severity + val parent = new StatusReport(null, Severity.INFO, "test", null, null, null, null, #[child], null) + + // Verify severity was elevated to CANCEL (the highest possible) + Assertions.assertEquals(Severity.CANCEL, parent.getSeverityLevel(), "Parent severity should be elevated to CANCEL") + Assertions.assertTrue(parent.isError(), "Parent should be an error (CANCEL is error)") + } + + /** + * Test that severity remains unchanged when parent has higher severity than children. + * Expected: parent severity should remain ERROR when children are only WARNING. + */ + @Test + def void testSeverityRemainsWhenHigherThanChildren() { + // Create children with WARNING severity + val child1 = createStatusReport(Severity.WARNING, "Warning 1", null) + val child2 = createStatusReport(Severity.WARNING, "Warning 2", null) + + // Create parent with ERROR severity (higher than children) + val parent = new StatusReport(null, Severity.ERROR, "test", null, null, null, null, #[child1, child2], null) + + // Verify severity remains ERROR + Assertions.assertEquals(Severity.ERROR, parent.getSeverityLevel(), "Parent severity should remain ERROR") + } + + /** + * Test that severity is not modified when children is null or empty. + * Expected: severity should remain unchanged. + */ + @Test + def void testSeverityWithNullOrEmptyChildren() { + // Test with null children + val parentWithNull = new StatusReport(null, Severity.INFO, "test", null, null, null, null, null, null) + Assertions.assertEquals(Severity.INFO, parentWithNull.getSeverityLevel(), + "Severity should remain INFO with null children") + + // Test with empty children + val parentWithEmpty = new StatusReport(null, Severity.WARNING, "test", null, null, null, null, #[], null) + Assertions.assertEquals(Severity.WARNING, parentWithEmpty.getSeverityLevel(), + "Severity should remain WARNING with empty children") + } + + /** + * Factory method to create a StatusReport with simple parameters. + */ + private def static StatusReport createStatusReport( + Severity severity, + String message, + List children + ) { + return new StatusReport(null, severity, message, null, null, null, null, children, null) + } + +} \ No newline at end of file diff --git a/bundles/nl.esi.xtext.common.lang/META-INF/MANIFEST.MF b/bundles/nl.esi.xtext.common.lang/META-INF/MANIFEST.MF index 6d2c10a..bf691c1 100644 --- a/bundles/nl.esi.xtext.common.lang/META-INF/MANIFEST.MF +++ b/bundles/nl.esi.xtext.common.lang/META-INF/MANIFEST.MF @@ -14,7 +14,9 @@ Require-Bundle: org.eclipse.xtext, org.eclipse.xtend.lib;bundle-version="2.36.0", org.eclipse.emf.common, org.antlr.runtime;bundle-version="[3.2.0,3.2.1)", - org.eclipse.core.resources + org.eclipse.core.resources, + org.eclipse.core.runtime;visibility:=reexport, + com.google.gson;bundle-version="[2.8.0,3.0.0)" Bundle-RequiredExecutionEnvironment: JavaSE-21 Export-Package: nl.esi.xtext.common.lang, nl.esi.xtext.common.lang.base, @@ -25,6 +27,7 @@ Export-Package: nl.esi.xtext.common.lang, nl.esi.xtext.common.lang.generator, nl.esi.xtext.common.lang.parser.antlr, nl.esi.xtext.common.lang.parser.antlr.internal, + nl.esi.xtext.common.lang.reporting, nl.esi.xtext.common.lang.scoping, nl.esi.xtext.common.lang.serializer, nl.esi.xtext.common.lang.services, diff --git a/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/IStatusReporting.java b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/IStatusReporting.java new file mode 100644 index 0000000..f257e21 --- /dev/null +++ b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/IStatusReporting.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024, 2026 TNO-ESI + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package nl.esi.xtext.common.lang.reporting; + +import org.eclipse.core.runtime.IStatus; + +/** + * An interface for reporting status messages, allowing implementations to + * handle the reporting of validation messages. + */ +public interface IStatusReporting { + /** + * Reports a status message. + * + * @param report the status report to be reported + */ + void addReport(IStatus report); + /** + * Reports an exception message. + * + * @param report the status report to be reported + */ + void addReport(Exception report); + +} \ No newline at end of file diff --git a/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/Location.java b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/Location.java new file mode 100644 index 0000000..622dc2e --- /dev/null +++ b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/Location.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024, 2026 TNO-ESI + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package nl.esi.xtext.common.lang.reporting; + +/** + * A record representing a location in source code or text. + * Captures details such as line numbers, character offset, length, and text content. + */ +public record Location( + Integer startLine, + Integer endLine, + Integer offset, + Integer length, + String text +) { + // Record with no additional implementation needed +} diff --git a/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/Severity.java b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/Severity.java new file mode 100644 index 0000000..f6f9967 --- /dev/null +++ b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/Severity.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024, 2026 TNO-ESI + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package nl.esi.xtext.common.lang.reporting; + +/** + * Enum representing the severity level of a validation message. + */ +public enum Severity { + /** + * Severity indicating everything is okay. + */ + OK(0x0), + + /** + * Severity indicating there is an informational message. + */ + INFO(0x1), + + /** + * Severity indicating there is a warning message. + */ + WARNING(0x2), + + /** + * Severity indicating there is an error message. + */ + ERROR(0x4), + + /** + * Severity indicating that the diagnosis was canceled. + */ + CANCEL(0x8); + + /** + * The numeric value representing this severity level. + */ + public final int value; + + /** + * Constructs a Severity with the given numeric value. + * + * @param value the numeric severity value + */ + private Severity(int value) { + this.value = value; + } + + /** + * Converts a numeric severity value to the corresponding Severity enum constant. + * + * @param value the numeric severity value + * @return the corresponding Severity enum constant, defaults to OK if not found + */ + public static Severity fromValue(int value) { + for (Severity severity : Severity.values()) { + if (severity.value == value) { + return severity; + } + } + return OK; + } + + /** + * Checks if this severity indicates an error condition. + * + * @return true if this is ERROR or CANCEL, false otherwise + */ + public boolean isError() { + return (this.value & (ERROR.value | CANCEL.value)) != 0; + } + +} diff --git a/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/StatusReport.java b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/StatusReport.java new file mode 100644 index 0000000..b070a29 --- /dev/null +++ b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/StatusReport.java @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2024, 2026 TNO-ESI + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package nl.esi.xtext.common.lang.reporting; + +import java.util.List; +import java.util.Optional; + +import org.eclipse.core.runtime.IStatus; + +/** + * A class for status reporting that represents the outcome of some processing + * activity, implementing {@link IStatus} and serializable with Gson. + */ +public final class StatusReport implements IStatus { + + private String plugin; + private final int code; + private final String message; + private final String source; + private final Severity severity; + private final String details; + private final Location location; + private final List children; + private transient final Exception exception; // covered by details, not serialized + + /** + * Creates a new StatusReport with all fields including exception. + * + * The severity field will be automatically elevated to the highest severity + * found in the children list, ensuring that the parent always reflects the most + * severe condition among its children. + * + * @param plugin the plugin identifier for this status + * @param severity the initial severity level (will be elevated if children + * have higher severity) + * @param message the message describing the situation + * @param code the source-specific identity code + * @param details the details string (representing low-level exception + * information) + * @param locations the list of location information (source, line numbers, + * offsets, text). Explicit multiplicity because one status can + * visible of multiple sources + * @param children the list of child validation messages (severity will be + * elevated based on these) + * @param exception the exception associated with this status (transient, not + * serialized) + */ + public StatusReport(String plugin, Severity severity, String message, String source, Integer code, String details, + Location location, List children, Exception exception) { + this.plugin = plugin; + this.code = code != null ? code : 0; + this.message = message; + this.exception = exception; + this.source = source; + + // If details is null but exception is not null, generate stack trace string + var effectiveDetails = details == null && exception != null ? getStackTraceAsString(exception) : details; + + // Calculate the highest severity from children using streams + var effectiveSeverity = children != null + ? children.stream().filter(child -> child != null).map(child -> child.severity) + .filter(s -> s.value > severity.value).max((s1, s2) -> Integer.compare(s1.value, s2.value)) + .orElse(severity) + : severity; + + this.severity = effectiveSeverity; + this.details = effectiveDetails; + this.location = location; + this.children = (children != null && !children.isEmpty()) ? children : null; + } + + // IStatus implementation methods + + @Override + public IStatus[] getChildren() { + return children != null ? children.toArray(new IStatus[0]) : new IStatus[0]; + } + + @Override + public int getCode() { + return code; + } + + @Override + public Throwable getException() { + return exception; + } + + @Override + public String getMessage() { + return message; + } + + public String getSource() { + return source; + } + + @Override + public String getPlugin() { + return plugin; + } + + public void setPluginId(String pluginId) { + this.plugin = pluginId; + } + + @Override + public int getSeverity() { + return mapSeverityToEclipse(severity); + } + + @Override + public boolean isMultiStatus() { + return children != null && !children.isEmpty(); + } + + @Override + public boolean isOK() { + return severity == Severity.OK; + } + + @Override + public boolean matches(int severityMask) { + return (getSeverity() & severityMask) != 0; + } + + // Custom getter methods + + /** + * Gets the severity level of this report. + * + * @return the severity level + */ + public Severity getSeverityLevel() { + return severity; + } + + /** + * Gets the list of location information for this status report. + * + * A single status message can refer to multiple sources, which is why this + * returns a list of locations. Each location contains source information such as + * file paths, line numbers, and offsets. + * + * @return an Optional containing the list of locations, or empty if no + * locations + */ + public Optional getLocation() { + return Optional.ofNullable(location); + } + + /** + * Gets the details string (low-level exception information). + * + * @return an Optional containing the details, or empty if no details + */ + public Optional getDetails() { + return Optional.ofNullable(details); + } + + /** + * Gets the list of child status reports. + * + * @return an Optional containing the children list, or empty if no children + */ + public Optional> getChildReports() { + return Optional.ofNullable(children); + } + + /** + * Gets the plugin identifier for this status. + * + * @return an Optional containing the plugin ID, or empty if not set + */ + public Optional getPluginId() { + return Optional.ofNullable(plugin); + } + + /** + * Gets the exception associated with this status. This field is transient and + * will not be serialized. + * + * @return an Optional containing the exception, or empty if none + */ + public Optional getExceptionOptional() { + return Optional.ofNullable(exception); + } + + /** + * Returns a string representation of the severity level. + * + * @return a string describing the severity + */ + public String getSeverityString() { + return severity.name(); + } + + /** + * Checks if this report indicates an error condition. + * + * @return true if severity is ERROR or CANCEL, false otherwise + */ + public boolean isError() { + return severity.isError(); + } + + /** + * Converts an exception's stack trace to a string representation. Limits the + * stack trace to 15 elements using Java 21+ features. + * + * @param exception the exception to convert + * @return a string representation of the stack trace + */ + private static String getStackTraceAsString(Exception exception) { + if (exception == null) { + return null; + } + + var sb = new StringBuilder(); + sb.append(exception.getClass().getName()); + + // Use Optional for null-safe message appending + Optional.ofNullable(exception.getMessage()).ifPresentOrElse(msg -> sb.append(": ").append(msg), () -> { + }); + + sb.append("\n"); + + // Use array stream for stack trace processing + var stackTrace = exception.getStackTrace(); + var limit = Math.min(stackTrace.length, 15); + + for (int i = 0; i < limit; i++) { + sb.append("\tat ").append(stackTrace[i]).append("\n"); + } + + if (stackTrace.length > 15) { + sb.append("\t... ").append(stackTrace.length - 15).append(" more\n"); + } + + return sb.toString(); + } + + /** + * Maps a Severity enum value to Eclipse Status severity constants using pattern + * matching. + */ + private static int mapSeverityToEclipse(Severity severity) { + return severity == null ? IStatus.OK : switch (severity) { + case OK -> IStatus.OK; + case INFO -> IStatus.INFO; + case WARNING -> IStatus.WARNING; + case ERROR -> IStatus.ERROR; + case CANCEL -> IStatus.CANCEL; + }; + } + + @Override + public String toString() { + return "StatusReport [pluginId=" + plugin + ", code=" + code + ", message=" + message + ", severity=" + + severity + ", details=" + details + ", location=" + location + ", children=" + children + "]"; + } + +} \ No newline at end of file diff --git a/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/StatusReportCollector.java b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/StatusReportCollector.java new file mode 100644 index 0000000..ec07b74 --- /dev/null +++ b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/StatusReportCollector.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024, 2026 TNO-ESI + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package nl.esi.xtext.common.lang.reporting; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.eclipse.core.runtime.IStatus; + +import com.google.inject.Singleton; + +/** + * An general class for reporting status messages, allowing implementations to + * handle the reporting of status messages. + */ +@Singleton +public class StatusReportCollector implements IStatusReporting { + private final List reports = Collections.synchronizedList(new ArrayList<>()); + /** + * Reports a status message. + * Supports reporting of StatusReport, IStatus, and Exception objects. Other object types will be logged as unsupported. + * + * @param object the status report to be reported + */ + @Override + public void addReport(IStatus report) { + if (report == null) { + return; + } + if( report instanceof StatusReport statusReport) { + reports.add(statusReport); + } else if( report instanceof IStatus) { + reports.add(StatusReportHelper.fromIStatus(report)); + } + } + + @Override + public void addReport(Exception report) { + addReport(StatusReportHelper.fromException(report, null, null)); + } + + public List getReports() { + return Collections.unmodifiableList(reports); + } +} \ No newline at end of file diff --git a/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/StatusReportHelper.java b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/StatusReportHelper.java new file mode 100644 index 0000000..604eb52 --- /dev/null +++ b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/StatusReportHelper.java @@ -0,0 +1,361 @@ +/* + * Copyright (c) 2024, 2026 TNO-ESI + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package nl.esi.xtext.common.lang.reporting; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.eclipse.core.runtime.IStatus; +import org.eclipse.emf.common.util.BasicDiagnostic; +import org.eclipse.emf.common.util.Diagnostic; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.emf.ecore.util.Diagnostician; +import org.eclipse.emf.ecore.util.EObjectValidator; +import org.eclipse.xtext.nodemodel.ICompositeNode; +import org.eclipse.xtext.nodemodel.util.NodeModelUtils; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import nl.esi.xtext.common.lang.utilities.EcoreUtil3; + + +public class StatusReportHelper { + + /** + * Gson instance for JSON serialization/deserialization. + */ + private static final Gson GSON = new GsonBuilder() + .setPrettyPrinting() + .create(); + + /** + * validates the given EObject. + * @see {@link EcoreUtil3#validate(EObject)} + */ + public static StatusReport validate(EObject eObject) { + var diagnostician = new Diagnostician(); + var diagnostics = diagnostician.createDefaultDiagnostic(eObject); + var context = diagnostician.createDefaultContext(); + + diagnostician.validate(eObject, diagnostics, context); + return fromDiagnostic(diagnostics); + } + + /** + * validate the given EObject and add it to the reports if not OK. + */ + public static void validate(List reports, EObject object) { + var report = validate(object); + reports.add(report); + } + + /** + * validate the given resource + * @see {@link EcoreUtil3#validate(EObject)} + */ + public static StatusReport validate(Resource resource) { + var diagnostician = new Diagnostician(); + var diagnostics = new BasicDiagnostic(EObjectValidator.DIAGNOSTIC_SOURCE, 0, + "Diagnosis of " + resource.getURI(), new Object[] { resource }); + var context = diagnostician.createDefaultContext(); + + for (EObject eObject : resource.getContents()) { + diagnostician.validate(eObject, diagnostics, context); + } + + return fromDiagnostic(diagnostics); + + } + + /** + * validate the given resource and add it to the reports if not OK. + * returns true if the resource an error exists in reports after validation. + */ + public static boolean validate(List reports, Resource resource) { + var report = validate(resource); + if(report.getSeverityLevel() != Severity.OK) { + reports.add(report); + } + return reports.stream().anyMatch(r->r.getSeverityLevel() == Severity.ERROR); + } + + /** + * Serializes this ValidationMessage to a JSON string using Gson. + * + * @return a JSON string representation of this ValidationMessage + */ + public static String toJson(StatusReport validationMessage) { + return GSON.toJson(validationMessage); + } + + + + public static StatusReport fromJson(String jsonString) { + return GSON.fromJson(jsonString, StatusReport.class); + } + + /** + * Generates a ValidationMessage from a Diagnostic instance, including Children. + * + * Mapping: + * - severity -> severity + * - message -> message + * - code -> code + * - exception -> details (or null if none) + * - children -> children (recursively converted) + * - location -> locations list (extracted from diagnostic data if available) + * + * @param diagnostic the diagnostic to convert + * @return a new StatusReport based on the diagnostic + */ + public static StatusReport fromDiagnostic(Diagnostic diagnostic) { + if (diagnostic == null) { + return null; + } + + var exception = diagnostic.getException(); + + List children = null; + var diagnosticChildren = diagnostic.getChildren(); + if (diagnosticChildren != null && !diagnosticChildren.isEmpty()) { + children = diagnosticChildren.stream() + .map(StatusReportHelper::fromDiagnostic) + .toList(); + } + + var source = extractSource(diagnostic); + var node = extractLocationData(diagnostic); + + Location location = null; + if (node != null) { + location = new Location( + node != null ? node.getStartLine() : null, + node != null ? node.getEndLine() : null, + node != null ? node.getOffset() : null, + node != null ? node.getLength() : null, + node != null ? node.getText() : null + ); + } + + return new StatusReport( + "unknown", // pluginId - unknown from Diagnostic + Severity.fromValue(diagnostic.getSeverity()), + diagnostic.getMessage(), + source, + diagnostic.getCode() == 0 ? null : diagnostic.getCode(), + null, + location, + children, + (Exception) exception + ); + } + + /** + * Converts an IStatus to a StatusReport. + * If the IStatus is already an instance of StatusReport, it is returned as-is. + * Otherwise, a new StatusReport is created from the IStatus data. + * + * @param status the IStatus to convert + * @return a StatusReport based on the IStatus, or null if status is null + */ + public static StatusReport fromIStatus(IStatus status) { + if (status == null) { + return null; + } + + // If already a StatusReport, return it directly + if (status instanceof StatusReport statusReport) { + return statusReport; + } + + // Convert IStatus to StatusReport + List children = null; + var statusChildren = status.getChildren(); + if (statusChildren != null && statusChildren.length > 0) { + children = new ArrayList<>(); + for (var child : statusChildren) { + var childReport = fromIStatus(child); + if (childReport != null) { + children.add(childReport); + } + } + if (children.isEmpty()) { + children = null; + } + } + + return new StatusReport( + status.getPlugin(), // pluginId + Severity.fromValue(status.getSeverity()), + status.getMessage(), + status.getPlugin(), + status.getCode() == 0 ? null : status.getCode(), + null, // details - not available from IStatus + null, // locations - not available from IStatus + children, + (Exception) status.getException() + ); + } + + /** + * Converts an exception to a StatusReport, including cause chain as children. + * Detects cycles in the cause chain to prevent infinite loops. + * + * @param exception the exception to convert + * @param linkedHashSet + * @return a new StatusReport with the exception details and cause chain + */ + public static StatusReport fromException(Throwable exception, String userMessage, List children) { + return fromException(exception, userMessage, children, new LinkedHashSet<>()); + } + + public static StatusReport errorReport(String errorMessage, List children) { + return fromMessage(Severity.ERROR, errorMessage, children); + } + + public static StatusReport warningReport( String errorMessage, List children) { + return fromMessage(Severity.WARNING, errorMessage, children); + } + + public static StatusReport infoReport(String errorMessage, List children) { + return fromMessage(Severity.INFO, errorMessage, children); + } + + public static StatusReport okReport(String errorMessage, List children) { + return fromMessage(Severity.OK, errorMessage, children); + } + + + + private static StatusReport fromMessage(Severity severity, String errorMessage, List children) { + return new StatusReport( + null, // pluginId - unknown + severity, + errorMessage, + null, + null, // code - default + null, // details - none + null, // locations - unknown + children, + null // exception - none + ); + } + + /** + * Extracts location information from a Diagnostic instance. + * The location is derived from the diagnostic's data array, typically containing + * EObject or Resource information. + * + * @param diagnostic the diagnostic to extract location from + * @return a location string if available, null otherwise + */ + private static String extractSource(Diagnostic diagnostic) { + if (diagnostic == null) { + return null; + } + + List data = diagnostic.getData(); + if (data != null && !data.isEmpty()) { + Object firstData = data.get(0); + if (firstData instanceof EObject eObject) { + Resource resource = eObject.eResource(); + if (resource != null) { + return resource.getURI().path(); + } + } else if (firstData instanceof Resource resource) { + return resource.getURI().path(); + } + } + + return null; + } + + /** + * Gets detailed location information including line, column, offset + * + * @param diagnostic The diagnostic to extract location from + * @return Object array [startLine, endLine, offset, length, text] with default + * values if not available + */ + private static ICompositeNode extractLocationData(Diagnostic diagnostic) { + if (diagnostic == null || diagnostic.getData() == null || diagnostic.getData().isEmpty()) { + return null; + } + + Object firstData = diagnostic.getData().get(0); + + if (firstData instanceof EObject eObject) { + ICompositeNode node = NodeModelUtils.getNode(eObject); + + if (node != null) { + return node; + } + } + + return null; + } + + /** + * Internal method to convert exception with cycle detection. + * + * @param exception the exception to convert + * @param seenExceptions set of already processed exceptions to detect cycles + * @return a new StatusReport with the exception details and cause chain + */ + private static StatusReport fromException(Throwable exception, String userMessage, List children, Set seenExceptions) { + if (exception == null) { + return null; + } + + // Check for cycle + if (seenExceptions.contains(exception)) { + return null; + } + + seenExceptions.add(exception); + + // Process cause as child (if exists) + var allChildren = new ArrayList(); + if (children != null) { + allChildren.addAll(children); + } + var cause = exception.getCause(); + if (cause != null) { + var childReport = fromException(cause, null, null, seenExceptions); + if (childReport != null) { + allChildren.add(childReport); + } + } + + // Create StatusReport with defaults for unknown fields + // Details will be filled automatically from exception if not provided + var message = new StringBuffer(); + if (userMessage != null && !userMessage.isEmpty()) { + message.append(userMessage).append("\n"); + } + message.append(exception.getLocalizedMessage() != null ? exception.getLocalizedMessage() : exception.getClass().getSimpleName()); + return new StatusReport( + null, // pluginId - unknown + Severity.ERROR, + message.toString(), + null, // source - unknown + null, // code - default + null, // details - will be filled from exception automatically + null, // locations - unknown + allChildren, + (Exception) exception // pass the exception to StatusReport + ); + } + +} diff --git a/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/StatusReportingEclipseRuntime.java b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/StatusReportingEclipseRuntime.java new file mode 100644 index 0000000..f42578e --- /dev/null +++ b/bundles/nl.esi.xtext.common.lang/src/nl/esi/xtext/common/lang/reporting/StatusReportingEclipseRuntime.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024, 2026 TNO-ESI + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package nl.esi.xtext.common.lang.reporting; + +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Plugin; +import org.eclipse.emf.common.util.Logger; + +import com.google.inject.Inject; + +/** + * An interface for reporting status messages, allowing implementations to + * handle the reporting of validation messages. + */ +public class StatusReportingEclipseRuntime implements IStatusReporting { + + private final Logger logger; + private final String pluginId; + + private boolean skipOkStatus = true; + + @Inject + public StatusReportingEclipseRuntime(Plugin plugin) { + if( plugin instanceof Logger logger) { + this.logger = logger; + this.pluginId = plugin.getBundle().getSymbolicName(); + } + else { + throw new IllegalArgumentException("Plugin must implement Logger interface"); + } + } + /** + * Reports a status message. + * + * @param report the status report to be reported + */ + @Override + public void addReport(IStatus report) { + if (skipOkStatus && report.getSeverity() == IStatus.OK) { + return; // Skip reporting OK status messages + } + if(report instanceof StatusReport statusReport) { + // If the report is a StatusReport, set the plugin ID + statusReport.setPluginId(pluginId); + } + // Using the standard eclipse logger to log the status + this.logger.log(report); + } + + /** + * Reports an exception as a status message. + * + * @param exception the status report to be reported + */ + @Override + public void addReport(Exception exception) { + this.logger.log(exception); + } + +} \ No newline at end of file diff --git a/bundles/nl.esi.xtext.types/src/nl/esi/xtext/types/generator/XPlusMain.xtend b/bundles/nl.esi.xtext.types/src/nl/esi/xtext/types/generator/XPlusMain.xtend index c0178d2..bcfc31b 100644 --- a/bundles/nl.esi.xtext.types/src/nl/esi/xtext/types/generator/XPlusMain.xtend +++ b/bundles/nl.esi.xtext.types/src/nl/esi/xtext/types/generator/XPlusMain.xtend @@ -19,19 +19,21 @@ import java.nio.file.Path import java.nio.file.Paths import java.text.MessageFormat import java.util.Comparator +import java.util.List +import nl.esi.xtext.common.lang.reporting.IStatusReporting +import nl.esi.xtext.common.lang.reporting.Severity +import nl.esi.xtext.common.lang.reporting.StatusReport +import nl.esi.xtext.common.lang.reporting.StatusReportCollector +import nl.esi.xtext.common.lang.reporting.StatusReportHelper import org.apache.commons.cli.CommandLine import org.apache.commons.cli.DefaultParser import org.apache.commons.cli.HelpFormatter import org.apache.commons.cli.Options import org.eclipse.emf.common.util.URI import org.eclipse.emf.ecore.resource.ResourceSet -import org.eclipse.xtext.diagnostics.Severity import org.eclipse.xtext.generator.GeneratorDelegate import org.eclipse.xtext.generator.JavaIoFileSystemAccess -import org.eclipse.xtext.util.CancelIndicator import org.eclipse.xtext.util.StringInputStream -import org.eclipse.xtext.validation.CheckMode -import org.eclipse.xtext.validation.IResourceValidator class XPlusMain { /* @@ -135,11 +137,11 @@ class XPlusMain { @Inject Provider resourceSetProvider - @Inject IResourceValidator validator - @Inject GeneratorDelegate generator @Inject JavaIoFileSystemAccess fileAccess + + @Inject IStatusReporting statusReporting String[] args String description @@ -157,9 +159,7 @@ class XPlusMain { val options = createOptions if (args.empty) { - System::err.println(ERR_LOCATION_MISSING) - showInfo(description, options) - System.exit(1); + abort(ERR_LOCATION_MISSING, options) return } @@ -198,9 +198,7 @@ class XPlusMain { cleanOutput(cmdLine, outputdir) if (tspecPath === null) { - System::err.println(ERR_LOCATION_MISSING) - showInfo(description, options) - System.exit(1) + abort(ERR_LOCATION_MISSING, options) return } @@ -223,7 +221,7 @@ class XPlusMain { generator.generate(resource, fileAccess, cliContext) } - + def specDiffOPT(CommandLine cmdLine, Options options) { val oriFeaturePath = getLocation(cmdLine, OPT_ORIGINALFEATURES) val updFeaturePath = getLocation(cmdLine, OPT_UPDATEDFEATURES) @@ -239,19 +237,15 @@ class XPlusMain { if (oriFeaturePath === null || updFeaturePath === null ){ - System::err.println(ERR_LOCATION_MISSING) - showInfo(description, options) - System.exit(1) + abort(ERR_LOCATION_MISSING, options) return } if (!Files.exists(oriFeaturePath, LinkOption.NOFOLLOW_LINKS)) { - System.out.println(ERR_LOCATION_WRONG + oriFeaturePath.toString) - System.exit(1) + abort(ERR_LOCATION_WRONG + oriFeaturePath.toString) return } if (!Files.exists(updFeaturePath, LinkOption.NOFOLLOW_LINKS)) { - System.out.println(ERR_LOCATION_WRONG + updFeaturePath.toString) - System.exit(1) + abort(ERR_LOCATION_WRONG + updFeaturePath.toString) return } @@ -267,20 +261,16 @@ class XPlusMain { def stepsParserOPT(CommandLine cmdLine, Options options) { val stepPath = getLocation(cmdLine, OPT_STEPSFILES) if (stepPath === null) { - System::err.println(ERR_LOCATION_MISSING) - showInfo(description, options) - System.exit(1) + abort(ERR_LOCATION_MISSING, options) return } if (!Files.exists(stepPath, LinkOption.NOFOLLOW_LINKS)) { - System.out.println(ERR_LOCATION_WRONG + stepPath.toString) - System.exit(1) + abort(ERR_LOCATION_WRONG + stepPath.toString) return } val context = cmdLine.getOptionValue(OPT_CONTEXT) if (context === null) { - System::err.println(ERR_CONTEXT_MISSING) - System.exit(1) + abort(ERR_CONTEXT_MISSING) return } val outputdir = Paths.get(cmdLine.getOptionValue(OPT_TESTCONFIGOUTPUT)) @@ -303,14 +293,11 @@ class XPlusMain { def monitoringOPT(CommandLine cmdLine, Options options) { val locationPath = getLocation(cmdLine, OPT_LOCATION) if (locationPath === null) { - System::err.println(ERR_LOCATION_MISSING) - showInfo(description, options) - System.exit(1) + abort(ERR_LOCATION_MISSING, options) return } if (!Files.exists(locationPath, LinkOption.NOFOLLOW_LINKS)) { - System.out.println(ERR_LOCATION_WRONG + locationPath.toString) - System.exit(1) + abort(ERR_LOCATION_WRONG + locationPath.toString) return } @@ -318,13 +305,16 @@ class XPlusMain { val outputdir = getOutputdir(cmdLine, locationPath, OPT_OUTPUT) System.out.println(INFO_OUTPUT + outputdir) cleanOutput(cmdLine, outputdir) - + if (Files.isDirectory(locationPath, LinkOption.NOFOLLOW_LINKS)) { println(MessageFormat.format(INFO_SEARCHING, language, ext, locationPath.toString)) val dir = new File(locationPath.toString) val prjFiles = dir.listFiles(createFileFilter(ext)); for (file : prjFiles) { runGeneration(file.absolutePath, outputdir.toString, validation) + if( reports.exists[getSeverityLevel == Severity.ERROR]) { + exit(StatusReportHelper.errorReport(INFO_STOP, getReports()), null) + } } if (prjFiles.empty) { System.out.println(INFO_EMPTY_LOCATION + locationPath.toString) @@ -332,10 +322,14 @@ class XPlusMain { } else { runGeneration(locationPath.toString, outputdir.toString, validation) + if( reports.exists[severity == Severity.ERROR]) { + exit(StatusReportHelper.errorReport(INFO_STOP, getReports()), null) + } } System.out.println(INFO_GENERATION_FINISHED) System.out.println("") - System.out.println(INFO_XPLUS_FINISHED) + System.out.println(INFO_XPLUS_FINISHED) + exit(StatusReportHelper.okReport(INFO_GENERATION_FINISHED, getReports()), null) } def Options createOptions() { @@ -493,46 +487,42 @@ class XPlusMain { generator.generate(resource, fileAccess, cliContext) } - def runGeneration(String string, String outputdir, Boolean validation) { + def void runGeneration(String string, String outputdir, Boolean validation) { // Load the resource System.out.println(INFO_READING + string) val set = resourceSetProvider.get val resource = set.getResource(URI.createFileURI(string), true) - + val context = new CmdLineContext // Validate the resource if (validation) { - val issues = validator.validate(resource, CheckMode.ALL, CancelIndicator.NullImpl) - if (!issues.empty) { - issues.forEach[System.err.println(it)] - } - - val errors = issues.filter[it.severity == Severity.ERROR] - if (!errors.empty) { - System.out.println(INFO_STOP) - System.exit(1) - return; - } + val report = StatusReportHelper.validate(resource) + statusReporting.addReport(report) + if (report.error) { + // stop when error else continue + return + } } // Configure and start the generator fileAccess.outputPath = outputdir setXPlusGen(fileAccess, outputdir) try { - generator.generate(resource, fileAccess, new CmdLineContext) - } catch (Exception e) { - System.err.println(e.localizedMessage) - System.err.println(INFO_STOP) - System.exit(1) - return; - } + generator.generate(resource, fileAccess, context) + } catch (Exception e) { + //create a status report + val report = StatusReportHelper.fromException(e, INFO_STOP, null); + statusReporting.addReport(report) + + } + return } def cleanOutput(CommandLine cmdLine, Path outputdir) { if (cmdLine.hasOption(OPT_CLEAN)) { println("Start cleaning output directory.") - val xplusgendir = outputdir.parent.resolve(nl.esi.xtext.types.generator.XPlusFileSystemAccess.XPLUS_OUTPUT_CONF.outputDirectory).normalize + val xplusgendir = outputdir.parent.resolve(XPlusFileSystemAccess.XPLUS_OUTPUT_CONF.outputDirectory).normalize //clean src-gen if(new File(outputdir.toString).exists){ Files.walk(outputdir) @@ -550,13 +540,13 @@ class XPlusMain { } def setXPlusGen(JavaIoFileSystemAccess fileAccess, String outputdir) { - val xplusConf = nl.esi.xtext.types.generator.XPlusFileSystemAccess.XPLUS_OUTPUT_CONF + val xplusConf = XPlusFileSystemAccess.XPLUS_OUTPUT_CONF xplusConf.outputDirectory = Paths.get(outputdir).parent.toString + xplusConf.outputDirectory - fileAccess.outputConfigurations.put(nl.esi.xtext.types.generator.XPlusFileSystemAccess.XPLUS_OUTPUT_ID, xplusConf) + fileAccess.outputConfigurations.put(XPlusFileSystemAccess.XPLUS_OUTPUT_ID, xplusConf) } def getXPlusGen() { - val xplusGen = fileAccess.outputConfigurations.get(nl.esi.xtext.types.generator.XPlusFileSystemAccess.XPLUS_OUTPUT_ID) + val xplusGen = fileAccess.outputConfigurations.get(XPlusFileSystemAccess.XPLUS_OUTPUT_ID) xplusGen.outputDirectory } @@ -566,5 +556,38 @@ class XPlusMain { println("Current directory: " + dir.toPath.toString) return dir } - + + private def void abort(String message) { + abort(message, null) + } + + private def void abort(String message, Options options) { + exit(StatusReportHelper.errorReport(message,getReports()), options) + } + + private def void exit(StatusReport statusReport, Options options) { + //be backwards compatible + System.err.println(statusReport.getMessage()) + saveReport(statusReport) + if(options !== null) { + showInfo(description, options) + } + System.out.println(String.format("Exiting with status %s(%d)", statusReport.getSeverityLevel().name, statusReport.getSeverityLevel().value)) + System.err.println(String.format("Exiting with status %s(%d)", statusReport.getSeverityLevel().name, statusReport.getSeverityLevel().value)) + System.exit(statusReport.getSeverityLevel().value) + } + + private def void saveReport(StatusReport report) { + //store the report with name `StatusReport.json` in the root of file_access + val resource = "report/StatusReport.json" + val charSequence = StatusReportHelper.toJson(report) + fileAccess.generateFile(resource, charSequence); + } + + private def List getReports(){ + if( statusReporting instanceof StatusReportCollector) { + return statusReporting.reports + } + throw new IllegalArgumentException("Excepting an instance of StatusReportCollector") + } } \ No newline at end of file