From 12d3844b0b93ac7a1ddb5e7b15adc5d0a9953893 Mon Sep 17 00:00:00 2001 From: U000805 Date: Fri, 28 Aug 2026 15:58:22 +0200 Subject: [PATCH] do not store check implementation --- .../ddk/check/core/test/storage/StorageChecks | 31 ++ .../core/test/CheckResourceStorageTest.java | 263 ++++++++++++++++ .../core/test/CheckStubCompilerTest.java | 138 +++++++++ .../check/test/core/CheckCoreTestSuite.java | 4 + .../META-INF/MANIFEST.MF | 1 + .../ddk/check/generator/CheckGenerator.java | 5 + .../check/generator/CheckStubCompiler.java | 292 ++++++++++++++++++ ...kBatchLinkableResourceStorageWritable.java | 84 +++++ .../ddk/check/resource/CheckModelPruner.java | 160 ++++++++++ .../ui/CheckResourceUIServiceProvider.java | 46 +++ .../tools/ddk/check/ui/CheckUiModule.java | 10 + 11 files changed, 1034 insertions(+) create mode 100644 com.avaloq.tools.ddk.check.core.test/resource/com/avaloq/tools/ddk/check/core/test/storage/StorageChecks create mode 100644 com.avaloq.tools.ddk.check.core.test/src/com/avaloq/tools/ddk/check/core/test/CheckResourceStorageTest.java create mode 100644 com.avaloq.tools.ddk.check.core.test/src/com/avaloq/tools/ddk/check/core/test/CheckStubCompilerTest.java create mode 100644 com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/generator/CheckStubCompiler.java create mode 100644 com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/resource/CheckModelPruner.java create mode 100644 com.avaloq.tools.ddk.check.ui/src/com/avaloq/tools/ddk/check/ui/CheckResourceUIServiceProvider.java diff --git a/com.avaloq.tools.ddk.check.core.test/resource/com/avaloq/tools/ddk/check/core/test/storage/StorageChecks b/com.avaloq.tools.ddk.check.core.test/resource/com/avaloq/tools/ddk/check/core/test/storage/StorageChecks new file mode 100644 index 0000000000..9348ed607a --- /dev/null +++ b/com.avaloq.tools.ddk.check.core.test/resource/com/avaloq/tools/ddk/check/core/test/storage/StorageChecks @@ -0,0 +1,31 @@ +package storage + +import com.avaloq.tools.ddk.check.check.Check +import com.avaloq.tools.ddk.check.check.Documented + +/** + * Check catalog used to verify that no implementation details are persisted. + */ +catalog StorageChecks +for grammar com.avaloq.tools.ddk.check.Check { + + java.lang.String hiddenCatalogMember = "hidden-member-literal"; + + def hiddenImplementation for Check hiddenImplementationVariable { + "hidden-implementation-literal".length() + } + + category PublicCategoryId "Public category label" { + + @SeverityRange(warning .. error) + live error PublicCheckId "Public check label" (int publicParameter = 23 "Public parameter label") + message "Public message" { + for Documented publicContextVariable { + guard publicParameter > "hidden-guard-literal".length() + issue on publicContextVariable + } + } + } + + external warning ExternalCheckId "External check label" +} diff --git a/com.avaloq.tools.ddk.check.core.test/src/com/avaloq/tools/ddk/check/core/test/CheckResourceStorageTest.java b/com.avaloq.tools.ddk.check.core.test/src/com/avaloq/tools/ddk/check/core/test/CheckResourceStorageTest.java new file mode 100644 index 0000000000..5330d8de7c --- /dev/null +++ b/com.avaloq.tools.ddk.check.core.test/src/com/avaloq/tools/ddk/check/core/test/CheckResourceStorageTest.java @@ -0,0 +1,263 @@ +/******************************************************************************* + * Copyright (c) 2016 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.check.core.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.xtext.EcoreUtil2; +import org.eclipse.xtext.common.types.JvmType; +import org.eclipse.xtext.resource.XtextResourceSet; +import org.eclipse.xtext.resource.persistence.StorageAwareResource; +import org.eclipse.xtext.testing.InjectWith; +import org.eclipse.xtext.testing.extensions.InjectionExtension; +import org.eclipse.xtext.util.CancelIndicator; +import org.eclipse.xtext.xbase.XNumberLiteral; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import com.avaloq.tools.ddk.check.CheckInjectorProvider; +import com.avaloq.tools.ddk.check.check.Category; +import com.avaloq.tools.ddk.check.check.Check; +import com.avaloq.tools.ddk.check.check.CheckCatalog; +import com.avaloq.tools.ddk.check.check.Context; +import com.avaloq.tools.ddk.check.check.FormalParameter; +import com.avaloq.tools.ddk.check.check.SeverityKind; +import com.avaloq.tools.ddk.check.resource.CheckBatchLinkableResourceStorageLoadable; +import com.avaloq.tools.ddk.check.resource.CheckBatchLinkableResourceStorageWritable; +import com.google.inject.Inject; +import com.google.inject.Injector; + + +/** + * Tests that the binary model of a check catalog only contains the publicly visible API of the catalog and none of the implementation details of its checks. + */ +@InjectWith(CheckInjectorProvider.class) +@ExtendWith(InjectionExtension.class) +@SuppressWarnings("nls") +public class CheckResourceStorageTest { + + private static final String MODEL_PATH = "storage/StorageChecks"; + private static final String RESOURCE_URI = "StorageChecks.check"; + + /** Strings that only occur in the implementation of the catalog and must therefore not be persisted. */ + private static final String[] IMPLEMENTATION_MARKERS = { // + "hidden-member-literal", "hidden-implementation-literal", "hidden-guard-literal", // + "hiddenCatalogMember", "hiddenImplementation", "hiddenImplementationVariable"}; + + @Inject + private Injector injector; + + /** + * Loads the test catalog from its source, including its inferred JVM model. + * + * @return the resource containing the test catalog, never {@code null} + * @throws IOException + * if the test model cannot be read + */ + private StorageAwareResource loadSource() throws IOException { + XtextResourceSet resourceSet = injector.getInstance(XtextResourceSet.class); + StorageAwareResource resource = (StorageAwareResource) resourceSet.createResource(URI.createURI(RESOURCE_URI)); + try (InputStream in = CheckResourceStorageTest.class.getResourceAsStream(MODEL_PATH)) { + assertNotNull(in, "Test model must be available"); + resource.load(in, null); + } + EcoreUtil2.resolveLazyCrossReferences(resource, CancelIndicator.NullImpl); + assertTrue(resource.getContents().stream().anyMatch(JvmType.class::isInstance), "Source resource should contain an inferred JVM model"); + return resource; + } + + /** + * Persists the given resource. + * + * @param resource + * the resource to persist, must not be {@code null} + * @return the binary model, never {@code null} + * @throws IOException + * if the resource cannot be persisted + */ + private byte[] save(final StorageAwareResource resource) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new CheckBatchLinkableResourceStorageWritable(out, false).writeResource(resource); + return out.toByteArray(); + } + + /** + * Loads a catalog from a binary model. + * + * @param storage + * the binary model, must not be {@code null} + * @return the resource containing the loaded catalog, never {@code null} + * @throws IOException + * if the binary model cannot be read + */ + private StorageAwareResource load(final byte[] storage) throws IOException { + XtextResourceSet resourceSet = injector.getInstance(XtextResourceSet.class); + StorageAwareResource resource = (StorageAwareResource) resourceSet.createResource(URI.createURI(RESOURCE_URI)); + resource.loadFromStorage(new CheckBatchLinkableResourceStorageLoadable(new ByteArrayInputStream(storage), false)); + return resource; + } + + /** + * Persists and reloads the test catalog. + * + * @return the reloaded catalog, never {@code null} + * @throws IOException + * if the catalog cannot be persisted or reloaded + */ + private CheckCatalog saveAndLoad() throws IOException { + StorageAwareResource loaded = load(save(loadSource())); + assertEquals(1, loaded.getContents().size(), "Only the catalog itself should have been persisted"); + return (CheckCatalog) loaded.getContents().get(0); + } + + /** + * Returns the only check of the given catalog's category. + * + * @param catalog + * the catalog, must not be {@code null} + * @return the categorized check, never {@code null} + */ + private Check categorizedCheck(final CheckCatalog catalog) { + assertEquals(1, catalog.getCategories().size(), "Catalog should have one category"); + Category category = catalog.getCategories().get(0); + assertEquals(1, category.getChecks().size(), "Category should have one check"); + return category.getChecks().get(0); + } + + /** + * Tests that the data describing the catalog and its checks is persisted. + */ + @Test + void testPublicApiIsPersisted() throws IOException { + CheckCatalog catalog = saveAndLoad(); + + assertEquals("storage", catalog.getPackageName(), "Package name"); + assertEquals("StorageChecks", catalog.getName(), "Catalog name"); + assertNotNull(catalog.getGrammar(), "Grammar reference"); + assertEquals("Public category label", catalog.getCategories().get(0).getLabel(), "Category label"); + assertEquals(2, catalog.getAllChecks().size(), "Number of checks"); + + Check check = categorizedCheck(catalog); + assertEquals("PublicCheckId", check.getId(), "Check id"); + assertEquals("Public check label", check.getLabel(), "Check label"); + assertEquals("Public message", check.getMessage(), "Check message"); + assertEquals(SeverityKind.ERROR, check.getDefaultSeverity(), "Default severity"); + assertNotNull(check.getSeverityRange(), "Severity range"); + assertEquals(SeverityKind.WARNING, check.getSeverityRange().getMinSeverity(), "Minimum severity"); + assertNotNull(check.getKind(), "Trigger kind"); + assertFalse(check.isExternal(), "Categorized check is not external"); + assertTrue(catalog.getChecks().get(0).isExternal(), "Second check is external"); + } + + /** + * Tests that the formal parameters of a check are persisted, including their type and default value. + */ + @Test + void testFormalParametersArePersisted() throws IOException { + Check check = categorizedCheck(saveAndLoad()); + + assertEquals(1, check.getFormalParameters().size(), "Number of formal parameters"); + FormalParameter parameter = check.getFormalParameters().get(0); + assertEquals("publicParameter", parameter.getName(), "Parameter name"); + assertEquals("Public parameter label", parameter.getLabel(), "Parameter label"); + assertNotNull(parameter.getType().getType(), "Parameter type"); + assertEquals("int", parameter.getType().getType().getQualifiedName(), "Parameter type name"); + assertEquals("23", ((XNumberLiteral) parameter.getRight()).getValue(), "Parameter default value"); + } + + /** + * Tests that the context variables of a check are persisted while their constraints are not. + */ + @Test + void testContextsArePersistedWithoutConstraint() throws IOException { + Check check = categorizedCheck(saveAndLoad()); + + assertEquals(1, check.getContexts().size(), "Number of contexts"); + Context context = check.getContexts().get(0); + assertNotNull(context.getContextVariable(), "Context variable"); + assertEquals("publicContextVariable", context.getContextVariable().getName(), "Context variable name"); + assertNotNull(context.getContextVariable().getType().getType(), "Context variable type"); + assertEquals("com.avaloq.tools.ddk.check.check.Documented", context.getContextVariable().getType().getType().getQualifiedName(), "Context variable type name"); + assertNull(context.getConstraint(), "Constraint must not be persisted"); + } + + /** + * Tests that the implementation of the catalog is not persisted. + */ + @Test + void testImplementationIsNotPersisted() throws IOException { + CheckCatalog catalog = saveAndLoad(); + + assertNull(catalog.getImports(), "Import section must not be persisted"); + assertTrue(catalog.getMembers().isEmpty(), "Members must not be persisted"); + assertTrue(catalog.getImplementations().isEmpty(), "Implementations must not be persisted"); + for (EObject content : catalog.eResource().getContents()) { + assertFalse(content instanceof JvmType, "The inferred JVM model must not be persisted"); + } + } + + /** + * Tests that no implementation detail leaks into the binary model, whichever entry it might be written to. + */ + @Test + void testNoImplementationDetailsInBinaryModel() throws IOException { + byte[] storage = save(loadSource()); + + try (ZipInputStream zipIn = new ZipInputStream(new ByteArrayInputStream(storage))) { + for (ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn.getNextEntry()) { + String contents = new String(zipIn.readAllBytes(), StandardCharsets.ISO_8859_1); + for (String marker : IMPLEMENTATION_MARKERS) { + assertFalse(contents.contains(marker), "Entry '" + entry.getName() + "' must not contain '" + marker + '\''); + } + } + } + } + + /** + * Tests that persisting a resource does not modify the resource itself, as it is still used by the generator afterwards. + */ + @Test + void testSourceResourceIsNotModified() throws IOException { + StorageAwareResource resource = loadSource(); + save(resource); + + CheckCatalog catalog = (CheckCatalog) resource.getContents().get(0); + assertNotNull(catalog.getImports(), "Import section"); + assertEquals(1, catalog.getMembers().size(), "Members"); + assertEquals(1, catalog.getImplementations().size(), "Implementations"); + assertNotNull(categorizedCheck(catalog).getContexts().get(0).getConstraint(), "Constraint"); + assertTrue(resource.getContents().stream().anyMatch(JvmType.class::isInstance), "Inferred JVM model"); + } + + /** + * Tests that persisting the same catalog twice yields the very same bytes, as the binary models are checked into source control. + */ + @Test + void testBinaryModelIsReproducible() throws IOException { + assertArrayEquals(save(loadSource()), save(loadSource()), "Binary models of the same catalog should be identical"); + } + +} diff --git a/com.avaloq.tools.ddk.check.core.test/src/com/avaloq/tools/ddk/check/core/test/CheckStubCompilerTest.java b/com.avaloq.tools.ddk.check.core.test/src/com/avaloq/tools/ddk/check/core/test/CheckStubCompilerTest.java new file mode 100644 index 0000000000..4c9564e188 --- /dev/null +++ b/com.avaloq.tools.ddk.check.core.test/src/com/avaloq/tools/ddk/check/core/test/CheckStubCompilerTest.java @@ -0,0 +1,138 @@ +/******************************************************************************* + * Copyright (c) 2016 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.check.core.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.eclipse.xtext.resource.XtextResource; +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.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import com.avaloq.tools.ddk.check.CheckInjectorProvider; +import com.avaloq.tools.ddk.check.check.Category; +import com.avaloq.tools.ddk.check.check.Check; +import com.avaloq.tools.ddk.check.check.CheckCatalog; +import com.avaloq.tools.ddk.check.check.Context; +import com.avaloq.tools.ddk.check.check.SeverityKind; +import com.avaloq.tools.ddk.check.generator.CheckStubCompiler; +import com.google.inject.Inject; + + +/** + * Tests that the generated catalog stub declares the public API of a catalog and none of its implementation. + */ +@InjectWith(CheckInjectorProvider.class) +@ExtendWith(InjectionExtension.class) +@SuppressWarnings("nls") +public class CheckStubCompilerTest { + + private static final String MODEL_PATH = "storage/StorageChecks"; + + /** Strings that only occur in the implementation of the catalog and must therefore not be part of the stub. */ + private static final String[] IMPLEMENTATION_MARKERS = { // + "hidden-member-literal", "hidden-implementation-literal", "hidden-guard-literal", // + "hiddenCatalogMember", "hiddenImplementation", "hiddenImplementationVariable", // + "import", "def ", "guard", "issue"}; + + @Inject + private ParseHelper parser; + + @Inject + private CheckStubCompiler stubCompiler; + + /** + * Compiles the stub of the test catalog. + * + * @return the stub source, never {@code null} + * @throws Exception + * if the test model cannot be read or parsed + */ + private String compileStub() throws Exception { + try (InputStream in = CheckStubCompilerTest.class.getResourceAsStream(MODEL_PATH)) { + assertNotNull(in, "Test model must be available"); + CheckCatalog catalog = parser.parse(new String(in.readAllBytes(), StandardCharsets.UTF_8)); + assertNotNull(catalog, "Test model must be parsable"); + return stubCompiler.compile(catalog).toString(); + } + } + + /** + * Tests that the stub declares the catalog and all data needed to reference its checks. + */ + @Test + void testStubDeclaresPublicApi() throws Exception { + String stub = compileStub(); + + assertTrue(stub.contains("package storage"), stub); + assertTrue(stub.contains("catalog StorageChecks for grammar com.avaloq.tools.ddk.check.Check {"), stub); + assertTrue(stub.contains("category PublicCategoryId \"Public category label\" {"), stub); + assertTrue(stub.contains("@SeverityRange(warning .. error)"), stub); + assertTrue(stub.contains( + "live error PublicCheckId \"Public check label\" (int publicParameter = 23 \"Public parameter label\") message \"Public message\" {"), stub); + assertTrue(stub.contains("for com.avaloq.tools.ddk.check.check.Documented publicContextVariable {}"), stub); + assertTrue(stub.contains("external warning ExternalCheckId \"External check label\""), stub); + } + + /** + * Tests that the stub contains none of the implementation of the catalog. + */ + @Test + void testStubContainsNoImplementation() throws Exception { + String stub = compileStub(); + + for (String marker : IMPLEMENTATION_MARKERS) { + assertFalse(stub.contains(marker), "Stub must not contain '" + marker + "':\n" + stub); + } + } + + /** + * Tests that the stub can be parsed again and yields the same public API. + */ + @Test + void testStubIsParsable() throws Exception { + CheckCatalog catalog = parser.parse(compileStub()); + + assertNotNull(catalog, "Stub must be parsable"); + assertFalse(((XtextResource) catalog.eResource()).getParseResult().hasSyntaxErrors(), "Stub must not have syntax errors"); + assertEquals("storage", catalog.getPackageName(), "Package name"); + assertEquals("StorageChecks", catalog.getName(), "Catalog name"); + assertNotNull(catalog.getGrammar(), "Grammar reference"); + assertEquals(2, catalog.getAllChecks().size(), "Number of checks"); + assertTrue(catalog.getMembers().isEmpty(), "Members"); + assertTrue(catalog.getImplementations().isEmpty(), "Implementations"); + + Category category = catalog.getCategories().get(0); + assertEquals("PublicCategoryId", category.getId(), "Category id"); + Check check = category.getChecks().get(0); + assertEquals("PublicCheckId", check.getId(), "Check id"); + assertEquals("Public check label", check.getLabel(), "Check label"); + assertEquals("Public message", check.getMessage(), "Check message"); + assertEquals(SeverityKind.ERROR, check.getDefaultSeverity(), "Default severity"); + assertEquals(SeverityKind.WARNING, check.getSeverityRange().getMinSeverity(), "Minimum severity"); + assertEquals("publicParameter", check.getFormalParameters().get(0).getName(), "Parameter name"); + assertEquals("Public parameter label", check.getFormalParameters().get(0).getLabel(), "Parameter label"); + + Context context = check.getContexts().get(0); + assertEquals("publicContextVariable", context.getContextVariable().getName(), "Context variable name"); + assertNotNull(context.getConstraint(), "Constraint is an empty block"); + assertTrue(catalog.getChecks().get(0).isExternal(), "External check"); + } + +} diff --git a/com.avaloq.tools.ddk.check.core.test/src/com/avaloq/tools/ddk/check/test/core/CheckCoreTestSuite.java b/com.avaloq.tools.ddk.check.core.test/src/com/avaloq/tools/ddk/check/test/core/CheckCoreTestSuite.java index f5c6ba4f93..31e9cb316e 100644 --- a/com.avaloq.tools.ddk.check.core.test/src/com/avaloq/tools/ddk/check/test/core/CheckCoreTestSuite.java +++ b/com.avaloq.tools.ddk.check.core.test/src/com/avaloq/tools/ddk/check/test/core/CheckCoreTestSuite.java @@ -19,6 +19,8 @@ import com.avaloq.tools.ddk.check.core.test.BugAig830; import com.avaloq.tools.ddk.check.core.test.BugDsl27; import com.avaloq.tools.ddk.check.core.test.CheckScopingTest; +import com.avaloq.tools.ddk.check.core.test.CheckResourceStorageTest; +import com.avaloq.tools.ddk.check.core.test.CheckStubCompilerTest; import com.avaloq.tools.ddk.check.core.test.IssueCodeToLabelMapGenerationTest; import com.avaloq.tools.ddk.check.core.test.IssueExpressionGenerationTest; import com.avaloq.tools.ddk.check.core.test.ProjectBasedTests; @@ -46,6 +48,8 @@ BugAig1314.class, BugDsl27.class, CheckApiAccessValidationsTest.class, + CheckResourceStorageTest.class, + CheckStubCompilerTest.class, CheckFormattingTest.class // @Format-On }) diff --git a/com.avaloq.tools.ddk.check.core/META-INF/MANIFEST.MF b/com.avaloq.tools.ddk.check.core/META-INF/MANIFEST.MF index 037d521288..aecd1ed44e 100644 --- a/com.avaloq.tools.ddk.check.core/META-INF/MANIFEST.MF +++ b/com.avaloq.tools.ddk.check.core/META-INF/MANIFEST.MF @@ -41,6 +41,7 @@ Export-Package: com.avaloq.tools.ddk.check, com.avaloq.tools.ddk.check.naming, com.avaloq.tools.ddk.check.parser.antlr, com.avaloq.tools.ddk.check.parser.antlr.internal, + com.avaloq.tools.ddk.check.resource, com.avaloq.tools.ddk.check.scoping, com.avaloq.tools.ddk.check.serializer, com.avaloq.tools.ddk.check.services, diff --git a/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/generator/CheckGenerator.java b/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/generator/CheckGenerator.java index 863c815c66..22501534fe 100644 --- a/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/generator/CheckGenerator.java +++ b/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/generator/CheckGenerator.java @@ -54,6 +54,9 @@ public class CheckGenerator extends JvmModelGenerator { @Inject private CheckCompiler compiler; + @Inject + private CheckStubCompiler stubCompiler; + @Inject private ICheckGeneratorConfigProvider generatorConfigProvider; @@ -70,6 +73,8 @@ public void doGenerate(final Resource resource, final IFileSystemAccess fsa) { for (final CheckCatalog catalog : catalogs) { lfFsa.generateFile(checkGeneratorNaming.issueCodesFilePath(catalog), compileIssueCodes(catalog)); lfFsa.generateFile(checkGeneratorNaming.standaloneSetupPath(catalog), compileStandaloneSetup(catalog)); + // The stub is registered in place of the catalog source; it must therefore be generated next to the binary model of the catalog. + lfFsa.generateFile(checkGeneratorNaming.checkFilePath(catalog), stubCompiler.compile(catalog)); // change output path for service registry lfFsa.generateFile( diff --git a/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/generator/CheckStubCompiler.java b/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/generator/CheckStubCompiler.java new file mode 100644 index 0000000000..77f739286a --- /dev/null +++ b/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/generator/CheckStubCompiler.java @@ -0,0 +1,292 @@ +/******************************************************************************* + * Copyright (c) 2016 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.check.generator; + +import org.eclipse.emf.ecore.EObject; +import org.eclipse.xtext.common.types.JvmTypeReference; +import org.eclipse.xtext.nodemodel.INode; +import org.eclipse.xtext.nodemodel.util.NodeModelUtils; +import org.eclipse.xtext.util.Strings; + +import com.avaloq.tools.ddk.check.check.Category; +import com.avaloq.tools.ddk.check.check.Check; +import com.avaloq.tools.ddk.check.check.CheckCatalog; +import com.avaloq.tools.ddk.check.check.CheckPackage; +import com.avaloq.tools.ddk.check.check.Context; +import com.avaloq.tools.ddk.check.check.ContextVariable; +import com.avaloq.tools.ddk.check.check.Documented; +import com.avaloq.tools.ddk.check.check.FormalParameter; +import com.avaloq.tools.ddk.check.check.SeverityKind; +import com.avaloq.tools.ddk.check.check.SeverityRange; + + +/** + * Compiles the public API of a check catalog into a check source file, a so-called catalog stub. + *

+ * The stub declares the catalog, its categories, its checks with their severities, formal parameters and contexts, but none of the implementation of those + * checks: the constraints of the contexts are emitted as empty blocks and the members, the implementations and the import section of the catalog are omitted + * altogether. It can thus be shipped in place of the catalog source itself, allowing consumers to resolve references to the checks of the catalog without + * disclosing how those checks are implemented. + *

+ *

+ * Type references and default values are emitted in a self-contained manner, that is, with fully qualified type names, so that the stub can be parsed and + * linked without the import section of the original catalog. + *

+ */ +@SuppressWarnings("nls") +public class CheckStubCompiler { + + private static final String INDENT = " "; + + /** + * Compiles the public API of the given catalog into check source. + * + * @param catalog + * the catalog to compile, must not be {@code null} + * @return the source of the catalog stub, never {@code null} + */ + public CharSequence compile(final CheckCatalog catalog) { + StringBuilder out = new StringBuilder(1024); + out.append("/*\n"); + out.append(" * Public API of check catalog ").append(catalog.getName()).append(".\n"); + out.append(" *\n"); + out.append(" * Generated file, do not edit. The implementation of the checks is deliberately not part of this file.\n"); + out.append(" */\n"); + out.append("package ").append(catalog.getPackageName()).append('\n'); + out.append('\n'); + appendDocumentation(out, catalog, ""); + if (catalog.isFinal()) { + out.append("final "); + } + out.append("catalog ").append(catalog.getName()); + if (catalog.getGrammar() != null && catalog.getGrammar().getName() != null) { + out.append(" for grammar ").append(catalog.getGrammar().getName()); + } + out.append(" {\n"); + for (EObject content : catalog.eContents()) { + if (content instanceof Category) { + appendCategory(out, (Category) content, INDENT); + } else if (content instanceof Check) { + appendCheck(out, (Check) content, INDENT); + } + } + out.append("}\n"); + return out; + } + + /** + * Appends the given category and its checks. + * + * @param out + * the builder to append to, must not be {@code null} + * @param category + * the category to append, must not be {@code null} + * @param indent + * the indentation to use, must not be {@code null} + */ + private void appendCategory(final StringBuilder out, final Category category, final String indent) { + out.append('\n'); + appendDocumentation(out, category, indent); + out.append(indent).append("category "); + if (category.getId() != null) { + out.append(category.getId()).append(' '); + } + appendString(out, category.getLabel()); + out.append(" {\n"); + for (Check check : category.getChecks()) { + appendCheck(out, check, indent + INDENT); + } + out.append(indent).append("}\n"); + } + + /** + * Appends the given check without the constraints of its contexts. + * + * @param out + * the builder to append to, must not be {@code null} + * @param check + * the check to append, must not be {@code null} + * @param indent + * the indentation to use, must not be {@code null} + */ + private void appendCheck(final StringBuilder out, final Check check, final String indent) { + out.append('\n'); + appendDocumentation(out, check, indent); + appendSeverityRange(out, check.getSeverityRange(), indent); + out.append(indent); + if (check.isFinal()) { + out.append("final "); + } + if (check.isExternal()) { + out.append("external "); + } + if (check.eIsSet(CheckPackage.Literals.CHECK__KIND)) { + out.append(check.getKind().getLiteral()).append(' '); + } + out.append(literal(check.getDefaultSeverity())).append(' '); + if (check.getId() != null) { + out.append(check.getId()).append(' '); + } + appendString(out, check.getLabel()); + appendFormalParameters(out, check); + if (check.getGivenMessage() != null) { + out.append(" message "); + appendString(out, check.getGivenMessage()); + } + if (check.getContexts().isEmpty()) { + out.append('\n'); + } else { + out.append(" {\n"); + for (Context context : check.getContexts()) { + appendContext(out, context, indent + INDENT); + } + out.append(indent).append("}\n"); + } + } + + /** + * Appends the given severity range, if any. + * + * @param out + * the builder to append to, must not be {@code null} + * @param range + * the severity range to append, may be {@code null} + * @param indent + * the indentation to use, must not be {@code null} + */ + private void appendSeverityRange(final StringBuilder out, final SeverityRange range, final String indent) { + if (range != null) { + out.append(indent).append("@SeverityRange(").append(literal(range.getMinSeverity())).append(" .. ").append(literal(range.getMaxSeverity())).append(")\n"); + } + } + + /** + * Appends the formal parameters of the given check, if any. + * + * @param out + * the builder to append to, must not be {@code null} + * @param check + * the check whose formal parameters to append, must not be {@code null} + */ + private void appendFormalParameters(final StringBuilder out, final Check check) { + if (check.getFormalParameters().isEmpty()) { + return; + } + out.append(" ("); + boolean first = true; + for (FormalParameter parameter : check.getFormalParameters()) { + if (!first) { + out.append(", "); + } + first = false; + out.append(typeText(parameter.getType())).append(' ').append(parameter.getName()).append(" = ").append(nodeText(parameter.getRight())); + if (parameter.getLabel() != null) { + out.append(' '); + appendString(out, parameter.getLabel()); + } + } + out.append(')'); + } + + /** + * Appends the given context with an empty constraint. + * + * @param out + * the builder to append to, must not be {@code null} + * @param context + * the context to append, must not be {@code null} + * @param indent + * the indentation to use, must not be {@code null} + */ + private void appendContext(final StringBuilder out, final Context context, final String indent) { + ContextVariable variable = context.getContextVariable(); + out.append(indent).append("for ").append(typeText(variable.getType())); + if (variable.getName() != null) { + out.append(' ').append(variable.getName()); + } + out.append(" {}\n"); + } + + /** + * Appends the documentation of the given element, if any, as a multi line comment. + * + * @param out + * the builder to append to, must not be {@code null} + * @param documented + * the documented element, must not be {@code null} + * @param indent + * the indentation to use, must not be {@code null} + */ + private void appendDocumentation(final StringBuilder out, final Documented documented, final String indent) { + String description = documented.getDescription(); + if (Strings.isEmpty(description)) { + return; + } + out.append(indent).append("/**\n"); + for (String line : description.replace("*/", "* /").split("\r?\n")) { + out.append(indent).append(" *"); + String trimmed = line.trim(); + if (!trimmed.isEmpty()) { + out.append(' ').append(trimmed); + } + out.append('\n'); + } + out.append(indent).append(" */\n"); + } + + /** + * Appends the given value as a string literal. + * + * @param out + * the builder to append to, must not be {@code null} + * @param value + * the value to append, may be {@code null} + */ + private void appendString(final StringBuilder out, final String value) { + out.append('"').append(value == null ? "" : Strings.convertToJavaString(value)).append('"'); + } + + /** + * Returns the keyword of the given severity. + * + * @param severity + * the severity, must not be {@code null} + * @return the keyword of the severity, never {@code null} + */ + private String literal(final SeverityKind severity) { + return severity.getLiteral(); + } + + /** + * Returns the fully qualified text of the given type reference. + * + * @param reference + * the type reference, must not be {@code null} + * @return the text to emit for the type reference, never {@code null} + */ + private String typeText(final JvmTypeReference reference) { + String qualifiedName = reference.getQualifiedName(); + return Strings.isEmpty(qualifiedName) ? nodeText(reference) : qualifiedName.replace('$', '.'); + } + + /** + * Returns the source text of the given object. Used for expressions that are known to be self-contained literals. + * + * @param object + * the object whose source text to return, must not be {@code null} + * @return the source text of the object, or an empty string if it is not available + */ + private String nodeText(final EObject object) { + INode node = NodeModelUtils.findActualNodeFor(object); + return node == null ? "" : NodeModelUtils.getTokenText(node).trim(); + } + +} diff --git a/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/resource/CheckBatchLinkableResourceStorageWritable.java b/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/resource/CheckBatchLinkableResourceStorageWritable.java index fbcc51ba0e..5279e3f31c 100644 --- a/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/resource/CheckBatchLinkableResourceStorageWritable.java +++ b/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/resource/CheckBatchLinkableResourceStorageWritable.java @@ -13,10 +13,19 @@ import java.io.BufferedOutputStream; import java.io.IOException; +import java.io.ObjectOutputStream; import java.io.OutputStream; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; +import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.InternalEObject; +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.emf.ecore.resource.impl.BinaryResourceImpl; import org.eclipse.xtext.resource.persistence.StorageAwareResource; import org.eclipse.xtext.xbase.resource.BatchLinkableResource; import org.eclipse.xtext.xbase.resource.BatchLinkableResourceStorageWritable; @@ -100,4 +109,79 @@ protected void writeEntries(final StorageAwareResource resource, final ZipOutput } } } + + /** + * Writes a {@link CheckModelPruner pruned} copy of the resource's check catalog instead of the resource contents themselves, so that the persisted model only + * exposes the public API of the catalog and neither the implementation of its checks nor the inferred JVM model. + *

+ * The serialization itself replicates the base class implementation: cross resource references are written as portable URIs and the + * {@link #beforeSaveEObject(InternalEObject, BinaryResourceImpl.EObjectOutputStream) before} and + * {@link #handleSaveEObject(InternalEObject, BinaryResourceImpl.EObjectOutputStream) after} hooks are invoked for every object so that the stream stays + * symmetric with the one expected by {@link CheckBatchLinkableResourceStorageLoadable}. + *

+ */ + @Override + protected void writeContents(final StorageAwareResource resource, final OutputStream outputStream) throws IOException { + Resource prunedResource = CheckModelPruner.createPrunedResource(resource); + if (prunedResource == null) { + super.writeContents(resource, outputStream); + return; + } + PrunedObjectOutputStream out = new PrunedObjectOutputStream(resource, outputStream, Collections.emptyMap()); + try { + out.saveResource(prunedResource); + } finally { + out.flush(); + } + } + + /** + * Writes an empty associations adapter. + *

+ * The associations map the source elements of the catalog to the elements of the inferred JVM model, neither of which is persisted anymore. The written data + * is identical to what the base class writes for a resource without any associations, so that the format of the binary model remains unchanged. + *

+ */ + @Override + protected void writeAssociationsAdapter(final BatchLinkableResource resource, final OutputStream zipOut) throws IOException { + try (ObjectOutputStream objOut = new ObjectOutputStream(zipOut) { + @Override + public void close() throws IOException { + flush(); + } + }) { + objOut.writeObject(new LinkedHashMap()); // logicalContainerMap + objOut.writeObject(new LinkedHashMap>()); // sourceToTargetMap + objOut.writeObject(new LinkedHashMap>()); // targetToSourceMap + } + } + + /** + * An output stream writing the contents of a resource other than the one being persisted, while still resolving portable URIs and computing the additional + * per-object data relative to the resource being persisted. + */ + private class PrunedObjectOutputStream extends BinaryResourceImpl.EObjectOutputStream { + + private final StorageAwareResource sourceResource; + + PrunedObjectOutputStream(final StorageAwareResource sourceResource, final OutputStream outputStream, final Map options) throws IOException { + super(outputStream, options); + this.sourceResource = sourceResource; + } + + @Override + public void writeURI(final URI uri, final String fragment) throws IOException { + URI fullURI = uri.appendFragment(fragment); + URI portableURI = sourceResource.getPortableURIs().toPortableURI(sourceResource, fullURI); + URI uriToWrite = portableURI == null ? fullURI : portableURI; + super.writeURI(uriToWrite.trimFragment(), uriToWrite.fragment()); + } + + @Override + public void saveEObject(final InternalEObject internalEObject, final BinaryResourceImpl.EObjectOutputStream.Check check) throws IOException { + beforeSaveEObject(internalEObject, this); + super.saveEObject(internalEObject, check); + handleSaveEObject(internalEObject, this); + } + } } diff --git a/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/resource/CheckModelPruner.java b/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/resource/CheckModelPruner.java new file mode 100644 index 0000000000..d8cb135afc --- /dev/null +++ b/com.avaloq.tools.ddk.check.core/src/com/avaloq/tools/ddk/check/resource/CheckModelPruner.java @@ -0,0 +1,160 @@ +/******************************************************************************* + * Copyright (c) 2016 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ + +package com.avaloq.tools.ddk.check.resource; + +import java.util.Collection; + +import org.eclipse.emf.common.util.TreeIterator; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.EReference; +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.emf.ecore.resource.impl.ResourceImpl; +import org.eclipse.emf.ecore.util.EcoreUtil; +import org.eclipse.xtext.xtype.XComputedTypeReference; + +import com.avaloq.tools.ddk.check.check.Check; +import com.avaloq.tools.ddk.check.check.CheckCatalog; +import com.avaloq.tools.ddk.check.check.Context; + + +/** + * Creates a copy of a check catalog model that only contains the publicly visible API of the catalog, that is, everything a consumer of a persisted catalog + * needs in order to reference its checks: the catalog itself, its categories, checks, formal parameters and the context variables the checks apply to. + *

+ * Everything describing how a check is implemented is removed: the constraint expressions of the contexts, the catalog members, the + * {@link com.avaloq.tools.ddk.check.check.Implementation implementations} and the import section. The inferred JVM model is not part of the copy either since + * only the catalog itself is copied. + *

+ */ +public final class CheckModelPruner { + + private CheckModelPruner() { + // Empty constructor to avoid instantiation. + } + + /** + * Creates a new, detached resource containing a copy of the given resource's check catalog from which all implementation details have been removed. The given + * resource is not modified. + * + * @param resource + * the resource holding the check catalog to copy, must not be {@code null} + * @return a detached resource containing the pruned copy of the catalog, or {@code null} if the given resource does not contain a check catalog + */ + public static Resource createPrunedResource(final Resource resource) { + if (resource.getContents().isEmpty() || !(resource.getContents().get(0) instanceof CheckCatalog)) { + return null; + } + CheckCatalog catalog = (CheckCatalog) resource.getContents().get(0); + // Computed type references can only be resolved as long as they are attached to their type provider, which is not copied. + resolveComputedTypeReferences(catalog); + + EcoreUtil.Copier copier = new EcoreUtil.Copier(true, true); + CheckCatalog prunedCatalog = (CheckCatalog) copier.copy(catalog); + copier.copyReferences(); + + Resource prunedResource = new ResourceImpl(resource.getURI()); + prunedResource.getContents().add(prunedCatalog); + + removeImplementation(prunedCatalog); + // References to objects that were either removed above or that belong to the inferred JVM model would otherwise still be serialized. + removeDanglingReferences(prunedCatalog, resource); + return prunedResource; + } + + /** + * Forces the resolution of all computed type references of the given catalog so that their equivalents are available in a copy of the catalog. + * + * @param catalog + * the catalog to resolve the computed type references of, must not be {@code null} + */ + private static void resolveComputedTypeReferences(final CheckCatalog catalog) { + for (TreeIterator contents = catalog.eAllContents(); contents.hasNext();) { + EObject object = contents.next(); + if (object instanceof XComputedTypeReference) { + ((XComputedTypeReference) object).getType(); + } + } + } + + /** + * Removes everything describing the implementation of the checks of the given catalog. + * + * @param catalog + * the catalog to prune, must not be {@code null} + */ + private static void removeImplementation(final CheckCatalog catalog) { + catalog.setImports(null); + catalog.getMembers().clear(); + catalog.getImplementations().clear(); + for (Check check : catalog.getAllChecks()) { + for (Context context : check.getContexts()) { + context.setConstraint(null); + } + } + } + + /** + * Unsets all cross references of the given object and its contents that no longer have a valid target, that is, that either still point to an object of the + * given resource or to an object that is no longer contained in any resource because it has been removed. + * + * @param root + * the root of the object tree to clean up, must not be {@code null} + * @param resource + * the resource whose objects must no longer be referenced, must not be {@code null} + */ + private static void removeDanglingReferences(final EObject root, final Resource resource) { + unsetDanglingReferences(root, resource); + for (TreeIterator contents = root.eAllContents(); contents.hasNext();) { + unsetDanglingReferences(contents.next(), resource); + } + } + + /** + * Unsets all cross references of the given object that no longer have a valid target. + * + * @param object + * the object to clean up, must not be {@code null} + * @param resource + * the resource whose objects must no longer be referenced, must not be {@code null} + */ + private static void unsetDanglingReferences(final EObject object, final Resource resource) { + for (EReference reference : object.eClass().getEAllReferences()) { + if (reference.isContainment() || reference.isContainer() || reference.isDerived() || !reference.isChangeable() || !object.eIsSet(reference)) { + continue; + } + if (reference.isMany()) { + ((Collection) object.eGet(reference, false)).removeIf(value -> isDangling(value, resource)); + } else if (isDangling(object.eGet(reference, false), resource)) { + object.eUnset(reference); + } + } + } + + /** + * Tests whether the given reference value can no longer be serialized, that is, whether it is an object of the given resource or an object that is no longer + * contained in any resource. Proxies are never dangling: they are serialized as the URI they point to. + * + * @param value + * the value to test, may be {@code null} + * @param resource + * the resource whose objects must no longer be referenced, must not be {@code null} + * @return {@code true} if the value must not be serialized + */ + private static boolean isDangling(final Object value, final Resource resource) { + if (!(value instanceof EObject) || ((EObject) value).eIsProxy()) { + return false; + } + Resource targetResource = ((EObject) value).eResource(); + return targetResource == resource || targetResource == null; + } + +} diff --git a/com.avaloq.tools.ddk.check.ui/src/com/avaloq/tools/ddk/check/ui/CheckResourceUIServiceProvider.java b/com.avaloq.tools.ddk.check.ui/src/com/avaloq/tools/ddk/check/ui/CheckResourceUIServiceProvider.java new file mode 100644 index 0000000000..ec3a360b1e --- /dev/null +++ b/com.avaloq.tools.ddk.check.ui/src/com/avaloq/tools/ddk/check/ui/CheckResourceUIServiceProvider.java @@ -0,0 +1,46 @@ +/******************************************************************************* + * Copyright (c) 2016 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.check.ui; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.IStorage; +import org.eclipse.emf.common.util.URI; +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.eclipse.xtext.ui.resource.DefaultResourceUIServiceProvider; + +import com.google.inject.Inject; + + +/** + * Excludes generated check catalogs from the build. + *

+ * The generator emits a {@link com.avaloq.tools.ddk.check.generator.CheckStubCompiler stub} of every catalog, that is, a check file declaring the public API of + * the catalog, into the output folder. Without this service provider that stub would be indexed and built like any other check file, which would result in a + * second catalog with the very same qualified name. + *

+ */ +public class CheckResourceUIServiceProvider extends DefaultResourceUIServiceProvider { + + @Inject + public CheckResourceUIServiceProvider(final IResourceServiceProvider delegate) { + super(delegate); + } + + @Override + public boolean canBuild(final URI uri, final IStorage storage) { + if (storage instanceof IFile && ((IFile) storage).isDerived(IResource.CHECK_ANCESTORS)) { + return false; + } + return super.canBuild(uri, storage); + } + +} diff --git a/com.avaloq.tools.ddk.check.ui/src/com/avaloq/tools/ddk/check/ui/CheckUiModule.java b/com.avaloq.tools.ddk.check.ui/src/com/avaloq/tools/ddk/check/ui/CheckUiModule.java index 65ef293211..808887600e 100644 --- a/com.avaloq.tools.ddk.check.ui/src/com/avaloq/tools/ddk/check/ui/CheckUiModule.java +++ b/com.avaloq.tools.ddk.check.ui/src/com/avaloq/tools/ddk/check/ui/CheckUiModule.java @@ -24,6 +24,7 @@ import org.eclipse.xtext.ui.editor.syntaxcoloring.IHighlightingConfiguration; import org.eclipse.xtext.ui.editor.templates.CrossReferenceTemplateVariableResolver; import org.eclipse.xtext.ui.editor.templates.XtextTemplateContextType; +import org.eclipse.xtext.ui.resource.IResourceUIServiceProvider; import org.eclipse.xtext.xbase.compiler.GeneratorConfigProvider; import org.eclipse.xtext.xbase.compiler.IGeneratorConfigProvider; @@ -89,6 +90,15 @@ public Class bindI return CheckBuilderParticipant.class; } + /** + * Binds a resource service provider which excludes generated catalog stubs from the build. + * + * @return the resource service provider to use, never {@code null} + */ + public Class bindIResourceUIServiceProvider() { + return CheckResourceUIServiceProvider.class; + } + @Override public Class bindIEObjectHoverProvider() { return CheckHoverProvider.class;