diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 34b47f3ed..a8a96b48f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1235,7 +1235,7 @@ jobs: - name: Build and test the SysON frontend working-directory: editors/syson/frontend run: | - npm ci + npm ci --ignore-scripts npm run typecheck npm run format:check npm test diff --git a/.github/workflows/syson-artifacts.yml b/.github/workflows/syson-artifacts.yml index 2119932ed..fb814afd4 100644 --- a/.github/workflows/syson-artifacts.yml +++ b/.github/workflows/syson-artifacts.yml @@ -44,7 +44,7 @@ jobs: - name: Build against the real Sirius packages working-directory: editors/syson/frontend run: | - npm ci + npm ci --ignore-scripts cp .npmrc.example .npmrc npm run install:syson npm run build:syson diff --git a/.gitignore b/.gitignore index 6dbd94c97..16ab8e708 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ coverage-node.lcov # Maven build output for the Java client and the Cameo plugin /client/java/**/target/ /editors/cameo/**/target/ +/editors/syson/**/target/ !/editors/cameo/dist/ # Partial oracle record a failed `make fuml-expected` leaves for diagnosis diff --git a/changes/unreleased/java-condition-equal-to.changed.md b/changes/unreleased/java-condition-equal-to.changed.md new file mode 100644 index 000000000..3cc5469da --- /dev/null +++ b/changes/unreleased/java-condition-equal-to.changed.md @@ -0,0 +1 @@ +- **The Java client's `Condition.equal` is renamed `Condition.equalTo`.** The old name collided with `Object.equals` on every use; `Condition.equalTo(property, values)` is a drop-in rename. diff --git a/client/java/README.md b/client/java/README.md index c9a75839b..7100fda89 100644 --- a/client/java/README.md +++ b/client/java/README.md @@ -35,7 +35,7 @@ try (Connection connection = Connection.open()) { // starts a private sysml boolean holds = v.holds(); // false is an answer, not a failure Analysis study = model.runAnalysis("Trade::lightest"); // outputs, verdicts, case evaluations List parts = model.query( - Query.all().where(Condition.equal("@type", List.of("PartUsage")))); + Query.all().where(Condition.equalTo("@type", List.of("PartUsage")))); connection.capabilities().require(Capabilities.FEATURE_VALUES); } diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Condition.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Condition.java index 46564a2df..61d7ba06b 100644 --- a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Condition.java +++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Condition.java @@ -5,7 +5,7 @@ /** * A {@link Query} filter: one comparison of a property, or several conditions combined. Build one - * with {@link #equal}, {@link #greater}, {@link #less}, {@link #all} or {@link #any}, and negate it + * with {@link #equalTo}, {@link #greater}, {@link #less}, {@link #all} or {@link #any}, and negate it * with {@link #negated()}. */ public sealed interface Condition { @@ -19,7 +19,7 @@ public sealed interface Condition { * @param values the values it may equal * @return the comparison */ - static Comparison equal(String property, List values) { + static Comparison equalTo(String property, List values) { return new Comparison(property, Comparison.Operator.EQUAL, values, false); } diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java index 922bff754..987c07d47 100644 --- a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java +++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java @@ -51,6 +51,8 @@ * } */ public final class Connection implements AutoCloseable { + private static final String NAME_OPTIONS = "options"; + private static final String NAME_CONTENT = "content"; private final ConnectTransport transport; private final String address; @@ -92,7 +94,7 @@ public static Connection open() { * @throws TransportException if the service could not be reached */ public static Connection open(ConnectionOptions options) { - Objects.requireNonNull(options, "options"); + Objects.requireNonNull(options, NAME_OPTIONS); Optional external = externalAddress(options); if (external.isPresent()) { String address = external.get(); @@ -217,7 +219,7 @@ public Model load(Path file, ParseOptions options) { * @throws ModelException if the source could not be parsed at all */ public Model parse(String content) { - Objects.requireNonNull(content, "content"); + Objects.requireNonNull(content, NAME_CONTENT); return parsed(ParseFileRequest.newBuilder().setContent(content).build()); } @@ -229,7 +231,7 @@ public Model parse(String content) { * @return the parsed model */ public Model parse(String content, ParseOptions options) { - Objects.requireNonNull(content, "content"); + Objects.requireNonNull(content, NAME_CONTENT); return parsed(request(options).setContent(content).build()); } @@ -267,7 +269,7 @@ public Model parseSources(List documents) { */ public Model parseSources(List documents, ParseOptions options) { Objects.requireNonNull(documents, "documents"); - Objects.requireNonNull(options, "options"); + Objects.requireNonNull(options, NAME_OPTIONS); capabilities.require(Capabilities.PARSE_SOURCES); if (options.strictConformance()) { capabilities.require(Capabilities.STRICT_CONFORMANCE); @@ -321,7 +323,7 @@ public Conversion convert(String content, String toFormat) { * @throws CapabilityException if the service does not advertise {@code convert} */ public Conversion convert(String content, String toFormat, ConversionOptions options) { - Objects.requireNonNull(content, "content"); + Objects.requireNonNull(content, NAME_CONTENT); return converted(ConvertRequest.newBuilder().setContent(content), toFormat, options); } @@ -420,7 +422,7 @@ private Model parsed(ParseFileRequest request) { private Conversion converted( ConvertRequest.Builder request, String toFormat, ConversionOptions options) { Objects.requireNonNull(toFormat, "toFormat"); - Objects.requireNonNull(options, "options"); + Objects.requireNonNull(options, NAME_OPTIONS); capabilities.require(Capabilities.CONVERT); request.setToFormat(toFormat).setTolerateSyntaxErrors(options.tolerateSyntaxErrors()); options.fromFormat().ifPresent(request::setFromFormat); @@ -434,7 +436,7 @@ private Conversion converted( } private static ParseFileRequest.Builder request(ParseOptions options) { - Objects.requireNonNull(options, "options"); + Objects.requireNonNull(options, NAME_OPTIONS); return ParseFileRequest.newBuilder() .setLanguage(options.language().wireName()) .setStrictConformance(options.strictConformance()); diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Edit.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Edit.java index dcc3a0f96..e42d66837 100644 --- a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Edit.java +++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Edit.java @@ -12,6 +12,10 @@ */ public sealed interface Edit { + private static void requireTarget(String target) { + Objects.requireNonNull(target, "target"); + } + /** * Sets the value of a feature that already exists, replacing the expression of its {@code = * } or adding one before the declaration's {@code ;}. @@ -30,7 +34,7 @@ record SetValue(String target, String value) implements Edit { * @param value the new value, never {@code null} */ public SetValue { - Objects.requireNonNull(target, "target"); + requireTarget(target); Objects.requireNonNull(value, "value"); } } @@ -52,7 +56,7 @@ record Rename(String target, String newName) implements Edit { * @param newName the new name, never {@code null} */ public Rename { - Objects.requireNonNull(target, "target"); + requireTarget(target); Objects.requireNonNull(newName, "newName"); } } @@ -170,7 +174,7 @@ record Delete(String target, boolean cascade) implements Edit { * @param cascade whether referring declarations go too */ public Delete { - Objects.requireNonNull(target, "target"); + requireTarget(target); } } @@ -191,7 +195,7 @@ record Move(String target, String owner) implements Edit { * @param owner the receiving namespace, never {@code null} */ public Move { - Objects.requireNonNull(target, "target"); + requireTarget(target); Objects.requireNonNull(owner, "owner"); } } diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java index 29e5c044b..d1e18a5f1 100644 --- a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java +++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java @@ -72,6 +72,10 @@ * what the call needs. */ public final class Model { + private static final String EXPLORE = "explore"; + private static final String NAME_SUBJECT_SYMBOL_ID = "subjectSymbolId"; + private static final String NAME_SYMBOL_ID = "symbolId"; + private static final String NAME_OPTIONS = "options"; private final Connection connection; private final String hash; @@ -171,7 +175,7 @@ public Optional engine() { public Model withEngine(String engine) { Objects.requireNonNull(engine, "engine"); connection.capabilities().require(Capabilities.ENGINES); - if (engine.equals("explore")) { + if (engine.equals(EXPLORE)) { connection.capabilities().require(Capabilities.SCHEDULE_EXPLORE); } return new Model(connection, hash, roots, parseDiagnostics, Optional.of(engine)); @@ -263,7 +267,7 @@ public Value evalInContext(String expression, String contextSymbolId) { * would otherwise ignore rather than refuse */ public Value evalWithSubject(String expression, String subjectSymbolId) { - Objects.requireNonNull(subjectSymbolId, "subjectSymbolId"); + Objects.requireNonNull(subjectSymbolId, NAME_SUBJECT_SYMBOL_ID); connection.capabilities().require(Capabilities.EVALUATE_SUBJECT); return evaluated(request(expression).setSubjectSymbolId(subjectSymbolId).build()); } @@ -277,7 +281,7 @@ public Value evalWithSubject(String expression, String subjectSymbolId) { * @throws ServiceException if the service does not hold this model */ public Instantiation instantiate(String symbolId) { - Objects.requireNonNull(symbolId, "symbolId"); + Objects.requireNonNull(symbolId, NAME_SYMBOL_ID); InstantiateResponse response = connection.call( "Instantiate", @@ -503,12 +507,12 @@ public Verification verifyConstraint(String symbolId) { * @throws CapabilityException if the service does not advertise {@code verification} */ public Verification verifyConstraint(String symbolId, String subjectSymbolId) { - Objects.requireNonNull(subjectSymbolId, "subjectSymbolId"); + Objects.requireNonNull(subjectSymbolId, NAME_SUBJECT_SYMBOL_ID); return verifyConstraint(symbolId, Optional.of(subjectSymbolId)); } private Verification verifyConstraint(String symbolId, Optional subjectSymbolId) { - Objects.requireNonNull(symbolId, "symbolId"); + Objects.requireNonNull(symbolId, NAME_SYMBOL_ID); connection.capabilities().require(Capabilities.VERIFICATION); VerifyConstraintRequest.Builder request = VerifyConstraintRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId); @@ -546,12 +550,12 @@ public Verification verifyRequirement(String symbolId) { * @throws CapabilityException if the service does not advertise {@code verification} */ public Verification verifyRequirement(String symbolId, String subjectSymbolId) { - Objects.requireNonNull(subjectSymbolId, "subjectSymbolId"); + Objects.requireNonNull(subjectSymbolId, NAME_SUBJECT_SYMBOL_ID); return verifyRequirement(symbolId, Optional.of(subjectSymbolId)); } private Verification verifyRequirement(String symbolId, Optional subjectSymbolId) { - Objects.requireNonNull(symbolId, "symbolId"); + Objects.requireNonNull(symbolId, NAME_SYMBOL_ID); connection.capabilities().require(Capabilities.VERIFICATION); VerifyRequirementRequest.Builder request = VerifyRequirementRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId); @@ -616,7 +620,7 @@ private Satisfaction verifySatisfaction(Optional scopeSymbolId) { * @throws CapabilityException if the service does not advertise {@code verification} */ public Validation validateInstance(String symbolId) { - Objects.requireNonNull(symbolId, "symbolId"); + Objects.requireNonNull(symbolId, NAME_SYMBOL_ID); connection.capabilities().require(Capabilities.VERIFICATION); ValidateInstanceRequest.Builder request = ValidateInstanceRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId); @@ -640,7 +644,7 @@ public Validation validateInstance(String symbolId) { * @throws ServiceException if the service does not hold this model */ public Calculation evaluateCalc(String symbolId, List arguments) { - Objects.requireNonNull(symbolId, "symbolId"); + Objects.requireNonNull(symbolId, NAME_SYMBOL_ID); Objects.requireNonNull(arguments, "arguments"); EvaluateCalcRequest.Builder request = EvaluateCalcRequest.newBuilder() @@ -732,10 +736,10 @@ public Exploration exploreAnalysis(String symbolId, AnalysisOptions options) { private RunAnalysisResponse runAnalysis( String symbolId, AnalysisOptions options, boolean explore) { - Objects.requireNonNull(symbolId, "symbolId"); - Objects.requireNonNull(options, "options"); + Objects.requireNonNull(symbolId, NAME_SYMBOL_ID); + Objects.requireNonNull(options, NAME_OPTIONS); connection.capabilities().require(Capabilities.VERIFICATION); - if (!explore && engine.isPresent() && engine.orElseThrow().equals("explore")) { + if (!explore && engine.isPresent() && engine.orElseThrow().equals(EXPLORE)) { throw new IllegalArgumentException( "engine explore answers every outcome; use exploreAnalysis"); } @@ -813,7 +817,7 @@ public Conversion convert(String toFormat) { */ public Conversion convert(String toFormat, ConversionOptions options) { Objects.requireNonNull(toFormat, "toFormat"); - Objects.requireNonNull(options, "options"); + Objects.requireNonNull(options, NAME_OPTIONS); connection.capabilities().require(Capabilities.CONVERT); ConvertRequest.Builder request = ConvertRequest.newBuilder() @@ -863,7 +867,7 @@ public EditResult applyEdits(List edits) { */ public EditResult applyEdits(List edits, EditOptions options) { Objects.requireNonNull(edits, "edits"); - Objects.requireNonNull(options, "options"); + Objects.requireNonNull(options, NAME_OPTIONS); connection.capabilities().require(Capabilities.APPLY_EDITS); for (Edit edit : edits) { if (edit instanceof Edit.AddMember || edit instanceof Edit.Delete || edit instanceof Edit.Move) { @@ -923,9 +927,9 @@ public Sweep runSweep(String symbolId, List ranges) { * @throws CapabilityException if the service does not advertise {@code verification} */ public Sweep runSweep(String symbolId, List ranges, SweepOptions options) { - Objects.requireNonNull(symbolId, "symbolId"); + Objects.requireNonNull(symbolId, NAME_SYMBOL_ID); Objects.requireNonNull(ranges, "ranges"); - Objects.requireNonNull(options, "options"); + Objects.requireNonNull(options, NAME_OPTIONS); connection.capabilities().require(Capabilities.VERIFICATION); RunSweepRequest.Builder request = RunSweepRequest.newBuilder() @@ -1016,7 +1020,7 @@ public RenderedDocument renderDocument(String documentId) { } private String schedule(ExecutionOptions options, boolean explore) { - Objects.requireNonNull(options, "options"); + Objects.requireNonNull(options, NAME_OPTIONS); if (options.performer().isPresent()) { connection.capabilities().require(Capabilities.PERFORMER); } @@ -1030,7 +1034,7 @@ private String schedule(Optional schedule, boolean explores, boolean exp "schedule " + schedule.orElseThrow() + " runs once; an exploration takes explore"); } connection.capabilities().require(Capabilities.SCHEDULE_EXPLORE); - return schedule.orElse("explore"); + return schedule.orElse(EXPLORE); } if (explores) { throw new IllegalArgumentException( @@ -1057,7 +1061,7 @@ private static void failed( } private SymbolResponse symbolResponse(String symbolId) { - Objects.requireNonNull(symbolId, "symbolId"); + Objects.requireNonNull(symbolId, NAME_SYMBOL_ID); return connection.call( "GetSymbol", GetSymbolRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId).build(), diff --git a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java index f47f869d5..16856bf05 100644 --- a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java +++ b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java @@ -665,19 +665,15 @@ void anActionThatCannotStartIsAModelFailureAndABadScheduleIsRefused() { ModelException failed = assertThrows(ModelException.class, () -> model.executeAction("Test::noStart")); assertFalse(failed.getMessage().isBlank()); + ExecutionOptions seeded = ExecutionOptions.defaults().withSchedule("seed:abc"); ServiceException refused = assertThrows( - ServiceException.class, - () -> - model.executeAction( - "Test::race", Map.of(), ExecutionOptions.defaults().withSchedule("seed:abc"))); + ServiceException.class, () -> model.executeAction("Test::race", Map.of(), seeded)); assertEquals(StatusCode.INVALID_ARGUMENT, refused.status()); assertTrue(refused.getMessage().contains("seed:abc")); + ExecutionOptions declared = ExecutionOptions.defaults().withSchedule("declared"); assertThrows( - IllegalArgumentException.class, - () -> - model.exploreAction( - "Test::race", Map.of(), ExecutionOptions.defaults().withSchedule("declared"))); + IllegalArgumentException.class, () -> model.exploreAction("Test::race", Map.of(), declared)); } @Test @@ -771,7 +767,7 @@ void aConstraintIsVerifiedAgainstDeclaredValuesOrAnObjectAndAFalseAnswerIsNotAFa assertFalse(wrongKind.verdict().decided()); assertFalse(wrongKind.holds()); assertEquals(FailureReason.WRONG_KIND, wrongKind.verdict().failureReason()); - assertTrue(wrongKind.verdict().error().orElseThrow().length() > 0); + assertFalse(wrongKind.verdict().error().orElseThrow().isEmpty()); } @Test @@ -900,13 +896,10 @@ void anAnalysisBindsItsArgumentsAndAFailedRunKeepsWhatItLeft() { List.of(Optional.of(new Value.RealValue(10.0)), Optional.of(new Value.RealValue(10.0))), three.evaluations().stream().map(org.openmbee.opensysml.CaseEvaluation::result).toList()); + AnalysisOptions arguments = + AnalysisOptions.defaults().withArguments(List.of(new Value.IntegerValue(4))); AnalysisException failed = - assertThrows( - AnalysisException.class, - () -> - model.runAnalysis( - "Trade::perOffset", - AnalysisOptions.defaults().withArguments(List.of(new Value.IntegerValue(4))))); + assertThrows(AnalysisException.class, () -> model.runAnalysis("Trade::perOffset", arguments)); assertTrue(failed.getMessage().contains("division by zero")); assertEquals(FailureReason.EVALUATION, failed.failureReason()); Analysis partial = failed.partial().orElseThrow(); @@ -956,7 +949,7 @@ void aQuerySelectsElementsByTypeScopeAndProperty() { assertTrue(all.containsAll(List.of("Demo", "Demo::Vehicle", "Demo::vehicle::wheels", "Demo::spare"))); List parts = - model.query(Query.all().where(Condition.equal("@type", List.of("PartUsage")))); + model.query(Query.all().where(Condition.equalTo("@type", List.of("PartUsage")))); assertEquals( List.of("Demo::spare", "Demo::vehicle", "Demo::vehicle::wheels"), parts.stream().map(QueryElement::id).sorted().toList()); @@ -969,7 +962,7 @@ void aQuerySelectsElementsByTypeScopeAndProperty() { model.query( Query.all() .withSelect(List.of("name", "owner")) - .where(Condition.equal("qualifiedName", List.of("Demo::vehicle::wheels")))); + .where(Condition.equalTo("qualifiedName", List.of("Demo::vehicle::wheels")))); assertEquals( List.of( new QueryElement( @@ -980,10 +973,9 @@ void aQuerySelectsElementsByTypeScopeAndProperty() { assertEquals( 3, model.queryOslc("oslc.where=rdf:type=\"PartUsage\"&oslc.select=sysml:name").size()); + Query missingScope = Query.all().withScope(List.of("Demo::Missing")); ServiceException refused = - assertThrows( - ServiceException.class, - () -> model.query(Query.all().withScope(List.of("Demo::Missing")))); + assertThrows(ServiceException.class, () -> model.query(missingScope)); assertEquals(StatusCode.INVALID_ARGUMENT, refused.status()); } @@ -1025,16 +1017,14 @@ void parseSourcesParsesSeveralDocumentsAsOneModel() throws Exception { @Test void parseSourcesRefusesTwoDocumentsOfOneName() throws Exception { + String librarySource = Files.readString(fixture("engine_library.sysml")); + String userSource = Files.readString(fixture("engine_user.sysml")); + List documents = + List.of( + SourceDocument.inline("same.sysml", librarySource), + SourceDocument.inline("same.sysml", userSource)); ServiceException refused = - assertThrows( - ServiceException.class, - () -> - connection.parseSources( - List.of( - SourceDocument.inline( - "same.sysml", Files.readString(fixture("engine_library.sysml"))), - SourceDocument.inline( - "same.sysml", Files.readString(fixture("engine_user.sysml")))))); + assertThrows(ServiceException.class, () -> connection.parseSources(documents)); assertEquals(StatusCode.INVALID_ARGUMENT, refused.status()); } @@ -1057,12 +1047,10 @@ void convertRewritesContentAndAParsedModel() throws Exception { @Test void convertOfUnreadableNotationIsAModelFailure() throws Exception { String source = Files.readString(fixture("syntax_error.sysml")); + ConversionOptions convertOptions = ConversionOptions.defaults().withFromFormat("sysml"); ModelException failed = assertThrows( - ModelException.class, - () -> - connection.convert( - source, "sysml", ConversionOptions.defaults().withFromFormat("sysml"))); + ModelException.class, () -> connection.convert(source, "sysml", convertOptions)); assertFalse(failed.diagnostics().isEmpty()); } @@ -1080,10 +1068,9 @@ void applyEditsRewritesAValueAndAnswersTheText() { @Test void applyEditsRefusesAnUnknownTargetByKind() { Model model = connection.load(fixture("editable.sysml")); + List edits = List.of(new Edit.SetValue("Demo::SC::nope", "1.0")); EditException refused = - assertThrows( - EditException.class, - () -> model.applyEdits(List.of(new Edit.SetValue("Demo::SC::nope", "1.0")))); + assertThrows(EditException.class, () -> model.applyEdits(edits)); assertEquals(EditFailure.UNKNOWN_TARGET, refused.failure()); assertEquals("EDIT_FAILURE_UNKNOWN_TARGET", refused.failureName()); } @@ -1110,16 +1097,15 @@ void runSweepStepsThroughARangeAndReportsEachRow() { @Test void runSweepOfAnotherKindIsAModelFailure() { Model model = connection.load(fixture("sweep.sysml")); + List ranges = + List.of( + org.openmbee.opensysml.SweepRange.of( + "limit", new Value.RealValue(0.0), new Value.RealValue(4.0)) + .withStep(new Value.RealValue(2.0))); ModelException failed = assertThrows( ModelException.class, - () -> - model.runSweep( - "Sw::barge", - List.of( - org.openmbee.opensysml.SweepRange.of( - "limit", new Value.RealValue(0.0), new Value.RealValue(4.0)) - .withStep(new Value.RealValue(2.0))))); + () -> model.runSweep("Sw::barge", ranges)); assertEquals(FailureReason.WRONG_KIND, failed.failureReason()); } diff --git a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ResultTypesTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ResultTypesTest.java index 2ea003fde..42299c0e4 100644 --- a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ResultTypesTest.java +++ b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ResultTypesTest.java @@ -96,9 +96,11 @@ void resultsCopyTheCollectionsTheyAreGivenAndRefuseChanges() { assertEquals(1, analysis.verdicts().size()); assertEquals(List.of("total"), List.copyOf(analysis.outputs().keySet())); Value added = new Value.RealValue(2.0); - assertThrows(UnsupportedOperationException.class, () -> analysis.outputs().put("x", added)); + Map analysisOutputs = analysis.outputs(); + assertThrows(UnsupportedOperationException.class, () -> analysisOutputs.put("x", added)); Verdict extra = verdict(false, ""); - assertThrows(UnsupportedOperationException.class, () -> analysis.verdicts().add(extra)); + List analysisVerdicts = analysis.verdicts(); + assertThrows(UnsupportedOperationException.class, () -> analysisVerdicts.add(extra)); Map context = new LinkedHashMap<>(Map.of("n", new Value.IntegerValue(1))); StateRun run = @@ -258,12 +260,13 @@ void analysisOptionsAccumulateAndCopyTheirArguments() { assertTrue(options.explores()); assertFalse(AnalysisOptions.defaults().explores()); Value extra = new Value.RealValue(1.0); - assertThrows(UnsupportedOperationException.class, () -> options.arguments().add(extra)); + List optionArguments = options.arguments(); + assertThrows(UnsupportedOperationException.class, () -> optionArguments.add(extra)); } @Test void aQueryIsBuiltUpAndItsConditionsNegate() { - Condition.Comparison parts = Condition.equal("@type", List.of("PartUsage", "PartDefinition")); + Condition.Comparison parts = Condition.equalTo("@type", List.of("PartUsage", "PartDefinition")); Condition.Comparison heavy = Condition.greater("mass", "1000"); Query query = Query.all() @@ -295,7 +298,8 @@ void aQueryElementCopiesItsProperties() { QueryElement element = new QueryElement("Demo::sedan", "PartUsage", properties); properties.clear(); assertEquals(Map.of("name", "sedan"), element.properties()); - assertThrows(UnsupportedOperationException.class, () -> element.properties().put("a", "b")); + Map elementProperties = element.properties(); + assertThrows(UnsupportedOperationException.class, () -> elementProperties.put("a", "b")); } @Test diff --git a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/DocumentProtosTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/DocumentProtosTest.java index f41563df9..e51a6e5a7 100644 --- a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/DocumentProtosTest.java +++ b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/DocumentProtosTest.java @@ -1,7 +1,6 @@ package org.openmbee.opensysml.internal; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/EditProtosTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/EditProtosTest.java index 6972ca6b2..411efd6ad 100644 --- a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/EditProtosTest.java +++ b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/EditProtosTest.java @@ -6,7 +6,6 @@ import java.nio.file.Path; import java.util.List; -import java.util.Optional; import org.junit.jupiter.api.Test; import org.openmbee.opensysml.Edit; import org.openmbee.opensysml.EditFailure; diff --git a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ResultProtosTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ResultProtosTest.java index bff7ae40d..6079566f0 100644 --- a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ResultProtosTest.java +++ b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ResultProtosTest.java @@ -346,7 +346,7 @@ void aQueryWritesItsScopeSelectionAndNestedConditions() { .where( Condition.all( List.of( - Condition.equal("@type", List.of("PartUsage", "PartDefinition")).negated(), + Condition.equalTo("@type", List.of("PartUsage", "PartDefinition")).negated(), Condition.any( List.of( Condition.greater("mass", "1000"), diff --git a/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java index 5b2ca35ee..fff9efe54 100644 --- a/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java +++ b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java @@ -32,7 +32,6 @@ import org.openmbee.opensysml.ServiceException; import org.openmbee.opensysml.SourceDocument; import org.openmbee.opensysml.StateRun; -import org.openmbee.opensysml.Sweep; import org.openmbee.opensysml.SweepOptions; import org.openmbee.opensysml.SweepRange; import org.openmbee.opensysml.Symbol; @@ -814,7 +813,7 @@ private static Condition condition(Constraint constraint) { Condition.Comparison comparison = switch (primitive.getOperator()) { case PRIMITIVE_OPERATOR_EQUAL -> - Condition.equal(primitive.getProperty(), primitive.getValueList()); + Condition.equalTo(primitive.getProperty(), primitive.getValueList()); case PRIMITIVE_OPERATOR_GREATER -> Condition.greater(primitive.getProperty(), soleValue(primitive)); case PRIMITIVE_OPERATOR_LESS -> diff --git a/cmd/sysml/render_document.go b/cmd/sysml/render_document.go index 1b67f9f1a..4864140d4 100644 --- a/cmd/sysml/render_document.go +++ b/cmd/sysml/render_document.go @@ -714,64 +714,91 @@ func documentForm() (string, error) { } switch form := docFormOrDefault(); form { case docFormMarkdown: - if htmlFlagsGiven() { - return "", errors.New("the -html- options shape HTML output; ask for it with -doc-form html") - } - if pdfEngine != "" || pdfTitlePage || pdfTOC || pdfNumbering { - return "", errors.New("-pdf-engine and the title page, contents and numbering options shape HTML and PDF output; ask for one with -doc-form html or -doc-form pdf") - } - return form, nil + return checkMarkdownForm(form) case docFormHTML: - if pdfEngine != "" { - return "", errors.New("-pdf-engine shapes PDF output; -doc-form html needs no external converter") - } - if htmlFragment && htmlNoCSS { - return "", errors.New("-html-fragment already writes no stylesheet; -html-no-default-css leaves the default sheet out of a whole page") - } - if htmlFragment && len(htmlCSS) > 0 { - return "", errors.New("-html-fragment writes the document element alone, with no place for a stylesheet; style the page you embed it in") - } - if htmlFragment && htmlMermaid != "" { - return "", errors.New("-html-fragment writes the document element alone, with no place for a script; load Mermaid in the page you embed it in") - } - if htmlFragment && htmlMath != "" { - return "", errors.New("-html-fragment writes the document element alone, with no place for a script; load MathJax in the page you embed it in") - } - if err := checkMermaidScript(); err != nil { - return "", err - } - if err := checkMathScript(); err != nil { - return "", err - } - if htmlFragment && htmlTheme != "" { - return "", errors.New("-html-fragment writes the document element alone, with no place for a stylesheet; -html-theme styles a whole page") - } - if err := checkThemeUse(); err != nil { - return "", err - } - return form, nil + return checkHTMLForm(form) case docFormPDF: - if htmlFragment { - return "", errors.New("-html-fragment writes the document element alone for embedding in a page; a PDF is laid out from a whole page") - } - if htmlMermaid != "" { - return "", errors.New("-html-mermaid loads a script into an HTML page; a PDF draws its diagrams with mermaid-cli ahead of the converter") - } - if htmlMath != "" { - return "", errors.New("-html-math loads a script into an HTML page; a PDF typesets its formulas with KaTeX ahead of the converter") - } - if err := checkThemeUse(); err != nil { - return "", err - } - if outputPath == "" { - return "", errors.New("-doc-form pdf writes a binary artifact; name the file to write with -o") - } - return form, nil + return checkPDFForm(form) default: return "", unknownDocumentForm(form) } } +// checkMarkdownForm refuses the HTML and PDF options, which do not apply. +func checkMarkdownForm(form string) (string, error) { + if htmlFlagsGiven() { + return "", errors.New("the -html- options shape HTML output; ask for it with -doc-form html") + } + if pdfEngine != "" || pdfTitlePage || pdfTOC || pdfNumbering { + return "", errors.New("-pdf-engine and the title page, contents and numbering options shape HTML and PDF output; ask for one with -doc-form html or -doc-form pdf") + } + return form, nil +} + +// checkHTMLForm checks the HTML option combination and the scripts and theme it names. +func checkHTMLForm(form string) (string, error) { + if pdfEngine != "" { + return "", errors.New("-pdf-engine shapes PDF output; -doc-form html needs no external converter") + } + if err := checkFragmentOptions(); err != nil { + return "", err + } + if err := checkMermaidScript(); err != nil { + return "", err + } + if err := checkMathScript(); err != nil { + return "", err + } + if htmlFragment && htmlTheme != "" { + return "", errors.New("-html-fragment writes the document element alone, with no place for a stylesheet; -html-theme styles a whole page") + } + if err := checkThemeUse(); err != nil { + return "", err + } + return form, nil +} + +// checkFragmentOptions refuses the stylesheet, script and theme options a +// fragment has no place for. +func checkFragmentOptions() error { + if !htmlFragment { + return nil + } + if htmlNoCSS { + return errors.New("-html-fragment already writes no stylesheet; -html-no-default-css leaves the default sheet out of a whole page") + } + if len(htmlCSS) > 0 { + return errors.New("-html-fragment writes the document element alone, with no place for a stylesheet; style the page you embed it in") + } + if htmlMermaid != "" { + return errors.New("-html-fragment writes the document element alone, with no place for a script; load Mermaid in the page you embed it in") + } + if htmlMath != "" { + return errors.New("-html-fragment writes the document element alone, with no place for a script; load MathJax in the page you embed it in") + } + return nil +} + +// checkPDFForm refuses the HTML options a PDF does not use and requires -o. +func checkPDFForm(form string) (string, error) { + if htmlFragment { + return "", errors.New("-html-fragment writes the document element alone for embedding in a page; a PDF is laid out from a whole page") + } + if htmlMermaid != "" { + return "", errors.New("-html-mermaid loads a script into an HTML page; a PDF draws its diagrams with mermaid-cli ahead of the converter") + } + if htmlMath != "" { + return "", errors.New("-html-math loads a script into an HTML page; a PDF typesets its formulas with KaTeX ahead of the converter") + } + if err := checkThemeUse(); err != nil { + return "", err + } + if outputPath == "" { + return "", errors.New("-doc-form pdf writes a binary artifact; name the file to write with -o") + } + return form, nil +} + // writePDFArtifact writes the PDF bytes to -o, byte-exact. func writePDFArtifact(pdf []byte) error { replaced, err := export.WriteFile(outputPath, pdf) diff --git a/cmd/sysml/render_document_pdf_test.go b/cmd/sysml/render_document_pdf_test.go index 6b925db03..776094140 100644 --- a/cmd/sysml/render_document_pdf_test.go +++ b/cmd/sysml/render_document_pdf_test.go @@ -123,11 +123,11 @@ printf '' > "$out" t.Fatal(err) } def := strings.Index(string(page), "@layer opensysml {") - print := strings.Index(string(page), "@layer opensysml-print {") + printLayer := strings.Index(string(page), "@layer opensysml-print {") reader := strings.Index(string(page), "rebeccapurple") link := strings.Index(string(page), ``) - if def < 0 || print < def || reader < print || link < reader { - t.Errorf("stylesheet order default=%d print=%d reader=%d link=%d:\n%s", def, print, reader, link, page) + if def < 0 || printLayer < def || reader < printLayer || link < reader { + t.Errorf("stylesheet order default=%d printLayer=%d reader=%d link=%d:\n%s", def, printLayer, reader, link, page) } if !strings.Contains(string(page), "/* report:") { t.Errorf("converter input misses the report theme:\n%s", page) diff --git a/docs/guide/09-clients.md b/docs/guide/09-clients.md index 53606078f..aa16230b7 100644 --- a/docs/guide/09-clients.md +++ b/docs/guide/09-clients.md @@ -1302,7 +1302,7 @@ try (Connection connection = Connection.open()) { // starts a private sysml Verification light = model.verifyConstraint("Demo::Vehicle::massLight", "Demo::sedan"); Analysis study = model.runAnalysis("Trade::lightest"); // selected alternative, evaluations List parts = model.query( - Query.all().where(Condition.equal("@type", List.of("PartUsage")))); + Query.all().where(Condition.equalTo("@type", List.of("PartUsage")))); } ``` diff --git a/docs/reference/java-api.md b/docs/reference/java-api.md index a1f845b3f..626645cf5 100644 --- a/docs/reference/java-api.md +++ b/docs/reference/java-api.md @@ -194,7 +194,7 @@ List parts = model.query( .withScope(List.of("Demo::vehicle")) // beneath these elements .withSelect(List.of("name", "owner")) // properties to read .where(Condition.all(List.of( - Condition.equal("@type", List.of("PartUsage")), + Condition.equalTo("@type", List.of("PartUsage")), Condition.greater("mass", "1000").negated())))); parts.get(0).id(); parts.get(0).type(); parts.get(0).properties(); // qualified name, metaclass, selected values List same = model.queryOslc("oslc.where=rdf:type=\"PartUsage\"&oslc.select=sysml:name"); diff --git a/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/export/ProjectTextExporter.java b/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/export/ProjectTextExporter.java index 7a2cfe2fe..5af041ff4 100644 --- a/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/export/ProjectTextExporter.java +++ b/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/export/ProjectTextExporter.java @@ -18,6 +18,8 @@ import org.openmbee.opensysml.syson.identity.ElementIndex; public class ProjectTextExporter implements ProjectExporter { + private static final String SYSML_EXTENSION = ".sysml"; + private final ElementSerializer serializer; private final IIdentityService identityService; @@ -36,36 +38,49 @@ public ExportedProject export(IEMFEditingContext context) { for (Resource resource : context.getDomain().getResourceSet().getResources()) { String uri = resource.getURI() == null ? "" : resource.getURI().toString(); if (uri.startsWith(ElementUtil.KERML_LIBRARY_SCHEME) || uri.startsWith(ElementUtil.SYSML_LIBRARY_SCHEME)) continue; - String base = resource.getURI() == null ? "" : resource.getURI().lastSegment(); - if (base == null || base.isBlank()) base = "document-" + documentIndex; - if (!base.endsWith(".sysml")) base += ".sysml"; - String name = base; - int suffix = 1; - while (containsDocument(documents, name)) { - name = base.replace(".sysml", "-" + suffix++ + ".sysml"); - } - StringBuilder text = new StringBuilder(); - for (EObject root : resource.getContents()) { - if (!(root instanceof Element element)) continue; - if (!text.isEmpty() && text.charAt(text.length() - 1) != '\n') text.append('\n'); - int start = text.isEmpty() ? 1 : lineCount(text); - List statuses = new ArrayList<>(); - String serialized = serializer.serialize(root, statuses::add); - if (serialized == null) serialized = ""; - text.append(serialized); - int end = Math.max(start, - lineCount(text) - (text.length() > 0 && text.charAt(text.length() - 1) == '\n' ? 1 : 0)); - ranges.add(new ExportedProject.DocumentRange(name, start, end, element)); - statuses.forEach(status -> messages.add(new ExportedProject.ExportMessage(ExportedProject.level(status), - status.message()))); - index(element, entries); - } - documents.add(SourceDocument.inline(name, text.toString())); + String name = documentName(resource, documents, documentIndex); + exportResource(resource, name, documents, messages, ranges, entries); documentIndex++; } return new ExportedProject(documents, new ElementIndex(entries), messages, ranges); } + // Names one resource: its URI's last segment as a .sysml name, suffixed when taken. + private String documentName(Resource resource, List documents, int documentIndex) { + String base = resource.getURI() == null ? "" : resource.getURI().lastSegment(); + if (base == null || base.isBlank()) base = "document-" + documentIndex; + if (!base.endsWith(SYSML_EXTENSION)) base += SYSML_EXTENSION; + String name = base; + int suffix = 1; + while (containsDocument(documents, name)) { + name = base.replace(SYSML_EXTENSION, "-" + suffix++ + SYSML_EXTENSION); + } + return name; + } + + // Serializes a resource's roots into one document, recording its ranges, messages and index entries. + private void exportResource(Resource resource, String name, List documents, + List messages, List ranges, + Map entries) { + StringBuilder text = new StringBuilder(); + for (EObject root : resource.getContents()) { + if (!(root instanceof Element element)) continue; + if (!text.isEmpty() && text.charAt(text.length() - 1) != '\n') text.append('\n'); + int start = text.isEmpty() ? 1 : lineCount(text); + List statuses = new ArrayList<>(); + String serialized = serializer.serialize(root, statuses::add); + if (serialized == null) serialized = ""; + text.append(serialized); + int end = Math.max(start, + lineCount(text) - (text.length() > 0 && text.charAt(text.length() - 1) == '\n' ? 1 : 0)); + ranges.add(new ExportedProject.DocumentRange(name, start, end, element)); + statuses.forEach(status -> messages.add(new ExportedProject.ExportMessage(ExportedProject.level(status), + status.message()))); + index(element, entries); + } + documents.add(SourceDocument.inline(name, text.toString())); + } + private void index(Element root, Map entries) { visit(root, element -> { String qualifiedName = element.getQualifiedName(); @@ -90,6 +105,6 @@ private int lineCount(CharSequence value) { } private boolean containsDocument(List documents, String name) { - return documents.stream().anyMatch(document -> document.name().equals(name)); + return documents.stream().anyMatch(document -> document.name().filter(name::equals).isPresent()); } } diff --git a/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/run/MutationRunWithOpenSysMLDataFetcher.java b/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/run/MutationRunWithOpenSysMLDataFetcher.java index c28e43b83..66e9906bb 100644 --- a/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/run/MutationRunWithOpenSysMLDataFetcher.java +++ b/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/run/MutationRunWithOpenSysMLDataFetcher.java @@ -36,19 +36,8 @@ public CompletableFuture get(DataFetchingEnvironment environment) { Map argument = environment.getArgument("input"); // GraphQL represents named inputs as a list; the input record uses a map. Map values = new LinkedHashMap<>(); - String inputError = null; - for (Map value : (List>) argument.getOrDefault("inputs", List.of())) { - String name = value.get("name"); - if (name == null || name.isBlank()) { - inputError = "input name must not be blank"; - break; - } - if (values.containsKey(name)) { - inputError = "duplicate input name: " + name; - break; - } - values.put(name, value.get("expression")); - } + String inputError = collectInputs( + (List>) argument.getOrDefault("inputs", List.of()), values); Map convertedArgument = new LinkedHashMap<>(argument); convertedArgument.put("inputs", values); RunWithOpenSysMLInput converted = objectMapper.convertValue(convertedArgument, RunWithOpenSysMLInput.class); @@ -63,4 +52,18 @@ public CompletableFuture get(DataFetchingEnvironment environment) { return exceptionWrapper.wrapMono(() -> editingContextDispatcher.dispatchMutation(input.editingContextId(), input), input).toFuture(); } -} + + private static String collectInputs(List> inputs, Map values) { + for (Map value : inputs) { + String name = value.get("name"); + if (name == null || name.isBlank()) { + return "input name must not be blank"; + } + if (values.containsKey(name)) { + return "duplicate input name: " + name; + } + values.put(name, value.get("expression")); + } + return null; + } +} \ No newline at end of file diff --git a/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/run/RunResult.java b/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/run/RunResult.java index 2c7d0d24c..02c24c84e 100644 --- a/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/run/RunResult.java +++ b/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/run/RunResult.java @@ -23,31 +23,61 @@ public record MappedDiagnostic(RunDiagnostic diagnostic, Element element) { private final List instances; private final List mappedDiagnostics; - public RunResult(String modelHash, RunOperation operation, String target, boolean ok, String verdict, String schedule, - Double finalTime, List outputs, List trace, String resultText, - List outcomes, List verdicts, List instances, - List mappedDiagnostics) { - this.modelHash = modelHash; - this.operation = operation; - this.target = target; - this.ok = ok; - this.verdict = verdict; - this.schedule = schedule; - this.finalTime = finalTime; - this.outputs = List.copyOf(outputs); - this.trace = List.copyOf(trace); - this.outcomes = List.copyOf(outcomes); - this.resultText = resultText; - this.verdicts = List.copyOf(verdicts); - this.instances = List.copyOf(instances); - this.mappedDiagnostics = List.copyOf(mappedDiagnostics); + private RunResult(Builder builder) { + this.modelHash = builder.modelHash; + this.operation = builder.operation; + this.target = builder.target; + this.ok = builder.ok; + this.verdict = builder.verdict; + this.schedule = builder.schedule; + this.finalTime = builder.finalTime; + this.outputs = List.copyOf(builder.outputs); + this.trace = List.copyOf(builder.trace); + this.outcomes = List.copyOf(builder.outcomes); + this.resultText = builder.resultText; + this.verdicts = List.copyOf(builder.verdicts); + this.instances = List.copyOf(builder.instances); + this.mappedDiagnostics = List.copyOf(builder.mappedDiagnostics); } - public RunResult(String modelHash, RunOperation operation, String target, boolean ok, String verdict, String schedule, - Double finalTime, List outputs, List trace, String resultText, - List verdicts, List instances, List mappedDiagnostics) { - this(modelHash, operation, target, ok, verdict, schedule, finalTime, outputs, trace, resultText, List.of(), - verdicts, instances, mappedDiagnostics); + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String modelHash = ""; + private RunOperation operation; + private String target; + private boolean ok; + private String verdict; + private String schedule; + private Double finalTime; + private List outputs = List.of(); + private List trace = List.of(); + private List outcomes = List.of(); + private String resultText; + private List verdicts = List.of(); + private List instances = List.of(); + private List mappedDiagnostics = List.of(); + + public Builder modelHash(String modelHash) { this.modelHash = modelHash; return this; } + public Builder operation(RunOperation operation) { this.operation = operation; return this; } + public Builder target(String target) { this.target = target; return this; } + public Builder ok(boolean ok) { this.ok = ok; return this; } + public Builder verdict(String verdict) { this.verdict = verdict; return this; } + public Builder schedule(String schedule) { this.schedule = schedule; return this; } + public Builder finalTime(Double finalTime) { this.finalTime = finalTime; return this; } + public Builder outputs(List outputs) { this.outputs = outputs; return this; } + public Builder trace(List trace) { this.trace = trace; return this; } + public Builder outcomes(List outcomes) { this.outcomes = outcomes; return this; } + public Builder resultText(String resultText) { this.resultText = resultText; return this; } + public Builder verdicts(List verdicts) { this.verdicts = verdicts; return this; } + public Builder instances(List instances) { this.instances = instances; return this; } + public Builder mappedDiagnostics(List mappedDiagnostics) { + this.mappedDiagnostics = mappedDiagnostics; + return this; + } + public RunResult build() { return new RunResult(this); } } public String modelHash() { return modelHash; } @@ -70,7 +100,7 @@ public List diagnostics() { public static RunResult failure(String hash, RunOperation operation, String target, String message) { RunDiagnostic diagnostic = new RunDiagnostic("error", message, "", null, null, null, null, null); - return new RunResult(hash, operation, target, false, null, null, null, List.of(), List.of(), null, - List.of(), List.of(), List.of(), List.of(new MappedDiagnostic(diagnostic, null))); + return RunResult.builder().modelHash(hash).operation(operation).target(target) + .mappedDiagnostics(List.of(new MappedDiagnostic(diagnostic, null))).build(); } } diff --git a/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/run/RunWithOpenSysMLService.java b/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/run/RunWithOpenSysMLService.java index 93b0dbdbe..f86f9a01a 100644 --- a/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/run/RunWithOpenSysMLService.java +++ b/editors/syson/backend/src/main/java/org/openmbee/opensysml/syson/run/RunWithOpenSysMLService.java @@ -89,9 +89,8 @@ public RunResult run(IEMFEditingContext context, Element target, RunWithOpenSysM mappedDiagnostics.forEach(value -> resultDiagnostics.add( new RunResult.MappedDiagnostic(value.diagnostic(), value.element()))); resultDiagnostics.add(new RunResult.MappedDiagnostic(diagnostic, selected.element())); - RunResult result = new RunResult("", input.operation(), targetName, false, null, null, null, List.of(), - List.of(), null, List.of(), List.of(), List.of(), - resultDiagnostics); + RunResult result = RunResult.builder().modelHash("").operation(input.operation()).target(targetName) + .mappedDiagnostics(resultDiagnostics).build(); store.put(context.getId(), result); return result; } @@ -125,9 +124,11 @@ private ResultParts dispatch(Model model, String target, RunWithOpenSysMLInput i : model.verifyConstraint(target, input.subject()), project); case VERIFY_REQUIREMENT -> ResultParts.verification(input.subject() == null ? model.verifyRequirement(target) : model.verifyRequirement(target, input.subject()), project); - case VERIFY_SATISFACTION -> ResultParts.satisfaction( - input.subject() == null ? model.verifySatisfaction(target) : model.verifySatisfaction(input.subject()), - project); + case VERIFY_SATISFACTION -> { + Satisfaction result = input.subject() == null ? model.verifySatisfaction(target) + : model.verifySatisfaction(input.subject()); + yield ResultParts.satisfaction(result, project); + } case EVALUATE_CALC -> ResultParts.calculation(model.evaluateCalc(target, arguments(model, input)), project); case RUN_ANALYSIS -> ResultParts.analysis(model.runAnalysis(target, new AnalysisOptions(Optional.ofNullable(input.subject()), arguments(model, input), @@ -154,12 +155,17 @@ private RunResult result(String hash, RunWithOpenSysMLInput input, String target mapped.forEach(value -> { mappedDiagnostics.add(new RunResult.MappedDiagnostic(value.diagnostic(), value.element())); }); - return new RunResult(hash, input.operation(), target, parts.ok, parts.verdict, parts.schedule, parts.finalTime, - parts.outputs, parts.trace, parts.resultText, parts.outcomes, parts.verdicts, parts.instances, - mappedDiagnostics); + return RunResult.builder().modelHash(hash).operation(input.operation()).target(target).ok(parts.ok) + .verdict(parts.verdict).schedule(parts.schedule).finalTime(parts.finalTime).outputs(parts.outputs) + .trace(parts.trace).resultText(parts.resultText).outcomes(parts.outcomes).verdicts(parts.verdicts) + .instances(parts.instances).mappedDiagnostics(mappedDiagnostics).build(); } static final class ResultParts { + private static final String HOLDS = "holds"; + private static final String VIOLATED = "violated"; + private static final String UNDECIDED = "undecided"; + private final ExportedProject project; private boolean ok = true; private String verdict; @@ -244,9 +250,7 @@ static ResultParts exploration(Exploration result, ExportedProject project) { } static ResultParts verification(Verification result, ExportedProject project) { ResultParts p = new ResultParts(project); - p.verdict = result.verdict().decided() - ? (result.verdict().holds() ? "holds" : "violated") - : "undecided"; + p.verdict = verdictLabel(result.verdict().decided(), result.verdict().holds(), HOLDS, VIOLATED); p.diagnostics = result.diagnostics(); p.verdicts = List.of(p.verdict(result.verdict())); p.instances = p.instances(result.instances()); @@ -255,8 +259,8 @@ static ResultParts verification(Verification result, ExportedProject project) { static ResultParts satisfaction(Satisfaction result, ExportedProject project) { ResultParts p = new ResultParts(project); p.verdicts = result.verdicts().stream().map(p::verdict).toList(); - p.verdict = result.verdicts().stream().anyMatch(verdict -> !verdict.decided()) - ? "undecided" : result.holds() ? "pass" : "fail"; + p.verdict = verdictLabel(result.verdicts().stream().allMatch(Verdict::decided), + result.holds(), "pass", "fail"); p.diagnostics = result.diagnostics(); p.instances = p.instances(result.instances()); return p; @@ -270,8 +274,8 @@ static ResultParts calculation(Calculation result, ExportedProject project) { } static ResultParts analysis(Analysis result, ExportedProject project) { ResultParts p = new ResultParts(project); - p.verdict = result.verdicts().stream().anyMatch(verdict -> !verdict.decided()) - ? "undecided" : result.holds() ? "holds" : "violated"; + p.verdict = verdictLabel(result.verdicts().stream().allMatch(Verdict::decided), + result.holds(), HOLDS, VIOLATED); p.outputs = values(result.outputs()); p.verdicts = result.verdicts().stream().map(p::verdict).toList(); p.diagnostics = result.diagnostics(); @@ -281,12 +285,19 @@ static ResultParts analysis(Analysis result, ExportedProject project) { static ResultParts validation(org.openmbee.opensysml.Validation result, ExportedProject project) { ResultParts p = new ResultParts(project); p.verdicts = result.verdicts().stream().map(p::verdict).toList(); - p.verdict = result.summary().decided() - ? (result.holds() ? "holds" : "violated") : "undecided"; + p.verdict = verdictLabel(result.summary().decided(), result.holds(), HOLDS, VIOLATED); p.diagnostics = result.diagnostics(); p.instances = p.instances(result.instances()); return p; } + // verdictLabel is the summary word a decided verdict writes. + static String verdictLabel(boolean decided, boolean holds, String holdsLabel, String failsLabel) { + if (!decided) { + return UNDECIDED; + } + return holds ? holdsLabel : failsLabel; + } + static List values(Map values) { return values.entrySet().stream() .map(entry -> new RunNamedValue(entry.getKey(), ValueText.render(entry.getValue()))) diff --git a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/DiagnosticMapperTest.java b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/DiagnosticMapperTest.java index 2fe5ffbe2..3018fa3d5 100644 --- a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/DiagnosticMapperTest.java +++ b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/DiagnosticMapperTest.java @@ -12,17 +12,18 @@ import org.openmbee.opensysml.syson.run.DiagnosticMapper; class DiagnosticMapperTest { + private static final String PKG_A = "Pkg::A"; @Test void mapsSpanAndNamedElement() { - FakeElement element = new FakeElement("Pkg::A"); - ElementIndex index = new ElementIndex(Map.of("Pkg::A", - new ElementIndex.IndexedElement("Pkg::A", "id-Pkg::A", "sirius://a", element))); + FakeElement element = new FakeElement(PKG_A); + ElementIndex index = new ElementIndex(Map.of(PKG_A, + new ElementIndex.IndexedElement(PKG_A, "id-Pkg::A", "sirius://a", element))); ExportedProject project = new ExportedProject(List.of(), index, List.of(), List.of(new ExportedProject.DocumentRange("doc.sysml", 1, 3, element))); Diagnostic diagnostic = new Diagnostic(Diagnostic.Severity.ERROR, "failed in Pkg::A", "syntax", java.util.Optional.of(new Diagnostic.Span("doc.sysml", 2, 1, 2, 2))); var mapped = DiagnosticMapper.map(diagnostic, project); - assertThat(mapped.diagnostic().qualifiedName()).isEqualTo("Pkg::A"); + assertThat(mapped.diagnostic().qualifiedName()).isEqualTo(PKG_A); assertThat(mapped.element()).isSameAs(element); } } diff --git a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/ElementIndexTest.java b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/ElementIndexTest.java index 8a144e487..45b488bad 100644 --- a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/ElementIndexTest.java +++ b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/ElementIndexTest.java @@ -8,11 +8,12 @@ import org.openmbee.opensysml.syson.identity.ElementIndex; class ElementIndexTest { + private static final String PKG_A = "Pkg::A"; @Test void findsQuotedAndBareQualifiedNames() { - FakeElement element = new FakeElement("Pkg::A"); - ElementIndex index = new ElementIndex(Map.of("Pkg::A", - new ElementIndex.IndexedElement("Pkg::A", "id-Pkg::A", "sirius://a", element))); + FakeElement element = new FakeElement(PKG_A); + ElementIndex index = new ElementIndex(Map.of(PKG_A, + new ElementIndex.IndexedElement(PKG_A, "id-Pkg::A", "sirius://a", element))); assertThat(index.firstNamedIn("bad in 'Pkg::A'").orElseThrow().element()).isSameAs(element); assertThat(index.firstNamedIn("bad at Pkg::A").orElseThrow().element()).isSameAs(element); } diff --git a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/OpenSysMLValidationServiceTest.java b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/OpenSysMLValidationServiceTest.java index 10a2045d0..8799f5b7d 100644 --- a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/OpenSysMLValidationServiceTest.java +++ b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/OpenSysMLValidationServiceTest.java @@ -13,6 +13,7 @@ import org.openmbee.opensysml.syson.validation.OpenSysMLValidationService; class OpenSysMLValidationServiceTest { + private static final String PKG_A = "Pkg::A"; @Test void emptyValidationHasNoDiagnostics() { assertThat(new OpenSysMLValidationService(new RunResultStore()).validate(new Object(), null)).isEmpty(); @@ -21,31 +22,27 @@ void emptyValidationHasNoDiagnostics() { @Test void storesDiagnosticsByEditingContext() { RunResultStore store = new RunResultStore(); - store.put("ctx", new RunResult("", RunOperation.INSTANTIATE, "Pkg::A", false, null, null, null, - List.of(), List.of(), null, - List.of(), List.of(), List.of(new MappedDiagnostic( - new RunDiagnostic("error", "bad", "x", null, null, null, null, null), null)))); - assertThat(new OpenSysMLValidationService(store).validate(new org.eclipse.sirius.components.core.api.IEditingContext() { - public String getId() { return "ctx"; } - })).hasSize(1); + store.put("ctx", RunResult.builder().operation(RunOperation.INSTANTIATE).target(PKG_A) + .mappedDiagnostics(List.of(new MappedDiagnostic( + new RunDiagnostic("error", "bad", "x", null, null, null, null, null), null))).build()); + assertThat(new OpenSysMLValidationService(store) + .validate((org.eclipse.sirius.components.core.api.IEditingContext) () -> "ctx")).hasSize(1); } @Test void preservesMappedAndUnmappedDiagnostics() { RunResultStore store = new RunResultStore(); - FakeElement element = new FakeElement("Pkg::A"); - RunDiagnostic mapped = new RunDiagnostic("error", "mapped", "x", null, null, "Pkg::A", "id-Pkg::A", + FakeElement element = new FakeElement(PKG_A); + RunDiagnostic mapped = new RunDiagnostic("error", "mapped", "x", null, null, PKG_A, "id-Pkg::A", "sirius://a"); RunDiagnostic unmapped = new RunDiagnostic("warning", "unmapped", "y", null, null, null, null, null); - RunResult result = new RunResult("", RunOperation.INSTANTIATE, "Pkg::A", false, null, null, null, - List.of(), List.of(), null, List.of(), List.of(), - List.of(new MappedDiagnostic(mapped, element), new MappedDiagnostic(unmapped, null))); + RunResult result = RunResult.builder().operation(RunOperation.INSTANTIATE).target(PKG_A) + .mappedDiagnostics(List.of(new MappedDiagnostic(mapped, element), new MappedDiagnostic(unmapped, null))) + .build(); store.put("ctx", result); List diagnostics = new OpenSysMLValidationService(store).validate( - new org.eclipse.sirius.components.core.api.IEditingContext() { - public String getId() { return "ctx"; } - }); + (org.eclipse.sirius.components.core.api.IEditingContext) () -> "ctx"); assertThat(diagnostics).hasSize(2); assertThat(((org.eclipse.emf.common.util.BasicDiagnostic) diagnostics.get(0)).getData()).hasSize(1); diff --git a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/ProjectTextExporterTest.java b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/ProjectTextExporterTest.java index 9017567cf..364d0fa35 100644 --- a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/ProjectTextExporterTest.java +++ b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/ProjectTextExporterTest.java @@ -108,6 +108,22 @@ void exportsStableDocumentNames() { assertThat(result.messages().get(0).message()).isEqualTo("warning"); } + @Test + void disambiguatesDuplicateDocumentNames() { + ResourceSet set = new ResourceSetImpl(); + set.getResources().add(new ResourceImpl(URI.createURI("sirius:///a/foo"))); + set.getResources().get(0).getContents().add(new FakeElement("First")); + set.getResources().add(new ResourceImpl(URI.createURI("sirius:///b/foo"))); + set.getResources().get(1).getContents().add(new FakeElement("Second")); + IEMFEditingContext context = context(set); + ElementSerializer serializer = (element, report) -> "part def X;"; + + var result = new ProjectTextExporter(serializer, mock(IIdentityService.class)).export(context); + + assertThat(result.documents()).extracting(document -> document.name().orElseThrow()) + .containsExactly("foo.sysml", "foo-1.sysml"); + } + private static IEMFEditingContext context(ResourceSet set) { AdapterFactoryEditingDomain domain = new AdapterFactoryEditingDomain(new ComposedAdapterFactory(), new BasicCommandStack(), set); diff --git a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunResultStoreTest.java b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunResultStoreTest.java index c08554077..b748bccaa 100644 --- a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunResultStoreTest.java +++ b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunResultStoreTest.java @@ -30,8 +30,7 @@ void clearsOnEditingContextDisposalAndResubscribes() { when(provider.getIfAvailable()).thenReturn(registry); RunResultStore store = new RunResultStore(provider); - RunResult result = new RunResult("", RunOperation.INSTANTIATE, "A", true, null, null, null, - List.of(), List.of(), null, List.of(), List.of(), List.of()); + RunResult result = RunResult.builder().operation(RunOperation.INSTANTIATE).target("A").ok(true).build(); store.put("ctx", result); assertThat(store.latest("ctx")).contains(result); diff --git a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunWithOpenSysMLServiceAnalysisTest.java b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunWithOpenSysMLServiceAnalysisTest.java index 52b2c38ef..3a45c975a 100644 --- a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunWithOpenSysMLServiceAnalysisTest.java +++ b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunWithOpenSysMLServiceAnalysisTest.java @@ -31,21 +31,23 @@ import org.openmbee.opensysml.syson.run.RunWithOpenSysMLService; class RunWithOpenSysMLServiceAnalysisTest { + private static final String ANALYSIS = "Analysis"; + private static final String ANALYSIS_ID = "analysis-id"; @Test void passesAnalysisOptionsToModel() { Connection connection = mock(Connection.class); Model model = mock(Model.class); Element target = mock(Element.class); - when(target.getQualifiedName()).thenReturn("Analysis"); - when(target.getElementId()).thenReturn("analysis-id"); + when(target.getQualifiedName()).thenReturn(ANALYSIS); + when(target.getElementId()).thenReturn(ANALYSIS_ID); when(connection.parseSources(any())).thenReturn(model); when(model.diagnostics()).thenReturn(List.of()); when(model.hash()).thenReturn("hash"); when(model.eval("21")).thenReturn(new Value.IntegerValue(21)); - when(model.runAnalysis(eq("Analysis"), any(AnalysisOptions.class))) + when(model.runAnalysis(eq(ANALYSIS), any(AnalysisOptions.class))) .thenReturn(new Analysis(Map.of(), List.of(), List.of(), List.of(), List.of(), List.of(), Standing.none())); ExportedProject project = new ExportedProject(List.of(), - new ElementIndex(Map.of("Analysis", new ElementIndex.IndexedElement("Analysis", "analysis-id", + new ElementIndex(Map.of(ANALYSIS, new ElementIndex.IndexedElement(ANALYSIS, ANALYSIS_ID, "sirius-id", target))), List.of(), List.of()); ProjectExporter exporter = context -> project; @@ -54,11 +56,11 @@ void passesAnalysisOptionsToModel() { RunWithOpenSysMLService service = new RunWithOpenSysMLService(connection, exporter, new RunResultStore(), new OpenSysMLProperties()); - service.run(context, target, new RunWithOpenSysMLInput(UUID.randomUUID(), "ctx", "analysis-id", + service.run(context, target, new RunWithOpenSysMLInput(UUID.randomUUID(), "ctx", ANALYSIS_ID, RunOperation.RUN_ANALYSIS, Map.of("x", "21"), List.of(), List.of("21"), "declared", "subject")); ArgumentCaptor options = ArgumentCaptor.forClass(AnalysisOptions.class); - verify(model).runAnalysis(eq("Analysis"), options.capture()); + verify(model).runAnalysis(eq(ANALYSIS), options.capture()); assertThat(options.getValue().subject()).isEqualTo(Optional.of("subject")); assertThat(options.getValue().arguments()).containsExactly(new Value.IntegerValue(21)); assertThat(options.getValue().namedArguments()).containsEntry("x", new Value.IntegerValue(21)); diff --git a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunWithOpenSysMLServiceTest.java b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunWithOpenSysMLServiceTest.java index 5af844ec0..8b376218b 100644 --- a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunWithOpenSysMLServiceTest.java +++ b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunWithOpenSysMLServiceTest.java @@ -19,30 +19,33 @@ import org.openmbee.opensysml.syson.export.ProjectTextExporter; import org.openmbee.opensysml.syson.identity.ElementIndex; import org.openmbee.opensysml.syson.run.RunOperation; +import org.openmbee.opensysml.syson.run.RunResult; import org.openmbee.opensysml.syson.run.RunResultStore; import org.openmbee.opensysml.syson.run.RunWithOpenSysMLInput; import org.openmbee.opensysml.syson.run.RunWithOpenSysMLService; class RunWithOpenSysMLServiceTest { + private static final String VEHICLE = "Vehicle"; + private static final String VEHICLE_ID = "vehicle-id"; @Test void doesNotEvaluateStaleArgumentsWhenInstantiating() { Connection connection = org.mockito.Mockito.mock(Connection.class); Model model = org.mockito.Mockito.mock(Model.class); Element target = org.mockito.Mockito.mock(Element.class); - org.mockito.Mockito.when(target.getQualifiedName()).thenReturn("Vehicle"); - org.mockito.Mockito.when(target.getElementId()).thenReturn("vehicle-id"); + org.mockito.Mockito.when(target.getQualifiedName()).thenReturn(VEHICLE); + org.mockito.Mockito.when(target.getElementId()).thenReturn(VEHICLE_ID); org.mockito.Mockito.when(connection.parseSources(org.mockito.ArgumentMatchers.any())) .thenReturn(model); org.mockito.Mockito.when(model.diagnostics()).thenReturn(List.of()); org.mockito.Mockito.when(model.hash()).thenReturn("hash"); Instance instance = org.mockito.Mockito.mock(Instance.class); org.mockito.Mockito.when(instance.id()).thenReturn(1L); - org.mockito.Mockito.when(instance.typeSymbolId()).thenReturn("Vehicle"); + org.mockito.Mockito.when(instance.typeSymbolId()).thenReturn(VEHICLE); org.mockito.Mockito.when(instance.featureValues()).thenReturn(Map.of()); - org.mockito.Mockito.when(model.instantiate("Vehicle")) + org.mockito.Mockito.when(model.instantiate(VEHICLE)) .thenReturn(new Instantiation(instance, List.of(instance), List.of())); ExportedProject project = new ExportedProject(List.of(SourceDocument.inline("vehicle.sysml", "part def Vehicle;")), - new ElementIndex(Map.of("Vehicle", new ElementIndex.IndexedElement("Vehicle", "vehicle-id", + new ElementIndex(Map.of(VEHICLE, new ElementIndex.IndexedElement(VEHICLE, VEHICLE_ID, "sirius-id", target))), List.of(), List.of()); IEMFEditingContext context = org.mockito.Mockito.mock(IEMFEditingContext.class); @@ -50,7 +53,7 @@ void doesNotEvaluateStaleArgumentsWhenInstantiating() { RunWithOpenSysMLService service = new RunWithOpenSysMLService(connection, ignored -> project, new RunResultStore(), new OpenSysMLProperties()); - service.run(context, target, new RunWithOpenSysMLInput(UUID.randomUUID(), "ctx", "vehicle-id", + service.run(context, target, new RunWithOpenSysMLInput(UUID.randomUUID(), "ctx", VEHICLE_ID, RunOperation.INSTANTIATE, Map.of("stale", "1"), List.of(), List.of("2"), null, null)); org.mockito.Mockito.verify(model, org.mockito.Mockito.never()).eval(org.mockito.ArgumentMatchers.anyString()); @@ -61,16 +64,16 @@ void scopesSatisfactionToTargetWhenSubjectIsAbsent() { Connection connection = org.mockito.Mockito.mock(Connection.class); Model model = org.mockito.Mockito.mock(Model.class); Element target = org.mockito.Mockito.mock(Element.class); - org.mockito.Mockito.when(target.getQualifiedName()).thenReturn("Vehicle"); - org.mockito.Mockito.when(target.getElementId()).thenReturn("vehicle-id"); + org.mockito.Mockito.when(target.getQualifiedName()).thenReturn(VEHICLE); + org.mockito.Mockito.when(target.getElementId()).thenReturn(VEHICLE_ID); org.mockito.Mockito.when(connection.parseSources(org.mockito.ArgumentMatchers.any())) .thenReturn(model); org.mockito.Mockito.when(model.diagnostics()).thenReturn(List.of()); org.mockito.Mockito.when(model.hash()).thenReturn("hash"); - org.mockito.Mockito.when(model.verifySatisfaction("Vehicle")) + org.mockito.Mockito.when(model.verifySatisfaction(VEHICLE)) .thenReturn(new Satisfaction(List.of(), List.of(), List.of(), List.of())); ExportedProject project = new ExportedProject(List.of(SourceDocument.inline("vehicle.sysml", "part def Vehicle;")), - new ElementIndex(Map.of("Vehicle", new ElementIndex.IndexedElement("Vehicle", "vehicle-id", + new ElementIndex(Map.of(VEHICLE, new ElementIndex.IndexedElement(VEHICLE, VEHICLE_ID, "sirius-id", target))), List.of(), List.of()); IEMFEditingContext context = org.mockito.Mockito.mock(IEMFEditingContext.class); @@ -78,10 +81,10 @@ void scopesSatisfactionToTargetWhenSubjectIsAbsent() { RunWithOpenSysMLService service = new RunWithOpenSysMLService(connection, ignored -> project, new RunResultStore(), new OpenSysMLProperties()); - service.run(context, target, new RunWithOpenSysMLInput(UUID.randomUUID(), "ctx", "vehicle-id", + service.run(context, target, new RunWithOpenSysMLInput(UUID.randomUUID(), "ctx", VEHICLE_ID, RunOperation.VERIFY_SATISFACTION, Map.of(), List.of(), List.of(), null, null)); - org.mockito.Mockito.verify(model).verifySatisfaction("Vehicle"); + org.mockito.Mockito.verify(model).verifySatisfaction(VEHICLE); org.mockito.Mockito.verify(model, org.mockito.Mockito.never()).verifySatisfaction(); } @@ -101,4 +104,29 @@ void reportsMissingQualifiedNameWithoutCallingService() { .isEqualTo("selected element has no qualified name in the export"); org.mockito.Mockito.verifyNoInteractions(connection); } + + @Test + void handledFailureKeepsModelHashNonNull() { + Connection connection = org.mockito.Mockito.mock(Connection.class); + Element target = org.mockito.Mockito.mock(Element.class); + org.mockito.Mockito.when(target.getQualifiedName()).thenReturn(VEHICLE); + org.mockito.Mockito.when(target.getElementId()).thenReturn(VEHICLE_ID); + org.mockito.Mockito.when(connection.parseSources(org.mockito.ArgumentMatchers.any())) + .thenThrow(new IllegalArgumentException("unparsable")); + ExportedProject project = new ExportedProject(List.of(SourceDocument.inline("vehicle.sysml", "part def ;")), + new ElementIndex(Map.of(VEHICLE, new ElementIndex.IndexedElement(VEHICLE, VEHICLE_ID, + "sirius-id", target))), + List.of(), List.of()); + IEMFEditingContext context = org.mockito.Mockito.mock(IEMFEditingContext.class); + org.mockito.Mockito.when(context.getId()).thenReturn("ctx"); + RunWithOpenSysMLService service = new RunWithOpenSysMLService(connection, ignored -> project, + new RunResultStore(), new OpenSysMLProperties()); + + RunResult result = service.run(context, target, new RunWithOpenSysMLInput(UUID.randomUUID(), "ctx", + VEHICLE_ID, RunOperation.INSTANTIATE, Map.of(), List.of(), List.of(), null, null)); + + assertThat(result.ok()).isFalse(); + assertThat(result.modelHash()).isEqualTo(""); + assertThat(result.diagnostics()).isNotEmpty(); + } } diff --git a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunVerdictSummaryTest.java b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/run/RunVerdictSummaryTest.java similarity index 96% rename from editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunVerdictSummaryTest.java rename to editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/run/RunVerdictSummaryTest.java index 948a97632..cbe85acb4 100644 --- a/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/RunVerdictSummaryTest.java +++ b/editors/syson/backend/src/test/java/org/openmbee/opensysml/syson/run/RunVerdictSummaryTest.java @@ -21,6 +21,7 @@ import org.openmbee.opensysml.syson.run.RunWithOpenSysMLService.ResultParts; class RunVerdictSummaryTest { + private static final String UNDECIDED = "undecided"; private static final ExportedProject PROJECT = new ExportedProject(List.of(), new ElementIndex(java.util.Map.of()), List.of(), List.of()); private static final Standing STANDING = Standing.none(); @@ -32,7 +33,7 @@ void mapsSatisfactionDecidedAndUndecidedSummaries() { assertThat(ResultParts.satisfaction(new Satisfaction(List.of(verdict(false, false)), List.of(), List.of(), List.of()), PROJECT).verdict()).isEqualTo("fail"); assertThat(ResultParts.satisfaction(new Satisfaction(List.of(verdict(false, true)), List.of(), List.of(), - List.of()), PROJECT).verdict()).isEqualTo("undecided"); + List.of()), PROJECT).verdict()).isEqualTo(UNDECIDED); } @Test @@ -42,7 +43,7 @@ void mapsValidationDecidedAndUndecidedSummaries() { assertThat(ResultParts.validation(new Validation(verdict(false, false), List.of(), List.of(), List.of(), List.of(), true), PROJECT).verdict()).isEqualTo("violated"); assertThat(ResultParts.validation(new Validation(verdict(false, true), List.of(), List.of(), List.of(), - List.of(), true), PROJECT).verdict()).isEqualTo("undecided"); + List.of(), true), PROJECT).verdict()).isEqualTo(UNDECIDED); } @Test @@ -55,7 +56,7 @@ void mapsAnalysisDecidedAndUndecidedSummaries() { assertThat(ResultParts.analysis(new Analysis(java.util.Map.of(), List.of(verdict(false, false)), List.of(), List.of(), List.of(), List.of(), STANDING), PROJECT).verdict()).isEqualTo("violated"); assertThat(ResultParts.analysis(new Analysis(java.util.Map.of(), List.of(verdict(false, true)), List.of(), - List.of(), List.of(), List.of(), STANDING), PROJECT).verdict()).isEqualTo("undecided"); + List.of(), List.of(), List.of(), STANDING), PROJECT).verdict()).isEqualTo(UNDECIDED); } @Test diff --git a/editors/syson/backend/src/test/stubs-java/org/openmbee/opensysml/syson/FakeElement.java b/editors/syson/backend/src/test/stubs-java/org/openmbee/opensysml/syson/FakeElement.java index c006696e6..f960605b7 100644 --- a/editors/syson/backend/src/test/stubs-java/org/openmbee/opensysml/syson/FakeElement.java +++ b/editors/syson/backend/src/test/stubs-java/org/openmbee/opensysml/syson/FakeElement.java @@ -36,7 +36,7 @@ public FakeElement addChild(FakeElement child) { return this; } @Override public String getDeclaredName() { return declaredName; } - @Override public String getName() { return declaredName; } + @Override public String getName() { return getDeclaredName(); } @Override public String getQualifiedName() { return qualifiedName; } @Override public String getElementId() { return elementId; } @Override public boolean isIsLibraryElement() { return library; } diff --git a/editors/syson/frontend/package.json b/editors/syson/frontend/package.json index ee7646229..40ebac3a4 100644 --- a/editors/syson/frontend/package.json +++ b/editors/syson/frontend/package.json @@ -56,7 +56,7 @@ "format": "prettier --write src", "format:check": "prettier --check src", "typecheck": "tsc --noEmit -p tsconfig.json", - "install:syson": "npm install --no-save @eclipse-sirius/sirius-components-core@2026.9.0 @eclipse-sirius/sirius-components-trees@2026.9.0", + "install:syson": "npm install --no-save --ignore-scripts @eclipse-sirius/sirius-components-core@2026.9.0 @eclipse-sirius/sirius-components-trees@2026.9.0", "build:syson": "OPENSYSML_SYSON_REAL=1 vite build && tsc -p tsconfig.syson.json" } } diff --git a/editors/syson/frontend/src/dialog/RunWithOpenSysMLDialog.tsx b/editors/syson/frontend/src/dialog/RunWithOpenSysMLDialog.tsx index ec9f7125e..f6fdbe9d5 100644 --- a/editors/syson/frontend/src/dialog/RunWithOpenSysMLDialog.tsx +++ b/editors/syson/frontend/src/dialog/RunWithOpenSysMLDialog.tsx @@ -47,6 +47,16 @@ const usesSchedule = (operation: GQLRunOperation) => operation === 'EXECUTE_STATE' || operation === 'EXPLORE_STATE' || operation === 'RUN_ANALYSIS'; +const inputNameError = (inputs: GQLRunInputValue[], name: string, index: number): string | undefined => { + if (name.trim() === '') { + return 'Input name is required'; + } + if (inputs.findIndex((entry) => entry.name === name) !== index) { + return 'Duplicate input name'; + } + return undefined; +}; + const usesSubject = (operation: GQLRunOperation) => operation === 'VERIFY_CONSTRAINT' || operation === 'VERIFY_REQUIREMENT' || @@ -90,6 +100,27 @@ export const RunWithOpenSysMLDialog = ({ setSelection({ entries: [{ id: siriusId }] }); }; + const updateInput = (index: number, field: 'name' | 'expression', value: string): void => { + setInputs((previous) => + previous.map((entry, entryIndex) => (entryIndex === index ? { ...entry, [field]: value } : entry)) + ); + }; + const removeInput = (index: number): void => { + setInputs((previous) => previous.filter((_, entryIndex) => entryIndex !== index)); + }; + const updateEvent = (index: number, value: string): void => { + setEvents((previous) => previous.map((entry, entryIndex) => (entryIndex === index ? value : entry))); + }; + const removeEvent = (index: number): void => { + setEvents((previous) => previous.filter((_, entryIndex) => entryIndex !== index)); + }; + const updateArgument = (index: number, value: string): void => { + setArgumentsText((previous) => previous.map((entry, entryIndex) => (entryIndex === index ? value : entry))); + }; + const removeArgument = (index: number): void => { + setArgumentsText((previous) => previous.filter((_, entryIndex) => entryIndex !== index)); + }; + return ( Run with OpenSysML: {elementLabel} @@ -121,35 +152,15 @@ export const RunWithOpenSysMLDialog = ({ input.name.trim() === '' || (input.name !== '' && inputs.findIndex((entry) => entry.name === input.name) !== index) } - helperText={ - input.name.trim() === '' - ? 'Input name is required' - : input.name !== '' && inputs.findIndex((entry) => entry.name === input.name) !== index - ? 'Duplicate input name' - : undefined - } - onChange={(event) => - setInputs((previous) => - previous.map((entry, entryIndex) => - entryIndex === index ? { ...entry, name: event.target.value } : entry - ) - ) - } + helperText={inputNameError(inputs, input.name, index)} + onChange={(event) => updateInput(index, 'name', event.target.value)} /> - setInputs((previous) => - previous.map((entry, entryIndex) => - entryIndex === index ? { ...entry, expression: event.target.value } : entry - ) - ) - } + onChange={(event) => updateInput(index, 'expression', event.target.value)} /> - @@ -171,15 +182,9 @@ export const RunWithOpenSysMLDialog = ({ margin="normal" label={`Event ${index + 1}`} value={event} - onChange={(change) => - setEvents((previous) => - previous.map((entry, entryIndex) => (entryIndex === index ? change.target.value : entry)) - ) - } + onChange={(change) => updateEvent(index, change.target.value)} /> - @@ -199,17 +204,9 @@ export const RunWithOpenSysMLDialog = ({ margin="normal" label={`Argument ${index + 1}`} value={argument} - onChange={(change) => - setArgumentsText((previous) => - previous.map((entry, entryIndex) => (entryIndex === index ? change.target.value : entry)) - ) - } + onChange={(change) => updateArgument(index, change.target.value)} /> - diff --git a/editors/syson/frontend/src/extension/RunWithOpenSysMLMenuContribution.tsx b/editors/syson/frontend/src/extension/RunWithOpenSysMLMenuContribution.tsx index 785e41190..86c72ea23 100644 --- a/editors/syson/frontend/src/extension/RunWithOpenSysMLMenuContribution.tsx +++ b/editors/syson/frontend/src/extension/RunWithOpenSysMLMenuContribution.tsx @@ -20,31 +20,13 @@ type GQLTreeItem = { selectable: boolean; }; +// The props of Sirius' TreeItemContextMenuComponentProps this menu uses; the +// rest are optional to it and unused here. type TreeItemContextMenuComponentProps = { editingContextId: string; treeId: string; item: GQLTreeItem; - entry: { - id: string; - label: string; - iconURL: string[]; - keyBindings: { - isCtrl: boolean; - isMeta: boolean; - isAlt: boolean; - key: string; - }[]; - __typename: string; - } | null; - readOnly: boolean; - expandItem: () => void; - selectTreeItems: (selectedTreeItemIds: string[]) => void; - onExpandedElementChange: (expanded: string[], maxDepth: number) => void; onClose: () => void; - key: string; - expanded: string[]; - maxDepth: number; - selectedTreeItemIds: string[]; }; export const RunWithOpenSysMLMenuContribution = forwardRef( diff --git a/editors/syson/frontend/src/registry/opensysmlExtensionRegistry.ts b/editors/syson/frontend/src/registry/opensysmlExtensionRegistry.ts index bbff2222c..372d842ee 100644 --- a/editors/syson/frontend/src/registry/opensysmlExtensionRegistry.ts +++ b/editors/syson/frontend/src/registry/opensysmlExtensionRegistry.ts @@ -14,7 +14,8 @@ interface ExtensionRegistry { const contribution: TreeItemContextMenuOverrideContribution = { canHandle: (entry: GQLTreeItemContextMenuEntry) => entry.id === RUN_WITH_OPENSYSML_TOOL_ID, - component: RunWithOpenSysMLMenuContribution, + // The menu uses only four of Sirius' props; the contribution type wants the full set. + component: RunWithOpenSysMLMenuContribution as unknown as TreeItemContextMenuOverrideContribution['component'], }; export const opensysmlExtensionRegistry: ExtensionRegistry = new SiriusExtensionRegistry(); diff --git a/editors/syson/frontend/src/results/RunResultsPanel.tsx b/editors/syson/frontend/src/results/RunResultsPanel.tsx index 97cf9918a..324d76c1d 100644 --- a/editors/syson/frontend/src/results/RunResultsPanel.tsx +++ b/editors/syson/frontend/src/results/RunResultsPanel.tsx @@ -14,7 +14,7 @@ import TableCell from '@mui/material/TableCell'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Typography from '@mui/material/Typography'; -import { GQLRunOperation, GQLOpenSysMLRunResult } from '../graphql/runWithOpenSysML'; +import { GQLRunOperation, GQLOpenSysMLRunOutcome, GQLOpenSysMLRunResult } from '../graphql/runWithOpenSysML'; export interface RunResultsPanelProps { result: GQLOpenSysMLRunResult; @@ -48,6 +48,23 @@ const verdictColor = (result: GQLOpenSysMLRunResult): 'success' | 'error' | 'war const verdictLabel = (result: GQLOpenSysMLRunResult): string => result.verdict ?? (result.ok ? 'completed' : 'failed'); +const outcomeKeys = (outcomes: GQLOpenSysMLRunOutcome[]): string[] => { + const seen = new Map(); + return outcomes.map((outcome) => { + const base = JSON.stringify(outcome); + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + return count === 0 ? base : `${base}#${count}`; + }); +}; + +const verdictIcon = (verdict: GQLOpenSysMLRunResult['verdicts'][number]) => { + if (!verdict.decided) { + return ; + } + return verdict.holds ? : ; +}; + const diagnosticIcon = (severity: string) => { switch (severity.toLowerCase()) { case 'error': @@ -61,6 +78,7 @@ const diagnosticIcon = (severity: string) => { export const RunResultsPanel = ({ result, onSelectElement }: RunResultsPanelProps) => { const status = verdictLabel(result); + const keys = outcomeKeys(result.outcomes); return (
@@ -113,7 +131,7 @@ export const RunResultsPanel = ({ result, onSelectElement }: RunResultsPanelProp
Outcomes ({result.outcomes.length}) {result.outcomes.map((outcome, index) => ( -
+
{outcome.outputs.length > 0 && ( outputs: {outcome.outputs.map((output) => `${output.name} = ${output.value}`).join(', ')} @@ -146,15 +164,7 @@ export const RunResultsPanel = ({ result, onSelectElement }: RunResultsPanelProp {result.verdicts.map((verdict, index) => { const content = ( <> - {verdict.subject} {verdict.kind}{' '} - {!verdict.decided ? ( - - ) : verdict.holds ? ( - - ) : ( - - )}{' '} - {verdict.detail ?? ''} + {verdict.subject} {verdict.kind} {verdictIcon(verdict)} {verdict.detail ?? ''} ); return ( @@ -164,15 +174,7 @@ export const RunResultsPanel = ({ result, onSelectElement }: RunResultsPanelProp onClick={() => verdict.siriusId && onSelectElement?.(verdict.siriusId)}> {verdict.subject} {verdict.kind} - - {!verdict.decided ? ( - - ) : verdict.holds ? ( - - ) : ( - - )} - + {verdictIcon(verdict)} {content} ); @@ -213,11 +215,12 @@ export const RunResultsPanel = ({ result, onSelectElement }: RunResultsPanelProp ) : ( {result.diagnostics.map((diagnostic, index) => { - const text = `${diagnostic.message}${ + const location = diagnostic.documentName && diagnostic.line !== null ? ` (${diagnostic.documentName}:${diagnostic.line})` - : '' - }${diagnostic.qualifiedName ? ` [${diagnostic.qualifiedName}]` : ''}`; + : ''; + const qualified = diagnostic.qualifiedName ? ` [${diagnostic.qualifiedName}]` : ''; + const text = `${diagnostic.message}${location}${qualified}`; const primary = ( <> {diagnosticIcon(diagnostic.severity)} {text} diff --git a/editors/syson/frontend/src/test/setup.ts b/editors/syson/frontend/src/test/setup.ts index 409d90ce7..188ed37fa 100644 --- a/editors/syson/frontend/src/test/setup.ts +++ b/editors/syson/frontend/src/test/setup.ts @@ -1,7 +1,6 @@ -import { expect } from 'vitest'; +import { afterEach, expect } from 'vitest'; import * as matchers from '@testing-library/jest-dom/matchers'; import { cleanup } from '@testing-library/react'; -import { afterEach } from 'vitest'; expect.extend(matchers); afterEach(() => cleanup()); diff --git a/internal/doc/docpdf/converter.go b/internal/doc/docpdf/converter.go index c9375c15f..8e47475d2 100644 --- a/internal/doc/docpdf/converter.go +++ b/internal/doc/docpdf/converter.go @@ -6,7 +6,7 @@ package docpdf import ( "context" - _ "embed" + _ "embed" // for the //go:embed directives below "fmt" "io" "os" diff --git a/internal/doc/docpdf/docpdf.go b/internal/doc/docpdf/docpdf.go index 6c1b51ee5..fa7f451b0 100644 --- a/internal/doc/docpdf/docpdf.go +++ b/internal/doc/docpdf/docpdf.go @@ -1,7 +1,7 @@ package docpdf import ( - _ "embed" + _ "embed" // for the //go:embed directives below "net/url" "os" "path/filepath" diff --git a/internal/doc/docpdf/docpdf_test.go b/internal/doc/docpdf/docpdf_test.go index f609b12f0..6acc7efd5 100644 --- a/internal/doc/docpdf/docpdf_test.go +++ b/internal/doc/docpdf/docpdf_test.go @@ -405,10 +405,10 @@ func TestPrintStylesheetContract(t *testing.T) { t.Fatal(err) } def := strings.Index(page, "@layer opensysml {") - print := strings.Index(page, "@layer opensysml-print {") + printLayer := strings.Index(page, "@layer opensysml-print {") reader := strings.Index(page, ".sysml-document { color: red }") - if def < 0 || print < def || reader < print { - t.Fatalf("stylesheet order default=%d print=%d reader=%d:\n%s", def, print, reader, page) + if def < 0 || printLayer < def || reader < printLayer { + t.Fatalf("stylesheet order default=%d printLayer=%d reader=%d:\n%s", def, printLayer, reader, page) } if strings.Count(page, "@layer opensysml-print {") != 1 { t.Fatal("print stylesheet inlined more than once") @@ -488,12 +488,12 @@ func TestPandocStylesheetSetsWideTablesLandscape(t *testing.T) { wide := "table:has(thead > tr > th:nth-child(7))" for _, want := range []string{ "@page wide { size: A4 landscape; }", - "body { page: main; }", + "body {\n font-family: serif;\n font-size: 11pt;\n line-height: 1.45;\n page: main;\n}", wide + " { page: wide; font-size: 9pt; }", "p:has(+ " + wide + "),", "p:has(.caption):has(+ p:has(+ " + wide + ")),", ":is(h1, h2, h3, h4, h5, h6):has(+ p:has(.caption):has(+ p:has(+ " + wide + "))) { page: wide; }", - "th { overflow-wrap: normal; }", + "th {\n background: #eeeeee;\n overflow-wrap: normal;\n}", "h1, h2, h3, h4, h5, h6 {\n font-family: sans-serif;\n line-height: 1.2;\n break-after: avoid;", "p:has(.caption) { break-after: avoid; page-break-after: avoid; }\np:has(+ table) { break-after: avoid; page-break-after: avoid; }", } { diff --git a/internal/doc/docpdf/pandoc.css b/internal/doc/docpdf/pandoc.css index 98afd5665..3a6e98397 100644 --- a/internal/doc/docpdf/pandoc.css +++ b/internal/doc/docpdf/pandoc.css @@ -16,6 +16,7 @@ body { font-family: serif; font-size: 11pt; line-height: 1.45; + page: main; } h1, h2, h3, h4, h5, h6 { @@ -64,8 +65,10 @@ th, td { overflow-wrap: anywhere; } -th { background: #eeeeee; } -th { overflow-wrap: normal; } +th { + background: #eeeeee; + overflow-wrap: normal; +} /* A caption and the paragraph leading into a table stay with what follows. */ p:has(.caption) { break-after: avoid; page-break-after: avoid; } @@ -74,7 +77,6 @@ p:has(+ table) { break-after: avoid; page-break-after: avoid; } /* A table of seven or more columns goes on a landscape page in smaller type with the heading, caption and group key ahead of it; :has() nests since cssselect2 misreads "+ p + table". */ @page wide { size: A4 landscape; } -body { page: main; } table:has(thead > tr > th:nth-child(7)) { page: wide; font-size: 9pt; } p:has(+ table:has(thead > tr > th:nth-child(7))), p:has(.caption):has(+ p:has(+ table:has(thead > tr > th:nth-child(7)))), diff --git a/internal/doc/docrender/html.go b/internal/doc/docrender/html.go index 8f9d38b8d..2febb983e 100644 --- a/internal/doc/docrender/html.go +++ b/internal/doc/docrender/html.go @@ -20,6 +20,12 @@ var defaultCSS string // DefaultStylesheet is the default document stylesheet: one cascade layer of // declarations, every value taken from a --sysml-* token on .sysml-document. + +// The note fragments the writer repeats. +const ( + spanClose = "" +) + func DefaultStylesheet() string { return defaultCSS } // themeFS holds the bundled themes, one .css each, written against the @@ -527,7 +533,7 @@ func (w *htmlWriter) writeValue(value queryexec.Value) { classes += " sysml-event" } w.b.WriteString("" + htmlText(valueText(value)) + "") + elementAttrs(value) + quantityAttrs(value) + ">" + htmlText(valueText(value)) + spanClose) } // quantityAttrs carries a quantity's magnitude and unit apart, so a theme or a @@ -717,9 +723,9 @@ func (w *htmlWriter) runHTML(run docir.TextRun) string { return "" + htmlText(run.Text()) + "" case docir.RunMath: if typeset, ok := w.opts.Math[inlineFormula(run.Text())]; ok { - return "" + typeset + "" + return "" + typeset + spanClose } - return "" + inlineMathHTML(run.Text()) + "" + return "" + inlineMathHTML(run.Text()) + spanClose case docir.RunLink: if target, ok := navigableURL(run.Target()); ok { return "" + htmlText(run.Text()) + "" diff --git a/internal/translate/migrate/activity.go b/internal/translate/migrate/activity.go index 705708b15..cdcf36af5 100644 --- a/internal/translate/migrate/activity.go +++ b/internal/translate/migrate/activity.go @@ -11,6 +11,17 @@ import ( // activityBody writes the nodes and edges of act as the body of def, the v2 // action def being written: act itself, or the operation whose method it is. + +// The note fragments the writer repeats. +const ( + siSeconds = " [SI::s]" + flowNote = "/* flow " + notWritten = " not written: " + itsInput = "its input " + noValueSince = " receives no value, since " + neverAssigns = " never assigns " +) + func (m *migration) activityBody(act, def *sysmlv1.Element) { keeping := m.keeping m.keeping = "" @@ -340,55 +351,83 @@ func (a *activity) write() { // An object flow into an action control flows also reach carries a value only, as // does one into a control or buffer node whose every outgoing edge does. func (a *activity) link() { - controlled := map[*sysmlv1.Element]bool{} - control := map[[2]*sysmlv1.Element]bool{} - outs := map[*sysmlv1.Element][]*sysmlv1.Element{} + controlled, control, outs := a.controlIndex() for _, e := range a.edges { src, tgt := a.m.model.Ref(e, "source"), a.m.model.Ref(e, "target") - if e.Type == "ControlFlow" && src != nil && tgt != nil { - controlled[tgt] = true - control[[2]*sysmlv1.Element{ownerNode(src), ownerNode(tgt)}] = true + if src == nil || tgt == nil || nodeKind(tgt) != nodePin || !controlled[tgt.Parent] { + continue } - if src != nil && tgt != nil { - outs[src] = append(outs[src], e) + if from := ownerNode(src); nodeKind(from) != nodeParam && from != tgt.Parent { + a.dataOnly[e] = true } } + a.propagateData(controlled, outs) + for _, n := range a.nodes { + if nodeKind(n) == nodeControl && len(outs[n]) == 0 { + a.sink[n] = true + } + } + a.linkEdges(control) +} + +// controlIndex marks the nodes a control flow reaches, the node pairs one +// joins, and every node's outgoing edges. +func (a *activity) controlIndex() (controlled map[*sysmlv1.Element]bool, control map[[2]*sysmlv1.Element]bool, outs map[*sysmlv1.Element][]*sysmlv1.Element) { + controlled = map[*sysmlv1.Element]bool{} + control = map[[2]*sysmlv1.Element]bool{} + outs = map[*sysmlv1.Element][]*sysmlv1.Element{} for _, e := range a.edges { src, tgt := a.m.model.Ref(e, "source"), a.m.model.Ref(e, "target") - if src == nil || tgt == nil || nodeKind(tgt) != nodePin || !controlled[tgt.Parent] { - continue + if e.Type == "ControlFlow" && src != nil && tgt != nil { + controlled[tgt] = true + control[[2]*sysmlv1.Element{ownerNode(src), ownerNode(tgt)}] = true } - if from := ownerNode(src); nodeKind(from) != nodeParam && from != tgt.Parent { - a.dataOnly[e] = true + if src != nil && tgt != nil { + outs[src] = append(outs[src], e) } } + return controlled, control, outs +} + +// propagateData marks as data nodes the control and buffer nodes whose every +// outgoing edge carries data only, feeding their incoming edges the same way, +// to a fixpoint. +func (a *activity) propagateData(controlled map[*sysmlv1.Element]bool, outs map[*sysmlv1.Element][]*sysmlv1.Element) { for changed := true; changed; { changed = false for _, n := range a.nodes { - if k := nodeKind(n); k != nodeControl && k != nodeBuffer || a.dataNode[n] || controlled[n] || len(outs[n]) == 0 { - continue - } - routes := true - for _, e := range outs[n] { - routes = routes && a.dataOnly[e] - } - if !routes { - continue - } - a.dataNode[n] = true - changed = true - for _, e := range a.edges { - if a.m.model.Ref(e, "target") == n { - a.dataOnly[e] = true - } + if a.promoteDataNode(n, controlled, outs) { + changed = true } } } - for _, n := range a.nodes { - if nodeKind(n) == nodeControl && len(outs[n]) == 0 { - a.sink[n] = true +} + +// promoteDataNode marks n a data node and its incoming edges data-only when n is +// an uncontrolled control or buffer node whose outgoing edges are all data-only. +func (a *activity) promoteDataNode(n *sysmlv1.Element, controlled map[*sysmlv1.Element]bool, outs map[*sysmlv1.Element][]*sysmlv1.Element) bool { + if k := nodeKind(n); k != nodeControl && k != nodeBuffer || a.dataNode[n] || controlled[n] || len(outs[n]) == 0 { + return false + } + routes := true + for _, e := range outs[n] { + routes = routes && a.dataOnly[e] + } + if !routes { + return false + } + a.dataNode[n] = true + for _, e := range a.edges { + if a.m.model.Ref(e, "target") == n { + a.dataOnly[e] = true } } + return true +} + +// linkEdges records the successions between nodes: every eligible edge into +// succ, the first edge between two nodes also into next and prev. +func (a *activity) linkEdges(control map[[2]*sysmlv1.Element]bool) { linked := map[[2]*sysmlv1.Element]bool{} for _, e := range a.edges { src, tgt := a.m.model.Ref(e, "source"), a.m.model.Ref(e, "target") @@ -527,6 +566,40 @@ func (a *activity) endpointIn(n *sysmlv1.Element) string { return a.entry[n] } +// startTargets lists the nodes start successions lead to: the initial nodes' +// targets, then every node no edge leads to (a starved one only marked, never a +// target). seen marks either. +func (a *activity) startTargets() (targets []*sysmlv1.Element, seen map[*sysmlv1.Element]bool) { + seen = map[*sysmlv1.Element]bool{} + for _, n := range a.nodes { + if nodeKind(n) != nodeInitial { + continue + } + for _, t := range a.next[n] { + if !seen[t] { + seen[t] = true + targets = append(targets, t) + } + } + } + for _, n := range a.nodes { + k := nodeKind(n) + if k != nodeAction && k != nodeControl && k != nodeBuffer || len(a.prev[n]) > 0 || seen[n] || a.dataNode[n] || a.sink[n] { + continue + } + if k == nodeControl && n.Type != "ForkNode" { + // A join or merge nothing leads to would never fire. + continue + } + if a.starved[n] == nil { + targets = append(targets, n) + a.m.add(n, Approximated, "", "no edge leads to the node, so it starts with the activity") + } + seen[n] = true + } + return targets, seen +} + // unwritableEdge reports an edge into a node no succession may lead to. func (a *activity) unwritableEdge(e *sysmlv1.Element) { a.m.unmapped(e, "the edge leads to "+describe(ownerNode(a.m.model.Ref(e, "target")))+", "+a.unwritableTarget(e)) @@ -552,36 +625,25 @@ func (a *activity) unwritableTarget(e *sysmlv1.Element) string { // and to every node no edge leads to, forked when several, after the initial // nodes' clock stamps and the activity's wait. func (a *activity) startSuccessions() { - var targets []*sysmlv1.Element - seen := map[*sysmlv1.Element]bool{} - for _, n := range a.nodes { - if nodeKind(n) != nodeInitial { - continue - } - for _, t := range a.next[n] { - if !seen[t] { - seen[t] = true - targets = append(targets, t) - } - } + targets, _ := a.startTargets() + from := a.startPrologue() + if len(targets) > 1 { + f := a.fresh("fork") + a.m.w.line(firstKw + from + thenKw + writeName(f) + ";") + a.m.w.line("fork " + writeName(f) + ";") + from = writeName(f) } - for _, n := range a.nodes { - k := nodeKind(n) - if k != nodeAction && k != nodeControl && k != nodeBuffer || len(a.prev[n]) > 0 || seen[n] || a.dataNode[n] || a.sink[n] { - continue - } - if k == nodeControl && n.Type != "ForkNode" { - // A join or merge nothing leads to would never fire. - continue - } - if a.starved[n] != nil { - seen[n] = true - continue + for _, t := range targets { + if to := a.endpointIn(t); to != "" { + a.m.w.line(firstKw + from + thenKw + to + ";") } - seen[n] = true - targets = append(targets, n) - a.m.add(n, Approximated, "", "no edge leads to the node, so it starts with the activity") } + a.checkInitialSuccessions() +} + +// startPrologue writes what precedes the targets: the kept action, the initial +// nodes' clock stamps and the activity's wait, returning the name they leave from. +func (a *activity) startPrologue() string { from := "start" if a.keeping != "" { name := a.fresh("keep") @@ -591,8 +653,8 @@ func (a *activity) startSuccessions() { } for _, n := range a.nodes { if s, ok := a.before[n]; ok && nodeKind(n) == nodeInitial { - a.m.w.line("first " + from + " then " + s.name + ";") - a.m.w.block("action "+s.name, func() { a.m.w.lines(s.lines) }) + a.m.w.line(firstKw + from + thenKw + s.name + ";") + a.m.w.block(actionKw+s.name, func() { a.m.w.lines(s.lines) }) from = s.name } } @@ -602,17 +664,12 @@ func (a *activity) startSuccessions() { a.m.w.line(actionKw + writeName(name) + " accept after " + w + ";") from = writeName(name) } - if len(targets) > 1 { - f := a.fresh("fork") - a.m.w.line(firstKw + from + thenKw + writeName(f) + ";") - a.m.w.line("fork " + writeName(f) + ";") - from = writeName(f) - } - for _, t := range targets { - if to := a.endpointIn(t); to != "" { - a.m.w.line(firstKw + from + thenKw + to + ";") - } - } + return from +} + +// checkInitialSuccessions reports an initial node's edge whose target no +// succession may lead to. +func (a *activity) checkInitialSuccessions() { for _, n := range a.nodes { if nodeKind(n) != nodeInitial { continue @@ -695,7 +752,7 @@ func (a *activity) waitFor(e *sysmlv1.Element) (string, bool) { hi, hok, hnote := a.m.durationExpr(a.m.model.Ref(spec, "max"), e) if bound, bnote, ok := a.m.singleValue(spec, lo, lok, hok); ok { a.m.add(dc, Approximated, a.m.v2Name(a.def), joinNotes(bnote, "so the wait is a fixed "+bound+" s before "+describe(e))) - return bound + " [SI::s]", true + return bound + siSeconds, true } if !lok || !hok { a.unmappedWait(dc, e, a.m.openInterval(spec, lo, lok, lnote, hi, hok, hnote)) @@ -717,7 +774,7 @@ func (a *activity) waitFor(e *sysmlv1.Element) (string, bool) { note = joinNotes(note, "written as a wait drawn uniformly over ["+lo+", "+hi+"] s before "+describe(e)+"; a tool's fixed min or max mode is a run setting, not the model's") } a.m.add(dc, Approximated, a.m.v2Name(a.def), note) - return expr + " [SI::s]", true + return expr + siSeconds, true } func (a *activity) unmappedWait(dc, e *sysmlv1.Element, note string) { @@ -737,8 +794,8 @@ func (a *activity) successions(n *sysmlv1.Element) { } from := writeName(a.name(n, baseName(n))) if s, ok := a.after[n]; ok { - a.m.w.line("first " + from + " then " + s.name + ";") - a.m.w.block("action "+s.name, func() { a.m.w.lines(s.lines) }) + a.m.w.line(firstKw + from + thenKw + s.name + ";") + a.m.w.block(actionKw+s.name, func() { a.m.w.lines(s.lines) }) from = s.name } outs := a.succ[n] @@ -1152,8 +1209,8 @@ func (a *activity) leadIn(n *sysmlv1.Element, into string) { into = w.name } if s, ok := a.before[n]; ok { - a.m.w.block("action "+s.name, func() { a.m.w.lines(s.lines) }) - a.m.w.line("first " + s.name + " then " + into + ";") + a.m.w.block(actionKw+s.name, func() { a.m.w.lines(s.lines) }) + a.m.w.line(firstKw + s.name + thenKw + into + ";") into = s.name } if j, ok := a.joins[n]; ok { @@ -1470,10 +1527,10 @@ func (a *activity) objectFlowTarget(e, tgt *sysmlv1.Element) { func (a *activity) objectFlowSource(e, s, tgt *sysmlv1.Element, to string) { if callee, p := a.calleeOutput(s); callee != nil && p == nil { why := "the pin " + describe(s) + " of " + describe(s.Parent) + " stands for no out parameter of the called " + qualifiedName(callee) + ", so it carries no value" - a.m.w.line("/* flow " + describe(s) + " to " + to + " not written: " + why + " */") + a.m.w.line(flowNote + describe(s) + " to " + to + notWritten + why + " */") a.m.add(e, Approximated, "", "the flow is kept as a comment: "+why+", and none reaches "+describe(tgt)) if nodeKind(tgt) == nodePin { - a.m.add(tgt.Parent, Approximated, "", "its input "+to+" receives no value, since "+why) + a.m.add(tgt.Parent, Approximated, "", itsInput+to+noValueSince+why) } return } @@ -1488,41 +1545,41 @@ func (a *activity) objectFlowSource(e, s, tgt *sysmlv1.Element, to string) { } a.written[[2]*sysmlv1.Element{s, tgt}] = true if nodeKind(s) == nodeParam && a.m.unvalued[a.m.model.Ref(s, "parameter")] { - a.m.w.line("/* flow " + from + " to " + to + " not written: the parameter " + from + " takes no value */") + a.m.w.line(flowNote + from + " to " + to + " not written: the parameter " + from + " takes no value */") a.m.add(e, Approximated, "", "the flow is kept as a comment: its source, the parameter "+from+", takes no value, so none reaches "+describe(tgt)) if nodeKind(tgt) == nodePin { - a.m.add(tgt.Parent, Approximated, "", "its input "+to+" receives no value, since the parameter "+from+" takes none") + a.m.add(tgt.Parent, Approximated, "", itsInput+to+" receives no value, since the parameter "+from+" takes none") } return } if a.inert[s.Parent] { - a.m.w.line("/* flow " + from + " to " + to + " not written: " + describe(s.Parent) + " is not migrated and produces no value */") + a.m.w.line(flowNote + from + " to " + to + notWritten + describe(s.Parent) + " is not migrated and produces no value */") a.m.add(e, Approximated, "", "the flow is kept as a comment: its source "+describe(s.Parent)+" is not migrated, so no value reaches "+describe(s)) if nodeKind(tgt) == nodePin { - a.m.add(tgt.Parent, Approximated, "", "its input "+to+" receives no value, since "+describe(s.Parent)+" is not migrated; the action cannot be performed until one is bound") + a.m.add(tgt.Parent, Approximated, "", itsInput+to+noValueSince+describe(s.Parent)+" is not migrated; the action cannot be performed until one is bound") } return } if a.unassigned(s) { - a.m.w.line("/* flow " + from + " to " + to + " not written: the body of " + describe(s.Parent) + " never assigns " + from + " */") - a.m.add(e, Approximated, "", "the flow is kept as a comment: the body of "+describe(s.Parent)+" never assigns "+describe(s)+", so no value leaves it") + a.m.w.line(flowNote + from + " to " + to + " not written: the body of " + describe(s.Parent) + neverAssigns + from + " */") + a.m.add(e, Approximated, "", "the flow is kept as a comment: the body of "+describe(s.Parent)+neverAssigns+describe(s)+", so no value leaves it") if nodeKind(tgt) == nodePin { - a.m.add(tgt.Parent, Approximated, "", "its input "+to+" receives no value, since the body of "+describe(s.Parent)+" never assigns "+from+"; the action cannot be performed until one is bound") + a.m.add(tgt.Parent, Approximated, "", itsInput+to+" receives no value, since the body of "+describe(s.Parent)+neverAssigns+from+"; the action cannot be performed until one is bound") } return } if callee, p := a.calleeOutput(s); p != nil && a.m.dryOutputs(callee)[p] { why := "nothing in the called " + qualifiedName(callee) + " gives its parameter " + a.m.nameFor(p) + " a value" - a.m.w.line("/* flow " + from + " to " + to + " not written: " + why + " */") + a.m.w.line(flowNote + from + " to " + to + notWritten + why + " */") a.m.add(e, Approximated, "", "the flow is kept as a comment: "+why+", so none reaches "+describe(tgt)) if nodeKind(tgt) == nodePin { - a.m.add(tgt.Parent, Approximated, "", "its input "+to+" receives no value, since "+why) + a.m.add(tgt.Parent, Approximated, "", itsInput+to+noValueSince+why) } return } st, tt := a.endType(s), a.endType(tgt) if !a.m.conform(st, tt) { - a.m.w.line("/* flow " + from + " to " + to + " not written: " + qualifiedName(st) + " and " + qualifiedName(tt) + " do not conform */") + a.m.w.line(flowNote + from + " to " + to + notWritten + qualifiedName(st) + " and " + qualifiedName(tt) + " do not conform */") a.m.add(e, Approximated, "", "the flow is kept as a comment: its ends are typed by "+qualifiedName(st)+" and "+qualifiedName(tt)+", which do not conform") return } @@ -1557,7 +1614,7 @@ func (a *activity) callBehavior(n *sysmlv1.Element, name string) { a.m.w.line("perform action " + name + " ::> " + usage + ";") a.m.add(n, Mapped, name, "performed by "+l.expr+", the object its swimlane represents, as its usage "+usage) } else { - a.m.w.line("action " + name + " : " + a.m.ref(b, a.def) + ";") + a.m.w.line(actionKw + name + " : " + a.m.ref(b, a.def) + ";") if owner, here := classifierOf(b), a.selfType(); owner != nil && owner != here && (here == nil || !a.m.inherits(here, owner)) { note = "the behavior belongs to " + qualifiedName(owner) + " and runs here in the caller's context" } @@ -2061,7 +2118,7 @@ func (m *migration) acceptClause(ev, scope *sysmlv1.Element, payload string) (cl if !ok { return "", "the time event's time is not written: " + note, false } - return "accept after " + d + " [SI::s]", note, true + return "accept after " + d + siSeconds, note, true case "ChangeEvent": expr, ok, note := m.behaviorValue(firstOwned(ev, "changeExpression"), scope) if !ok { diff --git a/internal/translate/migrate/arguments.go b/internal/translate/migrate/arguments.go index 9f2ae1b70..bd760a77a 100644 --- a/internal/translate/migrate/arguments.go +++ b/internal/translate/migrate/arguments.go @@ -12,82 +12,110 @@ import ( func (a *activity) refusal(n *sysmlv1.Element) (why string, v Verdict, refused bool) { switch n.Type { case "ValueSpecificationAction": - v := firstOwned(n, "value") - if v == nil { - return "the action has no value", Unmapped, true - } - var ok bool - var note string - if results := n.Owned("result"); len(results) == 0 { - _, ok, note = a.m.behaviorValue(v, n) - } else { - _, ok, note = a.m.typedBehaviorValue(v, results[0], n) - } - if !ok { - return "the value " + describeValue(v) + " is not written: " + note, Approximated, true - } + return a.valueActionRefusal(n) case "CallBehaviorAction": - b := a.m.model.Ref(n, "behavior") - if b == nil { - if a.leafStep(n) { - return "", Mapped, false - } - return a.unbehaved(n), Unmapped, true - } - if op := a.m.methodOf[b]; op != nil { - b = op - } - if !a.m.written(b) { - return "the behavior " + qualifiedName(b) + " it calls has no v2 declaration", Unmapped, true - } - switch cat, _ := a.m.classify(b); cat { - case catCalcDef: + return a.behaviorCallRefusal(n) + case "CallOperationAction": + return a.operationCallRefusal(n) + case "SendSignalAction": + return a.sendRefusal(n) + } + return "", Mapped, false +} + +// valueActionRefusal reports whether a value specification action's value +// cannot be written in v2. +func (a *activity) valueActionRefusal(n *sysmlv1.Element) (why string, v Verdict, refused bool) { + val := firstOwned(n, "value") + if val == nil { + return "the action has no value", Unmapped, true + } + var ok bool + var note string + if results := n.Owned("result"); len(results) == 0 { + _, ok, note = a.m.behaviorValue(val, n) + } else { + _, ok, note = a.m.typedBehaviorValue(val, results[0], n) + } + if !ok { + return "the value " + describeValue(val) + " is not written: " + note, Approximated, true + } + return "", Mapped, false +} + +// behaviorCallRefusal reports whether a call behavior action names a behavior +// no action can call, with unbound arguments or no context to bind. +func (a *activity) behaviorCallRefusal(n *sysmlv1.Element) (why string, v Verdict, refused bool) { + b := a.m.model.Ref(n, "behavior") + if b == nil { + if a.leafStep(n) { return "", Mapped, false - case catActionDef: - default: - return "the behavior " + qualifiedName(b) + " is written as a " + cat.keyword() + ", which an action cannot call", Unmapped, true - } - if p, why := a.unarguedParameter(inputPins(n), b); p != nil { - return why, Approximated, true } - if c := a.m.contextOf(b); c != nil { - if expr, cnote := a.callContext(n, c); expr == "" { - return a.uncontexted(b, cnote), Approximated, true - } - } - case "CallOperationAction": - op := a.m.model.Ref(n, "operation") - if op == nil { - return joinNotes(a.m.dangling(n, "operation"), "the action calls no operation"), Unmapped, true + return a.unbehaved(n), Unmapped, true + } + if op := a.m.methodOf[b]; op != nil { + b = op + } + if !a.m.written(b) { + return "the behavior " + qualifiedName(b) + " it calls has no v2 declaration", Unmapped, true + } + switch cat, _ := a.m.classify(b); cat { + case catCalcDef: + return "", Mapped, false + case catActionDef: + default: + return "the behavior " + qualifiedName(b) + " is written as a " + cat.keyword() + ", which an action cannot call", Unmapped, true + } + if p, why := a.unarguedParameter(inputPins(n), b); p != nil { + return why, Approximated, true + } + if c := a.m.contextOf(b); c != nil { + if expr, cnote := a.callContext(n, c); expr == "" { + return a.uncontexted(b, cnote), Approximated, true } - if !a.m.written(op) { - return "the operation " + qualifiedName(op) + " it calls has no v2 declaration", Unmapped, true + } + return "", Mapped, false +} + +// operationCallRefusal reports whether a call operation action's operation is +// unwritten or an argument pin has no parameter. +func (a *activity) operationCallRefusal(n *sysmlv1.Element) (why string, v Verdict, refused bool) { + op := a.m.model.Ref(n, "operation") + if op == nil { + return joinNotes(a.m.dangling(n, "operation"), "the action calls no operation"), Unmapped, true + } + if !a.m.written(op) { + return "the operation " + qualifiedName(op) + " it calls has no v2 declaration", Unmapped, true + } + t := firstOwned(n, "target") + ins := slices.DeleteFunc(inputPins(n), func(p *sysmlv1.Element) bool { return p == t }) + if p, why := a.unarguedParameter(ins, op); p != nil { + return why, Approximated, true + } + return "", Mapped, false +} + +// sendRefusal reports whether a send signal action's arguments cannot take the +// signal's attributes. +func (a *activity) sendRefusal(n *sysmlv1.Element) (why string, v Verdict, refused bool) { + sig := a.m.model.Ref(n, "signal") + if sig == nil || !a.m.written(sig) { + return "", Mapped, false + } + attrs := a.m.signalAttributes(sig) + pins := n.Owned("argument") + for i, attr := range attrs { + if !requiresValue(attr) { + continue } - t := firstOwned(n, "target") - ins := slices.DeleteFunc(inputPins(n), func(p *sysmlv1.Element) bool { return p == t }) - if p, why := a.unarguedParameter(ins, op); p != nil { - return why, Approximated, true + if i >= len(pins) { + return a.unargued(sig, attr), Approximated, true } - case "SendSignalAction": - sig := a.m.model.Ref(n, "signal") - if sig == nil || !a.m.written(sig) { - return "", Mapped, false + if dry := a.valueless(pins[i]); dry != nil { + return a.dryArgument(pins[i], sig, attr, dry), Approximated, true } - attrs := a.m.signalAttributes(sig) - pins := n.Owned("argument") - for i, attr := range attrs { - if !requiresValue(attr) { - continue - } - if i >= len(pins) { - return a.unargued(sig, attr), Approximated, true - } - if dry := a.valueless(pins[i]); dry != nil { - return a.dryArgument(pins[i], sig, attr, dry), Approximated, true - } - if pt, at := a.misfit(pins[i], attr); pt != nil { - return a.misfitArgument(pins[i], sig, attr, pt, at), Approximated, true - } + if pt, at := a.misfit(pins[i], attr); pt != nil { + return a.misfitArgument(pins[i], sig, attr, pt, at), Approximated, true } } return "", Mapped, false diff --git a/internal/translate/migrate/arrivals.go b/internal/translate/migrate/arrivals.go index 5e6911f09..d0c977897 100644 --- a/internal/translate/migrate/arrivals.go +++ b/internal/translate/migrate/arrivals.go @@ -32,6 +32,14 @@ func (m *migration) arrivalIndex() *arrivals { inward: map[*sysmlv1.Element][]*sysmlv1.Element{}, } m.arrived = idx + conveyed := m.portDelegations(idx) + idx.propagate(m, conveyed) + return idx +} + +// portDelegations fills idx's peer and delegation edges from the port connectors and +// lists, as port and signal pairs, the signals their item flows convey to an end port. +func (m *migration) portDelegations(idx *arrivals) [][2]*sysmlv1.Element { peers, outward, inward := idx.peers, idx.outward, idx.inward var conveyed [][2]*sysmlv1.Element for _, c := range m.connectors { @@ -60,48 +68,61 @@ func (m *migration) arrivalIndex() *arrivals { peers[p1] = append(peers[p1], p0) } } - sent := map[*sysmlv1.Element]map[*sysmlv1.Element]bool{} - var reach func(into map[*sysmlv1.Element]map[*sysmlv1.Element]bool, p, sig *sysmlv1.Element) - reach = func(into map[*sysmlv1.Element]map[*sysmlv1.Element]bool, p, sig *sysmlv1.Element) { - if into[p][sig] { - return - } - if into[p] == nil { - into[p] = map[*sysmlv1.Element]bool{} - } - into[p][sig] = true - for _, q := range inward[p] { - reach(into, q, sig) - } - } - var leave func(p, sig *sysmlv1.Element) - leave = func(p, sig *sysmlv1.Element) { - if sent[p][sig] { - return - } - if sent[p] == nil { - sent[p] = map[*sysmlv1.Element]bool{} - } - sent[p][sig] = true - for _, q := range peers[p] { - reach(idx.at, q, sig) - } - for _, q := range outward[p] { - leave(q, sig) - } - } + return conveyed +} + +// propagate spreads the sends and declared arrivals through idx's delegations: a send +// reaches a peer's arrival set and travels outward; declared and conveyed signals are carried. +func (idx *arrivals) propagate(m *migration, conveyed [][2]*sysmlv1.Element) { + w := &arrivalWalk{idx: idx, sent: map[*sysmlv1.Element]map[*sysmlv1.Element]bool{}} for _, send := range m.portSends { - leave(m.model.Ref(send, "onPort"), m.model.Ref(send, "signal")) + w.leave(m.model.Ref(send, "onPort"), m.model.Ref(send, "signal")) } for _, p := range m.ports { for _, sig := range m.declaredArrivals(p) { - reach(idx.carried, p, sig) + w.reach(idx.carried, p, sig) } } for _, pair := range conveyed { - reach(idx.carried, pair[0], pair[1]) + w.reach(idx.carried, pair[0], pair[1]) + } +} + +// arrivalWalk carries the send set while a propagation walks the delegations. +type arrivalWalk struct { + idx *arrivals + sent map[*sysmlv1.Element]map[*sysmlv1.Element]bool +} + +// reach records sig arriving at p and follows inward delegations. +func (w *arrivalWalk) reach(into map[*sysmlv1.Element]map[*sysmlv1.Element]bool, p, sig *sysmlv1.Element) { + if into[p][sig] { + return + } + if into[p] == nil { + into[p] = map[*sysmlv1.Element]bool{} + } + into[p][sig] = true + for _, q := range w.idx.inward[p] { + w.reach(into, q, sig) + } +} + +// leave records sig sent from p, reaches its peers, and travels outward. +func (w *arrivalWalk) leave(p, sig *sysmlv1.Element) { + if w.sent[p][sig] { + return + } + if w.sent[p] == nil { + w.sent[p] = map[*sysmlv1.Element]bool{} + } + w.sent[p][sig] = true + for _, q := range w.idx.peers[p] { + w.reach(w.idx.at, q, sig) + } + for _, q := range w.idx.outward[p] { + w.leave(q, sig) } - return idx } // conveyedTo lists, as port and signal, the signals the item flows connector c realizes @@ -135,37 +156,60 @@ func (m *migration) declaredArrivals(p *sysmlv1.Element) []*sysmlv1.Element { return } seen[t] = true - for _, a := range t.Owned("ownedAttribute") { - fp := stereo(a, "FlowProperty") - if fp == nil { - continue - } - dir := fp.Tag("direction") - if dir == "in" && conjugated || dir == "out" && !conjugated { - continue - } - if sig := m.model.Ref(a, "type"); sig != nil && sig.Type == "Signal" { - out = append(out, sig) - } - } + out = append(out, m.flowSignals(t, conjugated)...) if !conjugated { - for _, r := range t.Owned("ownedReception") { - if sig := m.model.Ref(r, "signal"); sig != nil { - out = append(out, sig) - } - } - for _, ir := range t.Owned("interfaceRealization") { - walk(m.model.Ref(ir, "contract")) - } - } - for _, g := range t.Owned("generalization") { - walk(m.model.Ref(g, "general")) + out = append(out, m.receptionSignals(t)...) } + m.arrivalSupertypes(t, conjugated, walk) } walk(m.model.Ref(p, "type")) return out } +// receptionSignals lists the signals of t's owned receptions. +func (m *migration) receptionSignals(t *sysmlv1.Element) []*sysmlv1.Element { + var out []*sysmlv1.Element + for _, r := range t.Owned("ownedReception") { + if sig := m.model.Ref(r, "signal"); sig != nil { + out = append(out, sig) + } + } + return out +} + +// arrivalSupertypes walks t's realized interface contracts — unconjugated only — +// and its general classifiers. +func (m *migration) arrivalSupertypes(t *sysmlv1.Element, conjugated bool, walk func(*sysmlv1.Element)) { + if !conjugated { + for _, ir := range t.Owned("interfaceRealization") { + walk(m.model.Ref(ir, "contract")) + } + } + for _, g := range t.Owned("generalization") { + walk(m.model.Ref(g, "general")) + } +} + +// flowSignals lists the signal types of t's flow properties that arrive when conjugation +// reads as given: flowing in, or out when conjugated. +func (m *migration) flowSignals(t *sysmlv1.Element, conjugated bool) []*sysmlv1.Element { + var out []*sysmlv1.Element + for _, a := range t.Owned("ownedAttribute") { + fp := stereo(a, "FlowProperty") + if fp == nil { + continue + } + dir := fp.Tag("direction") + if dir == "in" && conjugated || dir == "out" && !conjugated { + continue + } + if sig := m.model.Ref(a, "type"); sig != nil && sig.Type == "Signal" { + out = append(out, sig) + } + } + return out +} + // arrivesAt reports whether sig, or a signal a trigger for sig also accepts, arrives at port p: // the document sends it or a special there, or the port carries it, a general or a special. func (m *migration) arrivesAt(p, sig *sysmlv1.Element) bool { diff --git a/internal/translate/migrate/behavior.go b/internal/translate/migrate/behavior.go index ff0d43496..10e80be86 100644 --- a/internal/translate/migrate/behavior.go +++ b/internal/translate/migrate/behavior.go @@ -18,6 +18,12 @@ const ( // classifyBehavior decides the v2 declaration a UML behavior becomes: action def, // state def, calc def for an expression body, or a scenario action def for an interaction. + +// The note fragments the writer repeats. +const ( + methodNote = "the method " +) + func (m *migration) classifyBehavior(e *sysmlv1.Element) (category, string) { switch e.Type { case "Activity": @@ -146,7 +152,7 @@ func (m *migration) operationFeature(op *sysmlv1.Element) { return } usage := m.operationUsage(op) - m.w.line("action " + writeName(usage) + " : " + m.ref(op, op.Parent) + ";") + m.w.line(actionKw + writeName(usage) + " : " + m.ref(op, op.Parent) + ";") m.add(op, Mapped, "", "its owner's usage "+usage+" performs it, as a call on an object does") } @@ -741,8 +747,8 @@ func (m *migration) opaqueBehaviorBody(e, scope *sysmlv1.Element) { } name := m.freshName(scope, "body") m.w.line("first start then " + writeName(name) + ";") - m.w.block("action "+writeName(name), func() { m.w.lines(lines) }) - m.w.line("first " + writeName(name) + " then done;") + m.w.block(actionKw+writeName(name), func() { m.w.lines(lines) }) + m.w.line(firstKw + writeName(name) + " then done;") if note != "" { m.downgrade(e, note) } @@ -790,7 +796,7 @@ func (m *migration) operationBody(op *sysmlv1.Element) { m.downgrade(op, "the method refers to nothing in the document; the operation is written abstract") } case method.Parent != op.Parent: - m.downgrade(op, "the method "+qualifiedName(method)+" is owned elsewhere and written there; the operation is written abstract") + m.downgrade(op, methodNote+qualifiedName(method)+" is owned elsewhere and written there; the operation is written abstract") case method.Type == "Activity": m.activityBody(method, op) case method.Type == "OpaqueBehavior" || method.Type == "FunctionBehavior": @@ -909,8 +915,8 @@ func (m *migration) receptionLoop(r *sysmlv1.Element, route *receptionRoute, fro } trig := writeName(freshIn(route.used, "receive"+suffix)) payload := writeName(freshIn(route.used, lowerFirst(m.nameFor(route.sig))+suffix)) - m.w.line("first " + from + " then " + trig + ";") - m.w.line("action " + trig + " accept " + payload + " : " + m.ref(route.sig, route.owner) + via + ";") + m.w.line(firstKw + from + thenKw + trig + ";") + m.w.line(actionKw + trig + " accept " + payload + " : " + m.ref(route.sig, route.owner) + via + ";") last := trig method := route.method switch { @@ -921,7 +927,7 @@ func (m *migration) receptionLoop(r *sysmlv1.Element, route *receptionRoute, fro route.note = "the reception has no method, so it only accepts the signal" } case !m.written(method) || !(method.Type == "Operation" || hasActionForm(method)): - route.note = "the method " + qualifiedName(method) + " has no action def to perform; the reception only accepts the signal" + route.note = methodNote + qualifiedName(method) + " has no action def to perform; the reception only accepts the signal" default: args, refusal := m.receptionArguments(method, route.sig, payload) if refusal != "" { @@ -930,15 +936,15 @@ func (m *migration) receptionLoop(r *sysmlv1.Element, route *receptionRoute, fro } run := writeName(freshIn(route.used, "run"+suffix)) last, route.performed = run, true - m.w.line("first " + trig + " then " + run + ";") - decl := "action " + run + " : " + m.ref(method, route.owner) + m.w.line(firstKw + trig + thenKw + run + ";") + decl := actionKw + run + " : " + m.ref(method, route.owner) if len(args) == 0 { m.w.line(decl + ";") } else { m.w.line(decl + " { " + strings.Join(args, "; ") + "; }") } } - m.w.line("first " + last + " then " + trig + ";") + m.w.line(firstKw + last + thenKw + trig + ";") } // receptionComment writes a reception whose signal has no v2 declaration as a @@ -998,7 +1004,7 @@ func (m *migration) receptionArguments(method, sig *sysmlv1.Element, payload str a := attrs[name] if name == "" || a == nil { if requiresValue(p) { - refusal = joinNotes(refusal, "the method "+qualifiedName(method)+"'s parameter "+m.nameFor(p)+" must hold a value that no attribute of the signal supplies") + refusal = joinNotes(refusal, methodNote+qualifiedName(method)+"'s parameter "+m.nameFor(p)+" must hold a value that no attribute of the signal supplies") } continue } diff --git a/internal/translate/migrate/call_port.go b/internal/translate/migrate/call_port.go index 43d97e1ee..c269f5951 100644 --- a/internal/translate/migrate/call_port.go +++ b/internal/translate/migrate/call_port.go @@ -10,6 +10,12 @@ import ( // portReceiver writes the operation usage a call over port performs: the part // the caller's connectors join to its own port, or the target object's port when // the port is the target's. The note says why the call is not written that way. + +// The note fragments the writer repeats. +const ( + performsUsage = "the call performs the usage " +) + func (a *activity) portReceiver(port, t, op *sysmlv1.Element) (receiver, note string, ok bool) { if !a.m.written(port) { return "", "the call runs in the caller's context: the port " + qualifiedName(port) + " it goes through has no v2 declaration", false @@ -20,7 +26,7 @@ func (a *activity) portReceiver(port, t, op *sysmlv1.Element) (receiver, note st if why != "" { return "", "the call runs in the caller's context: " + why, false } - return a.on(a.self(), path+"."+usage), "the call performs the usage " + usage + " of the part connected to the port " + a.m.nameFor(port), true + return a.on(a.self(), path+"."+usage), performsUsage + usage + " of the part connected to the port " + a.m.nameFor(port), true } obj, typ, found := a.objectOf(t) switch { @@ -33,7 +39,7 @@ func (a *activity) portReceiver(port, t, op *sysmlv1.Element) (receiver, note st } path := a.on(obj, writeName(a.m.nameFor(port))) if pt := a.m.model.Ref(port, "type"); pt != nil && a.m.hasFeature(pt, op) { - return path + "." + usage, "the call performs the usage " + usage + " of the target's port " + path, true + return path + "." + usage, performsUsage + usage + " of the target's port " + path, true } if !a.m.hasFeature(typ, op) { return "", "the call runs in the caller's context: neither the target " + obj + " nor its port " + a.m.nameFor(port) + " has the operation " + a.m.nameOf(op), false @@ -41,7 +47,7 @@ func (a *activity) portReceiver(port, t, op *sysmlv1.Element) (receiver, note st if obj == a.self() { return a.on(obj, usage), "the target is " + obj + ", whose usage " + usage + " the call performs; its port " + a.m.nameFor(port) + " is not written, as a v2 perform names the operation on the object", true } - return a.on(obj, usage), "the call performs the usage " + usage + " of the target " + obj + "; its port " + a.m.nameFor(port) + " is not written, as a v2 perform names the operation on the object", true + return a.on(obj, usage), performsUsage + usage + " of the target " + obj + "; its port " + a.m.nameFor(port) + " is not written, as a v2 perform names the operation on the object", true } // connectedReceiver follows the connectors of classifier c from its port to the diff --git a/internal/translate/migrate/carriers.go b/internal/translate/migrate/carriers.go index 0434b057f..e52317b42 100644 --- a/internal/translate/migrate/carriers.go +++ b/internal/translate/migrate/carriers.go @@ -18,6 +18,12 @@ type carrier struct { // carriers declares, for each state whose entry or do behavior takes parameters, the item // holding the incoming signal whose properties match them by position, type, order and multiplicity. // Internal transitions enter no state, so they neither settle the signal nor rule it out. + +// The note fragments the writer repeats. +const ( + transitionFrom = "the transition from " +) + func (m *migration) carriers(sm *sysmlv1.Element, used map[string]bool) { incoming := map[*sysmlv1.Element][]*sysmlv1.Element{} var states []*sysmlv1.Element @@ -99,36 +105,11 @@ func (m *migration) carrierSignal(v *sysmlv1.Element, incoming []*sysmlv1.Elemen } var sig *sysmlv1.Element for _, t := range incoming { - src := m.model.Ref(t, "source") - if src != nil && src.Type == "Pseudostate" { - return nil, "the transition from the " + pseudoKind(src) + " pseudostate " + describe(src) + " enters the state with no signal of its own" - } - triggers := t.Owned("trigger") - if len(triggers) == 0 { - return nil, "the transition from " + describe(src) + " enters the state with no trigger" - } - for _, tr := range triggers { - ev := m.model.Ref(tr, "event") - if ev == nil || ev.Type != "SignalEvent" { - return nil, "the transition from " + describe(src) + " accepts " + eventKind(ev) + ", which carries no signal" - } - if note, ok := m.signalOf(ev); !ok { - return nil, "the transition from " + describe(src) + " accepts a signal with no v2 declaration: " + note - } - s := m.model.Ref(ev, "signal") - if sig != nil && s != sig { - return nil, "the transitions into the state accept different signals, " + m.nameFor(sig) + " and " + m.nameFor(s) - } - sig = s - } - if eff := firstOwned(t, "effect"); eff != nil { - switch { - case eff.Parent != t: - return nil, "the effect of the transition from " + describe(src) + " is written once, as its own action def, which cannot keep the accepted signal" - case eff.Type != "Activity" && eff.Type != "OpaqueBehavior" && eff.Type != "FunctionBehavior": - return nil, "the effect of the transition from " + describe(src) + " is " + aOrAn(eff.Type) + ", which has no action form to keep the accepted signal in" - } + s, note, fail := m.incomingSignal(t, sig) + if fail { + return nil, note } + sig = s } if cat, _ := m.classify(sig); cat != catItemDef { return nil, "the signal " + m.nameFor(sig) + " is written as " + aOrAn(cat.keyword()) + ", which no item holds" @@ -136,6 +117,52 @@ func (m *migration) carrierSignal(v *sysmlv1.Element, incoming []*sysmlv1.Elemen return sig, "" } +// incomingSignal checks one transition into the state: its trigger accepts a signal +// consistent with the others and its effect can keep it. fail reports the first reason not. +func (m *migration) incomingSignal(t, sig *sysmlv1.Element) (*sysmlv1.Element, string, bool) { + src := m.model.Ref(t, "source") + if src != nil && src.Type == "Pseudostate" { + return nil, "the transition from the " + pseudoKind(src) + " pseudostate " + describe(src) + " enters the state with no signal of its own", true + } + triggers := t.Owned("trigger") + if len(triggers) == 0 { + return nil, transitionFrom + describe(src) + " enters the state with no trigger", true + } + for _, tr := range triggers { + s, note, fail := m.triggerSignal(tr, src, sig) + if fail { + return nil, note, true + } + sig = s + } + if eff := firstOwned(t, "effect"); eff != nil { + switch { + case eff.Parent != t: + return nil, "the effect of the transition from " + describe(src) + " is written once, as its own action def, which cannot keep the accepted signal", true + case eff.Type != "Activity" && eff.Type != "OpaqueBehavior" && eff.Type != "FunctionBehavior": + return nil, "the effect of the transition from " + describe(src) + " is " + aOrAn(eff.Type) + ", which has no action form to keep the accepted signal in", true + } + } + return sig, "", false +} + +// triggerSignal checks one trigger's event: a SignalEvent whose declared signal +// matches the signals the other transitions accept. +func (m *migration) triggerSignal(tr, src, sig *sysmlv1.Element) (*sysmlv1.Element, string, bool) { + ev := m.model.Ref(tr, "event") + if ev == nil || ev.Type != "SignalEvent" { + return nil, transitionFrom + describe(src) + " accepts " + eventKind(ev) + ", which carries no signal", true + } + if note, ok := m.signalOf(ev); !ok { + return nil, transitionFrom + describe(src) + " accepts a signal with no v2 declaration: " + note, true + } + s := m.model.Ref(ev, "signal") + if sig != nil && s != sig { + return nil, "the transitions into the state accept different signals, " + m.nameFor(sig) + " and " + m.nameFor(s), true + } + return s, "", false +} + // eventKind names an event for a diagnostic, or its absence. func eventKind(ev *sysmlv1.Element) string { if ev == nil { diff --git a/internal/translate/migrate/context.go b/internal/translate/migrate/context.go index 0f8dc551d..7e64ac60f 100644 --- a/internal/translate/migrate/context.go +++ b/internal/translate/migrate/context.go @@ -27,6 +27,13 @@ type contextVisit struct { // object unless nothing it needs is one: v1 runs a called behavior on the caller's // object, whoever owns it, so such a behavior takes the object it acts on instead. // A cycle of calls settles at once, with everything its members name. + +// The note fragments the writer repeats. +const ( + actsOn = "the behavior acts on a " + throughParam = " through its parameter " +) + func (m *migration) contextOf(b *sysmlv1.Element) *behaviorContext { if b == nil || b.Type != "Activity" { return nil @@ -420,9 +427,9 @@ func (m *migration) contextBinding(c *behaviorContext, selfType *sysmlv1.Element kind := qualifiedName(c.classifier) switch { case selfType == nil: - return "", "the behavior acts on a " + kind + " through its parameter " + c.name + ", which is left unbound: the caller acts on no object" + return "", actsOn + kind + throughParam + c.name + ", which is left unbound: the caller acts on no object" case selfType == c.classifier || m.inherits(selfType, c.classifier): - return self, "the behavior acts on a " + kind + " through its parameter " + c.name + ", which is bound to " + self + return self, actsOn + kind + throughParam + c.name + ", which is bound to " + self } var parts []*sysmlv1.Element for _, f := range m.attributesOf(selfType) { @@ -435,11 +442,11 @@ func (m *migration) contextBinding(c *behaviorContext, selfType *sysmlv1.Element } if len(parts) == 1 { part := self + "." + writeName(m.nameFor(parts[0])) - return part, "the behavior acts on a " + kind + " through its parameter " + c.name + ", which is bound to " + part + ", the caller's one part that is one" + return part, actsOn + kind + throughParam + c.name + ", which is bound to " + part + ", the caller's one part that is one" } why := "has no part that is one" if len(parts) > 1 { why = "has " + strconv.Itoa(len(parts)) + " parts that are one, so no one of them is chosen" } - return "", "the behavior acts on a " + kind + " through its parameter " + c.name + ", which is left unbound: the caller is a " + qualifiedName(selfType) + ", which is no " + kind + " and " + why + return "", actsOn + kind + throughParam + c.name + ", which is left unbound: the caller is a " + qualifiedName(selfType) + ", which is no " + kind + " and " + why } diff --git a/internal/translate/migrate/interaction.go b/internal/translate/migrate/interaction.go index 3d51aae3a..495de6143 100644 --- a/internal/translate/migrate/interaction.go +++ b/internal/translate/migrate/interaction.go @@ -113,6 +113,17 @@ type scenarioOperand struct { // messagelessNote says why an interaction without messages has no steps; one of // state invariants under time constraints is a recorded timing trace, not a behavior. + +// The note fragments the writer repeats. +const ( + theMessage = "the message " + standsFor = "stands for " + noLifeline = "is received on no lifeline" + theValue = "the value " + theArgument = "the argument " + notMigrated = " not migrated — " +) + func messagelessNote(e *sysmlv1.Element) string { invariants := 0 for _, f := range e.Owned("fragment") { @@ -202,7 +213,7 @@ func (s *scenario) resolve(fragments, messages []*sysmlv1.Element, body *[]*scen s.placed[msg] = true step, note := s.message(msg, body) if note != "" { - return nil, "the message " + describe(msg) + " " + note + return nil, theMessage + describe(msg) + " " + note } steps = append(steps, step) case "CombinedFragment": @@ -227,7 +238,7 @@ func (s *scenario) resolve(fragments, messages []*sysmlv1.Element, body *[]*scen s.placed[msg] = true step, note := s.message(msg, body) if note != "" { - return nil, "the message " + describe(msg) + " " + note + return nil, theMessage + describe(msg) + " " + note } steps = append(steps, step) } @@ -260,22 +271,22 @@ func (s *scenario) lifeline(line *sysmlv1.Element) (lifelineRef, string) { ref = lifelineRef{line: line, path: name, chain: name, typ: s.m.model.Ref(rep, "type")} case "Property", "Port": if !s.m.written(rep) { - return lifelineRef{}, "stands for " + describe(rep) + " of " + qualifiedName(rep.Parent) + ", which has no v2 declaration" + return lifelineRef{}, standsFor + describe(rep) + " of " + qualifiedName(rep.Parent) + ", which has no v2 declaration" } paths := s.m.partPaths(s.context, rep) switch len(paths) { case 0: - return lifelineRef{}, "stands for " + describe(rep) + " of " + qualifiedName(rep.Parent) + ", which no part of " + qualifiedName(s.context) + " reaches" + return lifelineRef{}, standsFor + describe(rep) + " of " + qualifiedName(rep.Parent) + ", which no part of " + qualifiedName(s.context) + " reaches" case 1: default: - return lifelineRef{}, "stands for " + describe(rep) + ", which " + qualifiedName(s.context) + " reaches as both " + paths[0] + " and " + paths[1] + return lifelineRef{}, standsFor + describe(rep) + ", which " + qualifiedName(s.context) + " reaches as both " + paths[0] + " and " + paths[1] } ref = lifelineRef{line: line, path: s.self + "." + paths[0], chain: paths[0], typ: s.m.model.Ref(rep, "type")} if s.self != "this" { ref.chain = ref.path } default: - return lifelineRef{}, "stands for " + describe(rep) + ", a " + rep.Type + " rather than a part or parameter" + return lifelineRef{}, standsFor + describe(rep) + ", a " + rep.Type + " rather than a part or parameter" } s.lines[line] = ref return ref, "" @@ -356,7 +367,7 @@ func (s *scenario) message(msg *sysmlv1.Element, body *[]*scenarioStep) (*scenar return nil, "is a " + sort + " message, which has no v2 form" } if step.receiver == nil { - return nil, "is received on no lifeline" + return nil, noLifeline } return step, "" } @@ -389,7 +400,7 @@ func (s *scenario) send(step *scenarioStep) (*scenarioStep, string) { return nil, "names " + describe(sig) + ", which is not a migrated signal" } if step.receiver == nil { - return nil, "is received on no lifeline" + return nil, noLifeline } step.kind = stepSend step.signal = sig @@ -413,7 +424,7 @@ func (s *scenario) call(step *scenarioStep, sort string) (*scenarioStep, string) return nil, "names " + describe(op) + ", which is not a migrated operation" } if step.receiver == nil { - return nil, "is received on no lifeline" + return nil, noLifeline } switch { case step.receiver.typ == nil: @@ -476,7 +487,7 @@ func (s *scenario) reply(step *scenarioStep) (*scenarioStep, string) { arg := replyArgs[i] switch { case p == nil: - step.note = joinNotes(step.note, "the value "+describeValue(arg)+" has no out parameter of "+op.Name+" to stand for") + step.note = joinNotes(step.note, theValue+describeValue(arg)+" has no out parameter of "+op.Name+" to stand for") continue case !s.within(step.body, call.body): step.note = joinNotes(step.note, "the result "+s.m.nameOf(p)+" is not bound: the reply is not in the fragment of the call it answers") @@ -484,7 +495,7 @@ func (s *scenario) reply(step *scenarioStep) (*scenarioStep, string) { } target, value := assignmentOf(arg) if target == "" { - step.note = joinNotes(step.note, "the value "+describeValue(arg)+" of "+s.m.nameOf(p)+" is the operation's own result, which the call computes") + step.note = joinNotes(step.note, theValue+describeValue(arg)+" of "+s.m.nameOf(p)+" is the operation's own result, which the call computes") continue } attr := s.attributeNamed(step.receiver, target) @@ -493,7 +504,7 @@ func (s *scenario) reply(step *scenarioStep) (*scenarioStep, string) { continue } if value != "" { - step.note = joinNotes(step.note, "the value "+value+" the reply states for "+s.m.nameOf(p)+" is the operation's own result, which the call computes") + step.note = joinNotes(step.note, theValue+value+" the reply states for "+s.m.nameOf(p)+" is the operation's own result, which the call computes") } step.assigns = append(step.assigns, "assign "+step.receiver.path+"."+writeName(s.m.nameOf(attr))+" := "+call.name+"."+writeName(s.m.nameOf(p))+";") } @@ -601,11 +612,11 @@ func (s *scenario) bindArguments(msg *sysmlv1.Element, targets []*sysmlv1.Elemen for i, t := range s.pairArguments(msgArgs, targets) { arg := msgArgs[i] if t == nil { - note = joinNotes(note, "the argument "+describeValue(arg)+" has no "+kind+" of "+owner.Name+" to bind to and is dropped") + note = joinNotes(note, theArgument+describeValue(arg)+" has no "+kind+" of "+owner.Name+" to bind to and is dropped") continue } if bound[t] { - note = joinNotes(note, "the argument "+describeValue(arg)+" binds "+s.m.nameOf(t)+" a second time and is dropped") + note = joinNotes(note, theArgument+describeValue(arg)+" binds "+s.m.nameOf(t)+" a second time and is dropped") continue } expr, ok, vnote := s.m.typedBehaviorValue(arg, t, s.e) @@ -613,7 +624,7 @@ func (s *scenario) bindArguments(msg *sysmlv1.Element, targets []*sysmlv1.Elemen if requiresValue(t) { return nil, "", "leaves the " + kind + " " + s.m.nameOf(t) + " of " + owner.Name + ", which must hold a value, unbound: the argument " + describeValue(arg) + " is not written: " + vnote } - note = joinNotes(note, "the argument "+describeValue(arg)+" for "+s.m.nameOf(t)+" is dropped: "+vnote) + note = joinNotes(note, theArgument+describeValue(arg)+" for "+s.m.nameOf(t)+" is dropped: "+vnote) continue } bound[t] = true @@ -667,16 +678,26 @@ func (s *scenario) fragment(f *sysmlv1.Element, body *[]*scenarioStep) (*scenari default: return nil, "is a " + kind + " fragment, which has no v2 form" } - if step.kind != stepAlt && step.kind != stepSeq && len(operands) > 1 { - if step.kind != stepPar { - return nil, "is a " + kind + " fragment with " + strconv.Itoa(len(operands)) + " operands; it takes one" - } + if step.kind != stepAlt && step.kind != stepSeq && step.kind != stepPar && len(operands) > 1 { + return nil, "is a " + kind + " fragment with " + strconv.Itoa(len(operands)) + " operands; it takes one" } // The operands of alt, opt, loop and par each resolve from the calls open before the fragment: // alternatives do not see each other, and concurrent operands are unordered between themselves. - isolated := step.kind != stepSeq in := slices.Clone(s.calls) - var outs [][]*scenarioStep + outs, ferr := s.fragmentOperands(step, kind, operands, in, body) + if ferr != "" { + return nil, ferr + } + s.joinCalls(step, in, outs) + step.base = freshIn(s.used, kind) + step.name = writeName(step.base) + return step, "" +} + +// fragmentOperands resolves each operand's steps and collects the calls each +// leaves open; isolated operands all start from the calls open before the fragment. +func (s *scenario) fragmentOperands(step *scenarioStep, kind string, operands []*sysmlv1.Element, in []*scenarioStep, body *[]*scenarioStep) (outs [][]*scenarioStep, err string) { + isolated := step.kind != stepSeq for i, o := range operands { operand := &scenarioOperand{e: o} var steps []*scenarioStep @@ -684,32 +705,8 @@ func (s *scenario) fragment(f *sysmlv1.Element, body *[]*scenarioStep) (*scenari s.outer[&operand.steps] = body guard := firstOwned(o, "guard") var note string - switch step.kind { - case stepAlt: - operand.guard, operand.gnote, note = s.guard(guard) - if note != "" { - return nil, "has an operand whose guard is not written: " + note - } - if operand.guard == "" && i != len(operands)-1 { - return nil, "has an operand without a guard before its last, so the operands after it would never run" - } - case stepOpt: - operand.guard, operand.gnote, note = s.guard(guard) - if note != "" { - return nil, "has a guard that is not written: " + note - } - if operand.guard == "" { - return nil, "has no guard, so whether its operand runs is unspecified" - } - case stepLoop: - operand.guard, step.count, operand.gnote, note = s.loopBounds(guard) - if note != "" { - return nil, note - } - case stepPar, stepSeq: - if guard != nil && !trueGuard(guard) { - return nil, "has a guard on an operand of a " + kind + " fragment, which runs its operands regardless" - } + if err := s.operandGuard(step, kind, operand, guard, i, len(operands)); err != "" { + return nil, err } if isolated { s.calls = slices.Clone(in) @@ -722,6 +719,45 @@ func (s *scenario) fragment(f *sysmlv1.Element, body *[]*scenarioStep) (*scenari step.operands = append(step.operands, operand) outs = append(outs, s.calls) } + return outs, "" +} + +// operandGuard resolves an operand's guard by the fragment's kind, or why it cannot. +func (s *scenario) operandGuard(step *scenarioStep, kind string, operand *scenarioOperand, guard *sysmlv1.Element, i, n int) string { + var note string + switch step.kind { + case stepAlt: + operand.guard, operand.gnote, note = s.guard(guard) + if note != "" { + return "has an operand whose guard is not written: " + note + } + if operand.guard == "" && i != n-1 { + return "has an operand without a guard before its last, so the operands after it would never run" + } + case stepOpt: + operand.guard, operand.gnote, note = s.guard(guard) + if note != "" { + return "has a guard that is not written: " + note + } + if operand.guard == "" { + return "has no guard, so whether its operand runs is unspecified" + } + case stepLoop: + operand.guard, step.count, operand.gnote, note = s.loopBounds(guard) + if note != "" { + return note + } + case stepPar, stepSeq: + if guard != nil && !trueGuard(guard) { + return "has a guard on an operand of a " + kind + " fragment, which runs its operands regardless" + } + } + return "" +} + +// joinCalls closes the calls every operand path answers; a par's operands +// also open to replies the calls they made. +func (s *scenario) joinCalls(step *scenarioStep, in []*scenarioStep, outs [][]*scenarioStep) { switch step.kind { case stepAlt, stepOpt, stepLoop: // A path may skip the fragment unless an alt ends in an else; only a call still open on every path stays open. @@ -741,9 +777,6 @@ func (s *scenario) fragment(f *sysmlv1.Element, body *[]*scenarioStep) (*scenari } } } - step.base = freshIn(s.used, kind) - step.name = writeName(step.base) - return step, "" } // openOnEveryPath keeps the calls of in that every path in outs leaves unanswered. @@ -922,7 +955,7 @@ func (s *scenario) writeSteps(steps []*scenarioStep) { prev = next } } - s.m.w.line("first " + prev + " then done;") + s.m.w.line(firstKw + prev + " then done;") } // indexChain positions the steps of a chain, the operands of its seq fragments inlined. @@ -955,7 +988,7 @@ func (s *scenario) step(step *scenarioStep, prev string) string { } switch step.kind { case stepSend: - m.w.line("action " + step.name + " send new " + m.ref(step.signal, s.e) + "(" + strings.Join(step.args, ", ") + ") to " + step.receiver.path + ";") + m.w.line(actionKw + step.name + " send new " + m.ref(step.signal, s.e) + "(" + strings.Join(step.args, ", ") + ") to " + step.receiver.path + ";") s.messageDone(step, "written as a send to "+step.receiver.path) case stepCall: decl := "perform action " + step.name + " : " + m.ref(step.op, s.e) + " ::> " + step.receiver.chain + "." + writeName(m.operationUsage(step.op)) @@ -970,7 +1003,7 @@ func (s *scenario) step(step *scenarioStep, prev string) string { s.messageDone(step, "the reply is the completion of the call "+step.call.name+", which the next step follows") return "" } - m.w.block("action "+step.name, func() { + m.w.block(actionKw+step.name, func() { for _, a := range step.assigns { m.w.line(a) } @@ -982,11 +1015,11 @@ func (s *scenario) step(step *scenarioStep, prev string) string { what = "deletes" } m.w.lines(commentLines("not migrated: Message " + describe(step.msg) + " — the message " + what + " the object " + step.receiver.path + ", which exists for as long as its owner does")) - m.add(step.msg, Unmapped, "", "the message "+what+" "+step.receiver.path+", a part that exists for as long as its owner does; the steps after it address it as it is") + m.add(step.msg, Unmapped, "", theMessage+what+" "+step.receiver.path+", a part that exists for as long as its owner does; the steps after it address it as it is") s.occurrencesDone(step, "") return "" case stepAlt, stepOpt: - m.w.block("action "+step.name, func() { s.branches(step, 0) }) + m.w.block(actionKw+step.name, func() { s.branches(step, 0) }) s.fragmentDone(step, "if") case stepLoop: o := step.operands[0] @@ -994,18 +1027,18 @@ func (s *scenario) step(step *scenarioStep, prev string) string { if step.count != "" { head = "for " + freshIn(s.used, "i") + " in 1.." + step.count } - m.w.block("action "+step.name, func() { + m.w.block(actionKw+step.name, func() { m.w.block(head, func() { s.operand(step, o, 0) }) }) s.fragmentDone(step, strings.Fields(head)[0]) case stepPar: join := writeName(freshIn(s.used, step.base+"End")) m.w.line("fork " + step.name + ";") - m.w.line("first " + prev + " then " + step.name + ";") + m.w.line(firstKw + prev + thenKw + step.name + ";") for i, o := range step.operands { name := s.operand(step, o, i) - m.w.line("first " + step.name + " then " + name + ";") - m.w.line("first " + name + " then " + join + ";") + m.w.line(firstKw + step.name + thenKw + name + ";") + m.w.line(firstKw + name + thenKw + join + ";") } m.w.line("join " + join + ";") s.fragmentDone(step, "fork") @@ -1026,7 +1059,7 @@ func (s *scenario) step(step *scenarioStep, prev string) string { } return prevInner } - m.w.line("first " + prev + " then " + step.name + ";") + m.w.line(firstKw + prev + thenKw + step.name + ";") if step.msg != nil { return s.startWaits(step) } @@ -1045,8 +1078,8 @@ func (s *scenario) waits(step *scenarioStep, prev string) string { s.waited[dc] = true join := writeName(freshIn(s.used, p.base+"End")) s.m.w.line("join " + join + ";") - s.m.w.line("first " + prev + " then " + join + ";") - s.m.w.line("first " + p.wait + " then " + join + ";") + s.m.w.line(firstKw + prev + thenKw + join + ";") + s.m.w.line(firstKw + p.wait + thenKw + join + ";") prev = join s.m.add(dc, Approximated, p.wait, joinNotes("the time from "+p.from+", written as the wait "+p.wait+" forked after it and joined before "+step.name, p.note)) s.observationsDone(dc, p.wait, step.name) @@ -1059,19 +1092,19 @@ func (s *scenario) waits(step *scenarioStep, prev string) string { s.waited[dc] = true if from == "" { note := "the time it measures from " + s.names[s.otherEnd(dc, step.msg)] + " to " + step.name + " is not written: steps of other fragments lie between them, so no wait forked after the one can be joined before the other" - s.m.w.lines(commentLines("duration constraint on " + step.name + " not migrated — " + note)) + s.m.w.lines(commentLines("duration constraint on " + step.name + notMigrated + note)) s.m.add(dc, Unmapped, "", note) continue } expr, note, ok := s.waitExpr(dc) if !ok { - s.m.w.lines(commentLines("duration constraint on " + step.name + " not migrated — " + note)) + s.m.w.lines(commentLines("duration constraint on " + step.name + notMigrated + note)) s.m.add(dc, Unmapped, "", note) continue } name := writeName(freshIn(s.used, "wait")) - s.m.w.line("action " + name + " accept after " + expr + " [SI::s];") - s.m.w.line("first " + prev + " then " + name + ";") + s.m.w.line(actionKw + name + " accept after " + expr + " [SI::s];") + s.m.w.line(firstKw + prev + thenKw + name + ";") prev = name s.m.add(dc, Approximated, name, joinNotes(from+", written as the wait "+name+" before "+step.name, note)) s.observationsDone(dc, name, step.name) @@ -1099,19 +1132,19 @@ func (s *scenario) startWaits(step *scenarioStep) string { expr, note, ok := s.waitExpr(dc) if !ok { s.waited[dc] = true - s.m.w.lines(commentLines("duration constraint from " + step.name + " not migrated — " + note)) + s.m.w.lines(commentLines("duration constraint from " + step.name + notMigrated + note)) s.m.add(dc, Unmapped, "", note) continue } if fork == "" { fork = writeName(freshIn(s.used, "timing")) s.m.w.line("fork " + fork + ";") - s.m.w.line("first " + step.name + " then " + fork + ";") + s.m.w.line(firstKw + step.name + thenKw + fork + ";") } base := freshIn(s.used, "wait") wait := writeName(base) - s.m.w.line("action " + wait + " accept after " + expr + " [SI::s];") - s.m.w.line("first " + fork + " then " + wait + ";") + s.m.w.line(actionKw + wait + " accept after " + expr + " [SI::s];") + s.m.w.line(firstKw + fork + thenKw + wait + ";") s.pending[dc] = pendingWait{wait: wait, base: base, from: step.name, note: note} } if fork == "" { @@ -1254,9 +1287,9 @@ func (s *scenario) branches(step *scenarioStep, i int) { func (s *scenario) operand(step *scenarioStep, o *scenarioOperand, i int) string { name := writeName(freshIn(s.used, step.base+"Op"+strconv.Itoa(i+1))) if len(o.steps) == 0 { - s.m.w.line("action " + name + ";") + s.m.w.line(actionKw + name + ";") } else { - s.m.w.block("action "+name, func() { s.writeSteps(o.steps) }) + s.m.w.block(actionKw+name, func() { s.writeSteps(o.steps) }) } note := "written as the action " + name if o.guard != "" { diff --git a/internal/translate/migrate/opaque.go b/internal/translate/migrate/opaque.go index 22116823e..d0e8e339e 100644 --- a/internal/translate/migrate/opaque.go +++ b/internal/translate/migrate/opaque.go @@ -38,6 +38,14 @@ type refusal struct { } // note spells the refusal for a report entry or a comment. + +// The note fragments the writer repeats. +const ( + assignKw = "assign " + notBoolean = ", not a Boolean" + mathRound = "Math.round" +) + func (r *refusal) note() string { var text string switch r.kind { @@ -89,9 +97,9 @@ func (r opaqueRef) value() translated { return translated{expr: r.expr, scalar: r.scalar, object: r.object, plural: r.plural, atomic: true} } -// opaqueScope answers what the names of an opaque body mean where it is read; +// featureResolver answers what the names of an opaque body mean where it is read; // a path starting with `this` asks for a feature of the context object. -type opaqueScope interface { +type featureResolver interface { feature(path []string, write bool) (opaqueRef, *refusal) } @@ -243,7 +251,7 @@ func javaLabel(l string) bool { // translateExpr translates body as one expression read in sc yielding what // want asks for. The expression is complete or refused. -func translateExpr(body, lang string, sc opaqueScope, want wanted) (translated, *refusal) { +func translateExpr(body, lang string, sc featureResolver, want wanted) (translated, *refusal) { d := dialectOf(lang) if d == dialectNone { return translated{}, &refusal{kind: refusedLanguage, token: lang} @@ -268,7 +276,7 @@ func translateExpr(body, lang string, sc opaqueScope, want wanted) (translated, // translateStatements translates body as a sequence of script statements into // the lines of a v2 action body: local declarations and assignments. -func translateStatements(body, lang string, sc opaqueScope) ([]string, *refusal) { +func translateStatements(body, lang string, sc featureResolver) ([]string, *refusal) { d := dialectOf(lang) switch d { case dialectNone: @@ -283,7 +291,7 @@ func translateStatements(body, lang string, sc opaqueScope) ([]string, *refusal) } // wholeExprIn parses body as one expression of dialect d, its names answered by sc. -func wholeExprIn(body string, d dialect, sc opaqueScope) (translated, *refusal) { +func wholeExprIn(body string, d dialect, sc featureResolver) (translated, *refusal) { p, err := newOpaqueParser(body, d, sc) if err != nil { return translated{}, err @@ -292,7 +300,7 @@ func wholeExprIn(body string, d dialect, sc opaqueScope) (translated, *refusal) } // statementsIn parses body as statements of dialect d, its names answered by sc. -func statementsIn(body string, d dialect, sc opaqueScope) ([]string, *refusal) { +func statementsIn(body string, d dialect, sc featureResolver) ([]string, *refusal) { p, err := newOpaqueParser(body, d, sc) if err != nil { return nil, err @@ -343,6 +351,33 @@ func lineTerminator(r rune) bool { func lexOpaque(body string) ([]token, *refusal) { var toks []token s := body + for s != "" { + rest, newlines, done, err := lexTrivia(s) + if err != nil { + return nil, err + } + for i := 0; i < newlines; i++ { + toks = append(toks, token{tokNewline, "\n"}) + } + s = rest + if done { + break + } + r, _ := utf8.DecodeRuneInString(s) + tok, rest, err := lexAtom(s, r) + if err != nil { + return nil, err + } + toks = append(toks, tok) + s = rest + } + return append(toks, token{tokEOF, ""}), nil +} + +// lexTrivia skips whitespace, comments and line ends, counting the line ends +// crossed (one per terminator, one for a block comment spanning lines); done +// reports the body is spent. +func lexTrivia(s string) (rest string, newlines int, done bool, err *refusal) { for s != "" { r, size := utf8.DecodeRuneInString(s) switch { @@ -350,69 +385,89 @@ func lexOpaque(body string) ([]token, *refusal) { if strings.HasPrefix(s, "\r\n") { size = 2 } - toks = append(toks, token{tokNewline, "\n"}) + newlines++ s = s[size:] - continue case unicode.IsSpace(r): s = s[size:] - continue case strings.HasPrefix(s, "//"): - if i := strings.IndexFunc(s, lineTerminator); i >= 0 { - s = s[i:] - } else { - s = "" - } - continue + s = skipLineComment(s) case strings.HasPrefix(s, "/*"): - i := strings.Index(s[2:], "*/") - if i < 0 { - return nil, &refusal{kind: refusedSyntax, token: "/*", why: "the comment is not closed"} - } - if strings.ContainsFunc(s[2:2+i], lineTerminator) { - toks = append(toks, token{tokNewline, "\n"}) - } - s = s[i+4:] - continue - case r == '"' || r == '\'': - text, rest, err := lexString(s, r) + rest, nl, err := skipBlockComment(s) if err != nil { - return nil, err + return "", 0, false, err } - toks = append(toks, token{tokString, text}) - s = rest - continue - case unicode.IsDigit(r) || (r == '.' && len(s) > 1 && isDigit(s[1])): - text, rest := lexNumber(s) - toks = append(toks, token{tokNumber, text}) - s = rest - continue - case unicode.IsLetter(r) || r == '_' || r == '$': - i := size - for i < len(s) { - c, n := utf8.DecodeRuneInString(s[i:]) - if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '_' && c != '$' { - break - } - i += n + if nl { + newlines++ } - toks = append(toks, token{tokIdent, s[:i]}) - s = s[i:] - continue + s = rest + default: + return s, newlines, false, nil } - matched := false - for _, p := range puncts { - if strings.HasPrefix(s, p) { - toks = append(toks, token{tokPunct, p}) - s = s[len(p):] - matched = true - break - } + } + return s, newlines, true, nil +} + +// lexAtom reads the token at s's head: a string or number literal, an +// identifier, or the longest punctuation. +func lexAtom(s string, r rune) (token, string, *refusal) { + switch { + case r == '"' || r == '\'': + text, rest, err := lexString(s, r) + if err != nil { + return token{}, "", err + } + return token{tokString, text}, rest, nil + case unicode.IsDigit(r) || (r == '.' && len(s) > 1 && isDigit(s[1])): + text, rest := lexNumber(s) + return token{tokNumber, text}, rest, nil + case unicode.IsLetter(r) || r == '_' || r == '$': + text, rest := lexIdent(s) + return token{tokIdent, text}, rest, nil + } + if p, ok := lexPunct(s); ok { + return token{tokPunct, p}, s[len(p):], nil + } + return token{}, s, &refusal{kind: refusedSyntax, token: string(r)} +} + +// lexIdent reads the identifier at s's head. +func lexIdent(s string) (text, rest string) { + i := 0 + for i < len(s) { + c, n := utf8.DecodeRuneInString(s[i:]) + if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '_' && c != '$' { + break } - if !matched { - return nil, &refusal{kind: refusedSyntax, token: string(r)} + i += n + } + return s[:i], s[i:] +} + +// lexPunct returns the longest punctuation at s's head, or "" when none matches. +func lexPunct(s string) (string, bool) { + for _, p := range puncts { + if strings.HasPrefix(s, p) { + return p, true } } - return append(toks, token{tokEOF, ""}), nil + return "", false +} + +// skipLineComment drops a `//` comment through its line end. +func skipLineComment(s string) string { + if i := strings.IndexFunc(s, lineTerminator); i >= 0 { + return s[i:] + } + return "" +} + +// skipBlockComment drops a `/* */` comment, reporting a line end inside it. +func skipBlockComment(s string) (rest string, newline bool, err *refusal) { + i := strings.Index(s[2:], "*/") + if i < 0 { + return "", false, &refusal{kind: refusedSyntax, token: "/*", why: "the comment is not closed"} + } + return s[i+4:], strings.ContainsFunc(s[2:2+i], lineTerminator), nil } func isDigit(c byte) bool { return c >= '0' && c <= '9' } @@ -597,12 +652,12 @@ type opaqueParser struct { toks []token i int d dialect - sc opaqueScope + sc featureResolver locals map[string]local // names a `var`, `let` or `const` declared assigns bool // whether `=` assigns (a statement) rather than compares } -func newOpaqueParser(body string, d dialect, sc opaqueScope) (*opaqueParser, *refusal) { +func newOpaqueParser(body string, d dialect, sc featureResolver) (*opaqueParser, *refusal) { toks, err := lexOpaque(body) if err != nil { return nil, err @@ -770,7 +825,7 @@ func (p *opaqueParser) declaration() ([]string, *refusal) { target := writeName(name.text) return []string{ "attribute " + target + " : ScalarValues::" + value.scalar + ";", - "assign " + target + " := " + value.expr + ";", + assignKw + target + " := " + value.expr + ";", }, nil } @@ -785,7 +840,7 @@ func (p *opaqueParser) declarable(kw, name string) *refusal { if inheritedActionNames()[name] { return &refusal{kind: refusedConstruct, token: token, why: name + " is a member every action has"} } - if _, any := p.sc.(anyScope); any { + if _, isAny := p.sc.(anyScope); isAny { return nil } if _, err := p.sc.feature([]string{name}, false); err == nil { @@ -803,7 +858,7 @@ func (p *opaqueParser) step(path []string, op string) ([]string, *refusal) { if held := target.value().held(); held != "" && !isNumeric(target.scalar) { return nil, &refusal{kind: refusedType, token: strings.Join(path, ".") + op, why: "a " + held + " is not counted"} } - return []string{"assign " + target.expr + " := " + target.expr + " " + op[:1] + " 1;"}, nil + return []string{assignKw + target.expr + " := " + target.expr + " " + op[:1] + " 1;"}, nil } // assignment writes `x = e` or `x op= e` as an assignment. @@ -832,7 +887,7 @@ func (p *opaqueParser) assignment(path []string, op string) ([]string, *refusal) if held.held() != "" && value.held() != "" && !assignableTo(held, value) { return nil, &refusal{kind: refusedType, token: name + " " + op, why: "a " + value.held() + " is assigned to the " + held.held() + " " + name + " holds"} } - return []string{"assign " + target.expr + " := " + spellFor(target.scalar, value) + ";"}, nil + return []string{assignKw + target.expr + " := " + spellFor(target.scalar, value) + ";"}, nil } // target resolves the feature an assignment writes. @@ -909,7 +964,7 @@ func (p *opaqueParser) expr() (translated, *refusal) { } p.next(true) if cond.held() != "" && cond.scalar != "Boolean" { - return translated{}, &refusal{kind: refusedType, token: "?", why: "the condition is a " + cond.held() + ", not a Boolean"} + return translated{}, &refusal{kind: refusedType, token: "?", why: "the condition is a " + cond.held() + notBoolean} } yes, err := p.expr() if err != nil { @@ -960,7 +1015,7 @@ func (p *opaqueParser) logical(next func() (translated, *refusal), op binaryOp, } for _, side := range []translated{left, right} { if side.held() != "" && side.scalar != "Boolean" { - return translated{}, &refusal{kind: refusedType, token: tok.text, why: "an operand is a " + side.held() + ", not a Boolean"} + return translated{}, &refusal{kind: refusedType, token: tok.text, why: "an operand is a " + side.held() + notBoolean} } } left = binary(left, op.v2, right, loose, "Boolean") @@ -1158,7 +1213,7 @@ func (p *opaqueParser) unary() (translated, *refusal) { return translated{}, err } if x.held() != "" && x.scalar != "Boolean" { - return translated{}, &refusal{kind: refusedType, token: tok.text, why: "the operand is a " + x.held() + ", not a Boolean"} + return translated{}, &refusal{kind: refusedType, token: tok.text, why: "the operand is a " + x.held() + notBoolean} } return translated{expr: "not " + x.operandOf(looseUnary, true), scalar: "Boolean", loose: looseUnary}, nil case tok.isPunct("++"), tok.isPunct("--"): @@ -1202,52 +1257,64 @@ func (p *opaqueParser) primary() (translated, *refusal) { } return lit, nil case tokIdent: - switch { - case tok.word("true") || tok.word("false"): - if p.d.script() && tok.text != strings.ToLower(tok.text) { - return translated{}, &refusal{kind: refusedName, token: tok.text, why: "a script spells its Booleans in lower case"} - } - return translated{expr: strings.ToLower(tok.text), scalar: "Boolean", atomic: true, lit: "boolean"}, nil - case p.d.script() && scriptReserved[tok.text]: - return translated{}, &refusal{kind: refusedConstruct, token: tok.text} + return p.primaryIdent(tok) + case tokPunct: + return p.primaryPunct(tok) + case tokNewline: + return translated{}, &refusal{kind: refusedSyntax, token: "\n", why: "an operand is expected"} + } + return translated{}, &refusal{kind: refusedSyntax, token: "", why: "the expression ends early"} +} + +// primaryIdent reads an identifier: a Boolean literal, a call of a dotted +// name, or the name itself. +func (p *opaqueParser) primaryIdent(tok token) (translated, *refusal) { + switch { + case tok.word("true") || tok.word("false"): + if p.d.script() && tok.text != strings.ToLower(tok.text) { + return translated{}, &refusal{kind: refusedName, token: tok.text, why: "a script spells its Booleans in lower case"} } - p.i-- - path, err := p.path() + return translated{expr: strings.ToLower(tok.text), scalar: "Boolean", atomic: true, lit: "boolean"}, nil + case p.d.script() && scriptReserved[tok.text]: + return translated{}, &refusal{kind: refusedConstruct, token: tok.text} + } + p.i-- + path, err := p.path() + if err != nil { + return translated{}, err + } + if p.peek(false).isPunct("(") { + if p.d == dialectEnglish { + return translated{}, &refusal{kind: refusedConstruct, token: strings.Join(path, ".") + "(", + why: "a call is not English; the subset reads names, literals, comparisons and not/and/or"} + } + p.next(false) + return p.call(path) + } + return p.name(path) +} + +// primaryPunct reads a parenthesized expression; every other punctuation where +// an operand belongs is refused. +func (p *opaqueParser) primaryPunct(tok token) (translated, *refusal) { + if tok.text == "(" { + x, err := p.expr() if err != nil { return translated{}, err } - if p.peek(false).isPunct("(") { - if p.d == dialectEnglish { - return translated{}, &refusal{kind: refusedConstruct, token: strings.Join(path, ".") + "(", - why: "a call is not English; the subset reads names, literals, comparisons and not/and/or"} - } - p.next(false) - return p.call(path) - } - return p.name(path) - case tokPunct: - if tok.text == "(" { - x, err := p.expr() - if err != nil { - return translated{}, err - } - if !p.next(true).isPunct(")") { - return translated{}, &refusal{kind: refusedSyntax, token: "(", why: "the parenthesis is not closed"} - } - // The group keeps its looseness: operands are re-parenthesized where the v2 precedence needs it. - return x, nil - } - if tok.text == "{" || tok.text == "[" { - return translated{}, &refusal{kind: refusedConstruct, token: tok.text, why: "an object or array literal has no v2 form in the subset"} - } - if tok.text == "/" { - return translated{}, &refusal{kind: refusedConstruct, token: "/", why: "a regular expression has no v2 form"} + if !p.next(true).isPunct(")") { + return translated{}, &refusal{kind: refusedSyntax, token: "(", why: "the parenthesis is not closed"} } - return translated{}, &refusal{kind: refusedSyntax, token: tok.text, why: "an operand is expected"} - case tokNewline: - return translated{}, &refusal{kind: refusedSyntax, token: "\n", why: "an operand is expected"} + // The group keeps its looseness: operands are re-parenthesized where the v2 precedence needs it. + return x, nil } - return translated{}, &refusal{kind: refusedSyntax, token: "", why: "the expression ends early"} + if tok.text == "{" || tok.text == "[" { + return translated{}, &refusal{kind: refusedConstruct, token: tok.text, why: "an object or array literal has no v2 form in the subset"} + } + if tok.text == "/" { + return translated{}, &refusal{kind: refusedConstruct, token: "/", why: "a regular expression has no v2 form"} + } + return translated{}, &refusal{kind: refusedSyntax, token: tok.text, why: "an operand is expected"} } // name resolves a dotted name through the locals and the scope. @@ -1278,107 +1345,132 @@ func (p *opaqueParser) call(path []string) (translated, *refusal) { return nil } if p.d == dialectJava && len(path) > 1 && path[len(path)-1] == "equals" { - if err := arity(1); err != nil { - return translated{}, err - } - recv, err := p.name(path[:len(path)-1]) - if err != nil { - return translated{}, err - } - return stringEquals(recv, fn, args[0]) + return p.javaEquals(path, fn, args, arity) } switch fn { case "Math.max", "Math.min": - // Java's take two arguments; JavaScript's take any number, folded pairwise, - // one argument being itself and none an infinity the subset has no form for. - if p.d == dialectJava { - if err := arity(2); err != nil { - return translated{}, err - } - } else if len(args) == 0 { - return translated{}, &refusal{kind: refusedCall, token: fn, why: fn + " with no arguments yields an infinity, which has no v2 form in the subset"} - } - acc := args[0] - if err := numbersAt(fn, acc); err != nil { - return translated{}, err - } - for _, arg := range args[1:] { - if err := numbersAt(fn, acc, arg); err != nil { - return translated{}, err - } - scalar := arithmeticScalar("+", acc.scalar, arg.scalar) - acc = translated{expr: extremum(fn[5:], scalar) + "(" + acc.expr + ", " + arg.expr + ")", scalar: scalar, atomic: true} - } - return acc, nil - case "Math.abs": - if err := arity(1); err != nil { - return translated{}, err - } - if err := numbersAt(fn, args[0], args[0]); err != nil { - return translated{}, err - } - lib := "NumericalFunctions" - switch { - case wholeScalar(args[0].scalar): - lib = "IntegerFunctions" - case args[0].scalar != "": - lib = "RealFunctions" - } - return translated{expr: lib + "::abs(" + args[0].expr + ")", scalar: args[0].scalar, atomic: true}, nil - case "Math.floor", "Math.round", "Math.ceil": + return p.mathExtremum(fn, args, arity) + case "Math.abs", "Math.sqrt", "Math.pow": + return mathScalarCall(fn, args, arity) + case "Math.floor", mathRound, "Math.ceil": + return p.mathRoundish(fn, args, arity) + case "java.util.Collections.max", "java.util.Collections.min", "Collections.max", "Collections.min": if err := arity(1); err != nil { return translated{}, err } - if err := numbersAt(fn, args[0], args[0]); err != nil { - return translated{}, err - } - // Java's floor and ceil answer a double, so a `/` after them is real division; its round answers a long. - yields := "Integer" - if p.d == dialectJava && fn != "Math.round" { - yields = "Real" - } - switch fn { - case "Math.ceil": - // -floor(-x) would overflow at the least Integer; the extension library's ceiling does not. - return translated{expr: "OpenSysMLMathFunctions::ceiling(" + args[0].expr + ")", scalar: yields, atomic: true}, nil - case "Math.round": - // JavaScript and Java round a half toward +∞, where RealFunctions::round rounds it away from zero. - half := translated{expr: "0.5", scalar: "Real", atomic: true, lit: "real"} - return translated{expr: "RealFunctions::floor(" + binary(args[0], "+", half, looseAdditive, "Real").expr + ")", scalar: "Integer", atomic: true}, nil - default: - return translated{expr: "RealFunctions::floor(" + args[0].expr + ")", scalar: yields, atomic: true}, nil - } + return collectionExtremum(fn, args[0]) + } + return translated{}, &refusal{kind: refusedCall, token: fn} +} + +// mathScalarCall writes the one- or two-argument Math functions whose v2 form +// is a library call: abs, sqrt and the ** of pow. +func mathScalarCall(fn string, args []translated, arity func(int) *refusal) (translated, *refusal) { + n := 1 + if fn == "Math.pow" { + n = 2 + } + if err := arity(n); err != nil { + return translated{}, err + } + if err := numbersAt(fn, args[0], args[n-1]); err != nil { + return translated{}, err + } + switch fn { + case "Math.abs": + return mathAbs(args[0]), nil case "Math.sqrt": - if err := arity(1); err != nil { - return translated{}, err - } - if err := numbersAt(fn, args[0], args[0]); err != nil { - return translated{}, err - } return translated{expr: "RealFunctions::sqrt(" + args[0].expr + ")", scalar: "Real", atomic: true}, nil - case "Math.pow": + default: + return binary(args[0], "**", args[1], loosePower, "Real"), nil + } +} + +// mathAbs writes Math.abs through the library its argument's scalar picks. +func mathAbs(arg translated) translated { + lib := "NumericalFunctions" + switch { + case wholeScalar(arg.scalar): + lib = "IntegerFunctions" + case arg.scalar != "": + lib = "RealFunctions" + } + return translated{expr: lib + "::abs(" + arg.expr + ")", scalar: arg.scalar, atomic: true} +} + +// javaEquals writes Java's `x.equals(y)` as the string equality of x and y. +func (p *opaqueParser) javaEquals(path []string, fn string, args []translated, arity func(int) *refusal) (translated, *refusal) { + if err := arity(1); err != nil { + return translated{}, err + } + recv, err := p.name(path[:len(path)-1]) + if err != nil { + return translated{}, err + } + return stringEquals(recv, fn, args[0]) +} + +// mathExtremum writes Math.max/min. Java's take two arguments; JavaScript's take +// any number, folded pairwise, one argument being itself and none an infinity +// the subset has no form for. +func (p *opaqueParser) mathExtremum(fn string, args []translated, arity func(int) *refusal) (translated, *refusal) { + if p.d == dialectJava { if err := arity(2); err != nil { return translated{}, err } - if err := numbersAt(fn, args[0], args[1]); err != nil { - return translated{}, err - } - return binary(args[0], "**", args[1], loosePower, "Real"), nil - case "java.util.Collections.max", "java.util.Collections.min", "Collections.max", "Collections.min": - if err := arity(1); err != nil { + } else if len(args) == 0 { + return translated{}, &refusal{kind: refusedCall, token: fn, why: fn + " with no arguments yields an infinity, which has no v2 form in the subset"} + } + acc := args[0] + if err := numbersAt(fn, acc); err != nil { + return translated{}, err + } + for _, arg := range args[1:] { + if err := numbersAt(fn, acc, arg); err != nil { return translated{}, err } - s := args[0] - if !s.plural { - return translated{}, &refusal{kind: refusedType, token: fn, why: "the argument is a single value, not a collection"} - } - if s.held() != "" && !isNumeric(s.scalar) { - return translated{}, &refusal{kind: refusedType, token: fn, why: "the collection holds " + s.held() + " values, not numbers"} - } - which := fn[strings.LastIndex(fn, ".")+1:] - return translated{expr: s.operand() + "->ControlFunctions::reduce { in x; in y; " + extremum(which, s.scalar) + "(x, y) }", scalar: s.scalar, atomic: true}, nil + scalar := arithmeticScalar("+", acc.scalar, arg.scalar) + acc = translated{expr: extremum(fn[5:], scalar) + "(" + acc.expr + ", " + arg.expr + ")", scalar: scalar, atomic: true} } - return translated{}, &refusal{kind: refusedCall, token: fn} + return acc, nil +} + +// mathRoundish writes Math.floor, ceil and the script round. Java's floor and +// ceil answer a double, so a `/` after them is real division; its round answers a long. +func (p *opaqueParser) mathRoundish(fn string, args []translated, arity func(int) *refusal) (translated, *refusal) { + if err := arity(1); err != nil { + return translated{}, err + } + if err := numbersAt(fn, args[0], args[0]); err != nil { + return translated{}, err + } + yields := "Integer" + if p.d == dialectJava && fn != mathRound { + yields = "Real" + } + switch fn { + case "Math.ceil": + // -floor(-x) would overflow at the least Integer; the extension library's ceiling does not. + return translated{expr: "OpenSysMLMathFunctions::ceiling(" + args[0].expr + ")", scalar: yields, atomic: true}, nil + case mathRound: + // JavaScript and Java round a half toward +∞, where RealFunctions::round rounds it away from zero. + half := translated{expr: "0.5", scalar: "Real", atomic: true, lit: "real"} + return translated{expr: "RealFunctions::floor(" + binary(args[0], "+", half, looseAdditive, "Real").expr + ")", scalar: "Integer", atomic: true}, nil + default: + return translated{expr: "RealFunctions::floor(" + args[0].expr + ")", scalar: yields, atomic: true}, nil + } +} + +// collectionExtremum writes a Collections.max/min as a reduce over the extremum. +func collectionExtremum(fn string, s translated) (translated, *refusal) { + if !s.plural { + return translated{}, &refusal{kind: refusedType, token: fn, why: "the argument is a single value, not a collection"} + } + if s.held() != "" && !isNumeric(s.scalar) { + return translated{}, &refusal{kind: refusedType, token: fn, why: "the collection holds " + s.held() + " values, not numbers"} + } + which := fn[strings.LastIndex(fn, ".")+1:] + return translated{expr: s.operand() + "->ControlFunctions::reduce { in x; in y; " + extremum(which, s.scalar) + "(x, y) }", scalar: s.scalar, atomic: true}, nil } // arguments reads a call's arguments after its opening parenthesis, through the closing one. diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index fc00f69eb..8b0412e3c 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -18,6 +18,15 @@ const ( ) // stateMachineBody writes a state machine's regions as the body of its state def. + +// The note fragments the writer repeats. +const ( + doAction = "do action" + exitAction = "exit action" + notRun = " is not run: " + performsIts = "a state performs its " +) + func (m *migration) stateMachineBody(sm *sysmlv1.Element) { m.parameters(sm, sm) for _, c := range sm.Children { @@ -32,7 +41,7 @@ func (m *migration) stateMachineBody(sm *sysmlv1.Element) { for _, cp := range sm.Owned("connectionPoint") { m.connectionPoint(cp) } - m.regions(sm, m.populatedRegions(sm), false, func() {}) + m.regions(sm, m.populatedRegions(sm), false, func() { /* no extra nesting to write */ }) } // nameMachine names every vertex of a machine down through its nested regions ahead of writing, @@ -463,10 +472,10 @@ func (s *stateRegion) state(v *sysmlv1.Element) { entered := entry != nil && s.m.inlineBehavior("entry action", entry, v) between := func() { if do != nil { - s.m.inlineBehavior("do action", do, v) + s.m.inlineBehavior(doAction, do, v) } if exit != nil { - s.m.inlineBehavior("exit action", exit, v) + s.m.inlineBehavior(exitAction, exit, v) } } if len(regions) == 0 { @@ -537,56 +546,7 @@ func inheritedStateNamesSet() map[string]bool { // kw of the current body, and reports whether anything was written. func (m *migration) inlineBehavior(kw string, b, owner *sysmlv1.Element) bool { if b.Parent != owner { - if !m.written(b) { - m.w.lines(commentLines(kw + " " + qualifiedName(b) + " has no v2 declaration")) - m.add(b, Unmapped, "", "the behavior is not written; "+describe(owner)+" names it as its "+kw) - return false - } - if cat, _ := m.classify(b); cat != catActionDef { - m.w.lines(commentLines(kw + " " + qualifiedName(b) + " is written as a " + cat.keyword() + ", which no state runs")) - m.downgrade(b, describe(owner)+" names it as its "+kw+", which a "+cat.keyword()+" cannot be") - return false - } - var ins []string - note := "also run as the " + kw + " of " + describe(owner) - if c := m.contextOf(b); c != nil { - expr, cnote := m.contextBinding(c, classifierOf(owner), "this") - if expr == "" { - m.w.lines(commentLines(kw + " " + qualifiedName(b) + " is not run: " + cnote)) - m.downgrade(b, "not run as the "+kw+" of "+describe(owner)+": "+cnote) - m.add(owner, Approximated, "", "its "+kw+" "+qualifiedName(b)+" is not run: "+cnote) - return false - } - ins = append(ins, "in "+writeName(c.name)+" = "+expr) - note = joinNotes(note, cnote) - } - if params := inParameters(b); len(params) > 0 && owner.Type != "Transition" { - why := "a state performs its " + kw + " with no arguments; " + m.carrierWhy(owner, kw) - bound := m.carrierBindings(owner, b) - switch { - case bound != nil && kw != "exit action": - for _, p := range params { - ins = append(ins, m.parameterBinding(p, m.nameFor(p), bound[p])) - } - note = joinNotes(note, "its parameters take the attributes of the signal the transitions into the state accept") - case slices.IndexFunc(params, requiresValue) >= 0: - p := params[slices.IndexFunc(params, requiresValue)] - why = "its parameter " + m.nameFor(p) + " must hold a value that nothing supplies: " + why - m.w.lines(commentLines(kw + " " + qualifiedName(b) + " is not run: " + why)) - m.downgrade(b, "not run as the "+kw+" of "+describe(owner)+": "+why) - m.add(owner, Approximated, "", "its "+kw+" "+qualifiedName(b)+" is not run: "+why) - return false - default: - note = joinNotes(note, "its parameters take no value: "+why) - } - } - line := kw + " : " + m.ref(b, owner) + ";" - if len(ins) > 0 { - line = kw + " : " + m.ref(b, owner) + " { " + strings.Join(ins, "; ") + "; }" - } - m.w.line(line) - m.downgrade(b, note) - return true + return m.referencedBehavior(kw, b, owner) } saved := m.scope m.scope = b @@ -594,14 +554,14 @@ func (m *migration) inlineBehavior(kw string, b, owner *sysmlv1.Element) bool { if owner.Type != "Transition" { bound := m.carrierBindings(owner, b) switch { - case bound != nil && kw != "exit action": + case bound != nil && kw != exitAction: savedBound, savedNote := m.bound, m.boundNote m.bound, m.boundNote = bound, "an attribute of the signal the transitions into the state accept" defer func() { m.bound, m.boundNote = savedBound, savedNote }() case owner.Type == "State": - m.unbound(b, joinNotes("a state performs its "+kw+" with no arguments", m.carrierWhy(owner, kw))) + m.unbound(b, joinNotes(performsIts+kw+" with no arguments", m.carrierWhy(owner, kw))) default: - m.unbound(b, "a state performs its "+kw+" with no arguments; only a transition's effect receives the accepted signal") + m.unbound(b, performsIts+kw+" with no arguments; only a transition's effect receives the accepted signal") } } header := kw @@ -612,6 +572,12 @@ func (m *migration) inlineBehavior(kw string, b, owner *sysmlv1.Element) bool { } header += " " + writeName(name) } + return m.writeInlineBody(kw, b, owner, header) +} + +// writeInlineBody writes an inline behavior's block by its kind: an activity's +// full body, an opaque body's statements or a comment, nothing otherwise. +func (m *migration) writeInlineBody(kw string, b, owner *sysmlv1.Element, header string) bool { switch b.Type { case "Activity": m.w.block(header, func() { @@ -648,6 +614,71 @@ func (m *migration) inlineBehavior(kw string, b, owner *sysmlv1.Element) bool { return false } +// referencedBehavior writes `kw : ref` for a behavior written elsewhere, binding +// its context and signal-carried parameters where it can and reporting why not. +func (m *migration) referencedBehavior(kw string, b, owner *sysmlv1.Element) bool { + if !m.written(b) { + m.w.lines(commentLines(kw + " " + qualifiedName(b) + " has no v2 declaration")) + m.add(b, Unmapped, "", "the behavior is not written; "+describe(owner)+" names it as its "+kw) + return false + } + if cat, _ := m.classify(b); cat != catActionDef { + m.w.lines(commentLines(kw + " " + qualifiedName(b) + " is written as a " + cat.keyword() + ", which no state runs")) + m.downgrade(b, describe(owner)+" names it as its "+kw+", which a "+cat.keyword()+" cannot be") + return false + } + var ins []string + note := "also run as the " + kw + " of " + describe(owner) + if c := m.contextOf(b); c != nil { + expr, cnote := m.contextBinding(c, classifierOf(owner), "this") + if expr == "" { + m.w.lines(commentLines(kw + " " + qualifiedName(b) + notRun + cnote)) + m.downgrade(b, "not run as the "+kw+" of "+describe(owner)+": "+cnote) + m.add(owner, Approximated, "", "its "+kw+" "+qualifiedName(b)+notRun+cnote) + return false + } + ins = append(ins, "in "+writeName(c.name)+" = "+expr) + note = joinNotes(note, cnote) + } + if params := inParameters(b); len(params) > 0 && owner.Type != "Transition" { + var ok bool + ins, note, ok = m.parameterIns(kw, b, owner, params, ins, note) + if !ok { + return false + } + } + line := kw + " : " + m.ref(b, owner) + ";" + if len(ins) > 0 { + line = kw + " : " + m.ref(b, owner) + " { " + strings.Join(ins, "; ") + "; }" + } + m.w.line(line) + m.downgrade(b, note) + return true +} + +// parameterIns binds a referenced behavior's parameters: the carrier's signal +// attributes when it has one, otherwise it reports what nothing supplies. +func (m *migration) parameterIns(kw string, b, owner *sysmlv1.Element, params []*sysmlv1.Element, ins []string, note string) ([]string, string, bool) { + why := performsIts + kw + " with no arguments; " + m.carrierWhy(owner, kw) + bound := m.carrierBindings(owner, b) + switch { + case bound != nil && kw != exitAction: + for _, p := range params { + ins = append(ins, m.parameterBinding(p, m.nameFor(p), bound[p])) + } + return ins, joinNotes(note, "its parameters take the attributes of the signal the transitions into the state accept"), true + case slices.IndexFunc(params, requiresValue) >= 0: + p := params[slices.IndexFunc(params, requiresValue)] + why = "its parameter " + m.nameFor(p) + " must hold a value that nothing supplies: " + why + m.w.lines(commentLines(kw + " " + qualifiedName(b) + notRun + why)) + m.downgrade(b, "not run as the "+kw+" of "+describe(owner)+": "+why) + m.add(owner, Approximated, "", "its "+kw+" "+qualifiedName(b)+notRun+why) + return ins, note, false + default: + return ins, joinNotes(note, "its parameters take no value: "+why), true + } +} + // path names vertex v from region s.r: its name when the region holds it, else // its name qualified from the state def down, which resolves from any region // of the machine; false for a vertex of another machine or one not written. @@ -778,30 +809,10 @@ func (s *stateRegion) connection(t, v *sysmlv1.Element, role string) (string, bo // transition writes a transition: one per trigger, since a v2 transition // accepts one, sharing the guard and effect. func (s *stateRegion) transition(t *sysmlv1.Element) { - src, tgt := s.m.model.Ref(t, "source"), s.m.model.Ref(t, "target") - internal := t.Attrs["kind"] == "internal" - if internal && tgt == nil && len(s.m.model.Unresolved(t, "target")) == 0 { - // An internal transition stays in its source; some tools write it with no target. - tgt = src - } - if src == nil || tgt == nil { - s.m.unmapped(t, joinNotes(s.m.dangling(t, "source", "target"), "the transition lacks an end")) - return - } - if pseudoKind(src) == "initial" { - // Written as the region's entry. + src, tgt, internal, ok := s.transitionEnds(t) + if !ok { return } - if internal { - if src.Type != "State" { - s.m.unmapped(t, "the source "+describe(src)+isA+kindOf(src)+", and only a state has an internal transition") - return - } - if tgt != src { - s.m.unmapped(t, "an internal transition targets "+describe(tgt)+", not its source "+describe(src)+"; whether it stays or moves cannot be told") - return - } - } from, ok := s.source(t, src) if !ok { s.m.unmapped(t, "the source "+describe(src)+isA+kindOf(src)+outsideMachine) @@ -815,29 +826,95 @@ func (s *stateRegion) transition(t *sysmlv1.Element) { } } triggers := t.Owned("trigger") - var notes []string + notes, done := s.kindNotes(t, src, internal, len(triggers), from) + if done { + return + } + guard, gnote := s.guard(t, src) + eff := firstOwned(t, "effect") + if gnote != "" { + notes = append(notes, gnote) + } + accepts, notes, info, written := s.transitionAccepts(t, triggers, eff, tgt, notes) + if len(triggers) > 0 && len(accepts) == 0 { + s.m.w.lines(commentLines("transition " + describe(t) + " from " + from + " to " + to + " not migrated — " + strings.Join(notes, "; "))) + s.m.add(t, Unmapped, "", "every trigger is dropped, so the transition would fire at once: "+strings.Join(notes, "; ")) + return + } + if len(accepts) == 0 { + accepts = []acceptance{{}} + if eff != nil { + s.m.unbound(eff, "the transition accepts no signal") + } + } else if written > 1 { + notes = append(notes, "written as "+strconv.Itoa(written)+" transitions, one per trigger") + } + tname := "" + if s.m.nameOf(t) != "" { + tname = freshIn(s.used, s.m.nameOf(t)) + } + s.writeAccepts(t, accepts, tname, guard, eff, from, to) + note := strings.Join(notes, "; ") + s.m.add(t, verdictFor(note), tname, joinNotes(note, strings.Join(info, "; "))) +} + +// kindNotes notes how an internal or local transition's semantics change in +// v2; done reports an internal transition with no trigger, which is unmapped. +func (s *stateRegion) kindNotes(t, src *sysmlv1.Element, internal bool, triggers int, from string) (notes []string, done bool) { switch { case internal: - if len(triggers) == 0 { + if triggers == 0 { s.m.unmapped(t, "an internal transition without a trigger has no v2 form: a self transition would fire again on every re-entry") - return + return nil, true } if !s.m.reentryObservable(src) { s.m.add(t, Mapped, "", "an internal transition is written as a self transition; "+from+" has no entry, exit or do behavior and no substates, so re-entering it is not observable") - break + return nil, false } - notes = append(notes, "an internal transition is written as a self transition, which exits and re-enters "+from+" where v1 stayed in it, running its exit and entry behaviors") + return []string{"an internal transition is written as a self transition, which exits and re-enters " + from + " where v1 stayed in it, running its exit and entry behaviors"}, false case t.Attrs["kind"] == "local": - notes = append(notes, "a local transition is written external: the composite state "+from+" exits and re-enters where v1 stayed in it, running its exit and entry behaviors") + return []string{"a local transition is written external: the composite state " + from + " exits and re-enters where v1 stayed in it, running its exit and entry behaviors"}, false } - guard, gnote := s.guard(t, src) - eff := firstOwned(t, "effect") + return nil, false +} + +// transitionEnds resolves a transition's source and target, checking the ends +// exist, an initial source is skipped, and an internal one stays in its source. +// False means the transition is done or unmapped. +func (s *stateRegion) transitionEnds(t *sysmlv1.Element) (src, tgt *sysmlv1.Element, internal, ok bool) { + src, tgt = s.m.model.Ref(t, "source"), s.m.model.Ref(t, "target") + internal = t.Attrs["kind"] == "internal" + if internal && tgt == nil && len(s.m.model.Unresolved(t, "target")) == 0 { + // An internal transition stays in its source; some tools write it with no target. + tgt = src + } + if src == nil || tgt == nil { + s.m.unmapped(t, joinNotes(s.m.dangling(t, "source", "target"), "the transition lacks an end")) + return nil, nil, internal, false + } + if pseudoKind(src) == "initial" { + // Written as the region's entry. + return nil, nil, internal, false + } + if internal { + if src.Type != "State" { + s.m.unmapped(t, "the source "+describe(src)+isA+kindOf(src)+", and only a state has an internal transition") + return nil, nil, internal, false + } + if tgt != src { + s.m.unmapped(t, "an internal transition targets "+describe(tgt)+", not its source "+describe(src)+"; whether it stays or moves cannot be told") + return nil, nil, internal, false + } + } + return src, tgt, internal, true +} + +// transitionAccepts writes one acceptance per usable trigger, noting the ones +// dropped; returns the acceptances, updated notes and infos, and how many were written. +func (s *stateRegion) transitionAccepts(t *sysmlv1.Element, triggers []*sysmlv1.Element, eff, tgt *sysmlv1.Element, notes []string) ([]acceptance, []string, []string, int) { var accepts []acceptance var info []string written := 0 - if gnote != "" { - notes = append(notes, gnote) - } for _, tr := range triggers { a, note, ok := s.triggerAccept(t, tr, eff, tgt) if !ok { @@ -853,23 +930,12 @@ func (s *stateRegion) transition(t *sysmlv1.Element) { s.m.add(tr, verdictFor(note), "", joinNotes(note, rinfo)) accepts = append(accepts, routes...) } - if len(triggers) > 0 && len(accepts) == 0 { - s.m.w.lines(commentLines("transition " + describe(t) + " from " + from + " to " + to + " not migrated — " + strings.Join(notes, "; "))) - s.m.add(t, Unmapped, "", "every trigger is dropped, so the transition would fire at once: "+strings.Join(notes, "; ")) - return - } - if len(accepts) == 0 { - accepts = []acceptance{{}} - if eff != nil { - s.m.unbound(eff, "the transition accepts no signal") - } - } else if written > 1 { - notes = append(notes, "written as "+strconv.Itoa(written)+" transitions, one per trigger") - } - tname := "" - if s.m.nameOf(t) != "" { - tname = freshIn(s.used, s.m.nameOf(t)) - } + return accepts, notes, info, written +} + +// writeAccepts writes one transition line per acceptance, each with the guard, +// an effect body when there is one, and the target. +func (s *stateRegion) writeAccepts(t *sysmlv1.Element, accepts []acceptance, tname, guard string, eff *sysmlv1.Element, from, to string) { for i, accept := range accepts { line := "transition " if tname != "" { @@ -886,8 +952,6 @@ func (s *stateRegion) transition(t *sysmlv1.Element) { } s.m.w.line(line + " then " + to + ";") } - note := strings.Join(notes, "; ") - s.m.add(t, verdictFor(note), tname, joinNotes(note, strings.Join(info, "; "))) } // routes writes the acceptances a trigger stands for: as read when taken from the object itself, @@ -969,11 +1033,11 @@ func (s *stateRegion) writeTransitionEffect(t, eff *sysmlv1.Element, accept acce s.m.w.line(line) s.m.w.indented(func() { if eff == nil { - s.m.w.block("do action", func() { s.m.w.line(accept.keeping) }) + s.m.w.block(doAction, func() { s.m.w.line(accept.keeping) }) } else { saved, savedKeep := s.m.bound, s.m.keeping s.m.bound, s.m.keeping = accept.bound, accept.keeping - s.m.inlineBehavior("do action", eff, t) + s.m.inlineBehavior(doAction, eff, t) s.m.bound, s.m.keeping = saved, savedKeep } s.m.w.line("then " + to + ";") diff --git a/internal/translate/migrate/swimlane.go b/internal/translate/migrate/swimlane.go index 03d08fe17..f14a04a22 100644 --- a/internal/translate/migrate/swimlane.go +++ b/internal/translate/migrate/swimlane.go @@ -27,6 +27,13 @@ type lane struct { // use records that a name or a call at e resolved through lane l, and so // through every other partition holding e that represents the same object. + +// The note fragments the writer repeats. +const ( + partRepresents = "the partition represents " + butNote = ", but " +) + func (ls *lanes) use(e *sysmlv1.Element, l *lane) { l.used = true for _, o := range ls.of[e] { @@ -207,41 +214,7 @@ func (m *migration) resolveLane(l *lane, ctx *sysmlv1.Element) { } l.represents = r if r.Type == "Property" || r.Type == "Port" { - owner := r.Parent - l.typ = m.model.Ref(r, "type") - name := writeName(m.nameOf(r)) - switch { - case !m.written(r) || m.nameOf(r) == "": - l.note = "the property " + qualifiedName(r) + " it represents has no v2 declaration" - case unreadableBounds(r): - l.note = "the partition represents " + qualifiedName(r) + ", but " + boundsNote(r) - case l.parent != nil && l.parent.expr != "" && l.parent.typ != nil && m.hasFeature(l.parent.typ, r): - l.expr = l.parent.expr + "." + name - l.plural = l.parent.plural || manyValued(r) - l.note = "the partition represents " + m.nameOf(r) + " of the enclosing partition's object, read as " + l.expr - case ctx == nil: - l.note = "the activity is in no classifier whose object could hold " + qualifiedName(r) - case m.hasFeature(ctx, r): - l.expr = "this." + name - l.plural = manyValued(r) - l.note = "the partition represents the context's " + m.nameOf(r) + ", read as " + l.expr - default: - if path, plural, unread := m.partPath(ctx, owner); unread != nil { - l.note = "the partition represents " + qualifiedName(r) + " through the part " + qualifiedName(unread) + ", but " + boundsNote(unread) - } else if path != "" { - l.expr = "this." + path + "." + name - l.plural = plural || manyValued(r) - l.note = "the partition represents " + qualifiedName(r) + ", read as " + l.expr - } else { - l.note = "no part of " + qualifiedName(ctx) + " is a " + qualifiedName(owner) + ", which holds the represented " + m.nameOf(r) - } - } - if l.typ == nil && l.expr != "" { - l.note += "; the property has no type, so no name resolves through it" - } - if l.plural { - l.note += "; it is a collection, so names read through it are collections and are not assigned" - } + m.laneFeatureObject(l, r, ctx) return } l.typ = r @@ -258,7 +231,7 @@ func (m *migration) resolveLane(l *lane, ctx *sysmlv1.Element) { l.note = "the partition represents the context object itself, a " + qualifiedName(r) default: if path, plural, unread := m.partPath(ctx, r); unread != nil { - l.note = "the partition represents a " + qualifiedName(r) + " through the part " + qualifiedName(unread) + ", but " + boundsNote(unread) + l.note = "the partition represents a " + qualifiedName(r) + " through the part " + qualifiedName(unread) + butNote + boundsNote(unread) } else if path != "" { l.expr, l.plural = "this."+path, plural l.note = "the partition represents the context's part " + path + ", a " + qualifiedName(r) @@ -271,6 +244,46 @@ func (m *migration) resolveLane(l *lane, ctx *sysmlv1.Element) { } } +// laneFeatureObject resolves a lane representing a property or port: the read +// expression, its type, and a note saying how it was found or why not. +func (m *migration) laneFeatureObject(l *lane, r, ctx *sysmlv1.Element) { + owner := r.Parent + l.typ = m.model.Ref(r, "type") + name := writeName(m.nameOf(r)) + switch { + case !m.written(r) || m.nameOf(r) == "": + l.note = "the property " + qualifiedName(r) + " it represents has no v2 declaration" + case unreadableBounds(r): + l.note = partRepresents + qualifiedName(r) + butNote + boundsNote(r) + case l.parent != nil && l.parent.expr != "" && l.parent.typ != nil && m.hasFeature(l.parent.typ, r): + l.expr = l.parent.expr + "." + name + l.plural = l.parent.plural || manyValued(r) + l.note = partRepresents + m.nameOf(r) + " of the enclosing partition's object, read as " + l.expr + case ctx == nil: + l.note = "the activity is in no classifier whose object could hold " + qualifiedName(r) + case m.hasFeature(ctx, r): + l.expr = "this." + name + l.plural = manyValued(r) + l.note = "the partition represents the context's " + m.nameOf(r) + ", read as " + l.expr + default: + if path, plural, unread := m.partPath(ctx, owner); unread != nil { + l.note = partRepresents + qualifiedName(r) + " through the part " + qualifiedName(unread) + butNote + boundsNote(unread) + } else if path != "" { + l.expr = "this." + path + "." + name + l.plural = plural || manyValued(r) + l.note = partRepresents + qualifiedName(r) + ", read as " + l.expr + } else { + l.note = "no part of " + qualifiedName(ctx) + " is a " + qualifiedName(owner) + ", which holds the represented " + m.nameOf(r) + } + } + if l.typ == nil && l.expr != "" { + l.note += "; the property has no type, so no name resolves through it" + } + if l.plural { + l.note += "; it is a collection, so names read through it are collections and are not assigned" + } +} + // partPath finds the one chain of composite parts, of any length, from // classifier c to an object of classifier target; "" when none or several // exist. plural reports whether any part on the chain holds several objects; diff --git a/internal/translate/migrate/translate.go b/internal/translate/migrate/translate.go index cedf9d123..74e3e34d8 100644 --- a/internal/translate/migrate/translate.go +++ b/internal/translate/migrate/translate.go @@ -91,112 +91,161 @@ func (m *migration) lanesAround(e *sysmlv1.Element) (*lanes, *sysmlv1.Element) { func (s *bodyScope) feature(path []string, write bool) (opaqueRef, *refusal) { m := s.m full := strings.Join(path, ".") - var expr string - var f *sysmlv1.Element - var plural bool // whether the objects the name reads through are a collection - var carrier string // the first such collection + a := s.featureAnchor(path, write) + if a.refusal != nil { + return opaqueRef{}, a.refusal + } + if a.res != nil { + return *a.res, nil + } + expr, f, plural, carrier := a.expr, a.f, a.plural, a.carrier + expr, f, plural, carrier, r := s.featureSteps(path, full, expr, f, plural, carrier) + if r != nil { + return opaqueRef{}, r + } + if unreadableBounds(f) { + return opaqueRef{}, boundsRefusal(f, full) + } + if dir, _ := parameterDirection(f); write && f.Type == "Parameter" && dir == "in" { + return opaqueRef{}, &refusal{kind: refusedConstruct, token: full, why: "an in parameter is not assigned"} + } + if write && plural { + return opaqueRef{}, &refusal{kind: refusedConstruct, token: full, + why: carrier + " is a collection, so the assignment would write through several objects"} + } + if s.lane != nil && s.viaLane { + m.useLane(s.scope, s.lane) + } + return opaqueRef{ + expr: expr, + scalar: m.scalarBase(m.typedAs(f)), + object: m.nonScalar(m.typedAs(f)), + plural: plural || manyValued(f), + }, nil +} + +// featureAnchor is what a dotted name's first step resolved to — the object and +// expression it reads — or the whole answer (res or refusal) when the name ends there. +type featureAnchor struct { + expr string + f *sysmlv1.Element + plural bool // whether the objects the name reads through are a collection + carrier string + res *opaqueRef + refusal *refusal +} + +// featureAnchor resolves the first step of path: `this`, a pin, a lane feature or a +// member visible from the scope. +func (s *bodyScope) featureAnchor(path []string, write bool) featureAnchor { + m := s.m + full := strings.Join(path, ".") if path[0] == "this" { - switch { - case s.lane != nil && s.lane.expr != "" && s.lane.typ != nil: - expr, f, plural, carrier = s.lane.expr, s.lane.typ, s.lane.plural, s.lane.expr - s.viaLane = true - case m.contextClassifier(s.scope) != nil: - expr, f = "this", m.contextClassifier(s.scope) - default: - return opaqueRef{}, &refusal{kind: refusedContext, token: "this", - why: "the body is in no classifier and its partition represents no object"} - } - if len(path) == 1 { - if write { - return opaqueRef{}, &refusal{kind: refusedContext, token: "this", why: "the object itself is not assigned"} - } - return opaqueRef{expr: expr, plural: plural}, nil - } - } else if p, d := m.pinNamed(s.scope, path[0]); p != nil { + return s.thisAnchor(path, write) + } + if p, d := m.pinNamed(s.scope, path[0]); p != nil { if write && len(path) == 1 && d.dir == "in" { - return opaqueRef{}, &refusal{kind: refusedConstruct, token: full, why: "an input pin is not assigned"} + return featureAnchor{refusal: &refusal{kind: refusedConstruct, token: full, why: "an input pin is not assigned"}} } - expr, f = writeName(d.name), p - } else { - name := path[0] - if lf := m.laneFeature(s.lane, name); lf != nil { - expr, f, plural, carrier = s.lane.expr+"."+writeName(m.nameOf(lf)), lf, s.lane.plural, s.lane.expr - s.viaLane = true - } else { - visible, hidden := m.visibleFrom(s.scope) - f = visible[name] - // The clock variable is the tool's global; any feature of that name shadows it. - if by, clock := m.clockNames()[name]; clock && f == nil && hidden[name] == nil && len(path) == 1 { - if write { - return opaqueRef{}, &refusal{kind: refusedConstruct, token: name, why: "the simulation clock is read, never assigned"} - } - s.clock = name + ", " + by - return opaqueRef{expr: clockRead, scalar: "Real"}, nil - } - switch { - case f == nil && hidden[name] != nil: - return opaqueRef{}, &refusal{kind: refusedName, token: name, - why: "it is private to " + qualifiedName(hidden[name].Parent)} - case f == nil: - return opaqueRef{}, &refusal{kind: refusedName, token: name, - why: joinNotes("nothing visible from "+qualifiedName(s.scope)+" is called "+name, s.clash)} - case f.Type != "Property" && f.Type != "Port" && f.Type != "Parameter": - return opaqueRef{}, &refusal{kind: refusedName, token: name, - why: "it is " + kindOf(f) + " " + qualifiedName(f) + ", not a feature a body reads"} - } - expr = writeName(m.nameOf(f)) - if m.ownedByClassifier(f, s.scope) { - expr = "this." + expr - } + return featureAnchor{expr: writeName(d.name), f: p} + } + return s.scopeAnchor(path, write) +} + +// thisAnchor resolves `this` to the lane's object when the lane represents one, +// else the context classifier. +func (s *bodyScope) thisAnchor(path []string, write bool) featureAnchor { + m := s.m + var a featureAnchor + switch { + case s.lane != nil && s.lane.expr != "" && s.lane.typ != nil: + a.expr, a.f, a.plural, a.carrier = s.lane.expr, s.lane.typ, s.lane.plural, s.lane.expr + s.viaLane = true + case m.contextClassifier(s.scope) != nil: + a.expr, a.f = "this", m.contextClassifier(s.scope) + default: + return featureAnchor{refusal: &refusal{kind: refusedContext, token: "this", + why: "the body is in no classifier and its partition represents no object"}} + } + if len(path) == 1 { + if write { + return featureAnchor{refusal: &refusal{kind: refusedContext, token: "this", why: "the object itself is not assigned"}} } + return featureAnchor{res: &opaqueRef{expr: a.expr, plural: a.plural}} } + return a +} + +// scopeAnchor resolves the first step of path to a lane feature, the simulation +// clock, or a member visible from the scope. +func (s *bodyScope) scopeAnchor(path []string, write bool) featureAnchor { + m := s.m + name := path[0] + if lf := m.laneFeature(s.lane, name); lf != nil { + s.viaLane = true + return featureAnchor{expr: s.lane.expr + "." + writeName(m.nameOf(lf)), f: lf, plural: s.lane.plural, carrier: s.lane.expr} + } + visible, hidden := m.visibleFrom(s.scope) + f := visible[name] + // The clock variable is the tool's global; any feature of that name shadows it. + if by, clock := m.clockNames()[name]; clock && f == nil && hidden[name] == nil && len(path) == 1 { + if write { + return featureAnchor{refusal: &refusal{kind: refusedConstruct, token: name, why: "the simulation clock is read, never assigned"}} + } + s.clock = name + ", " + by + return featureAnchor{res: &opaqueRef{expr: clockRead, scalar: "Real"}} + } + switch { + case f == nil && hidden[name] != nil: + return featureAnchor{refusal: &refusal{kind: refusedName, token: name, + why: "it is private to " + qualifiedName(hidden[name].Parent)}} + case f == nil: + return featureAnchor{refusal: &refusal{kind: refusedName, token: name, + why: joinNotes("nothing visible from "+qualifiedName(s.scope)+" is called "+name, s.clash)}} + case f.Type != "Property" && f.Type != "Port" && f.Type != "Parameter": + return featureAnchor{refusal: &refusal{kind: refusedName, token: name, + why: "it is " + kindOf(f) + " " + qualifiedName(f) + ", not a feature a body reads"}} + } + expr := writeName(m.nameOf(f)) + if m.ownedByClassifier(f, s.scope) { + expr = "this." + expr + } + return featureAnchor{expr: expr, f: f} +} + +// featureSteps resolves each further step of path as a feature of the last object, +// tracking whether the name reads through a collection. +func (s *bodyScope) featureSteps(path []string, full, expr string, f *sysmlv1.Element, plural bool, carrier string) (string, *sysmlv1.Element, bool, string, *refusal) { + m := s.m for _, step := range path[1:] { if unreadableBounds(f) { - return opaqueRef{}, boundsRefusal(f, full) + return expr, f, plural, carrier, boundsRefusal(f, full) } if !plural && manyValued(f) { plural, carrier = true, expr } typ := m.typedAs(f) if typ == nil { - return opaqueRef{}, &refusal{kind: refusedName, token: full, + return expr, f, plural, carrier, &refusal{kind: refusedName, token: full, why: qualifiedName(f) + " has no type, so no feature " + step} } visible, hidden := m.membersOf(typ, memberAny) next := visible[step] switch { case next == nil && hidden[step] != nil: - return opaqueRef{}, &refusal{kind: refusedName, token: full, + return expr, f, plural, carrier, &refusal{kind: refusedName, token: full, why: step + " is private to " + qualifiedName(hidden[step].Parent)} case next == nil: - return opaqueRef{}, &refusal{kind: refusedName, token: full, + return expr, f, plural, carrier, &refusal{kind: refusedName, token: full, why: qualifiedName(f) + " has no feature " + step} case next.Type != "Property" && next.Type != "Port": - return opaqueRef{}, &refusal{kind: refusedName, token: full, + return expr, f, plural, carrier, &refusal{kind: refusedName, token: full, why: step + " is " + kindOf(next) + ", not a feature a body reads"} } expr += "." + writeName(m.nameOf(next)) f = next } - if unreadableBounds(f) { - return opaqueRef{}, boundsRefusal(f, full) - } - if dir, _ := parameterDirection(f); write && f.Type == "Parameter" && dir == "in" { - return opaqueRef{}, &refusal{kind: refusedConstruct, token: full, why: "an in parameter is not assigned"} - } - if write && plural { - return opaqueRef{}, &refusal{kind: refusedConstruct, token: full, - why: carrier + " is a collection, so the assignment would write through several objects"} - } - if s.lane != nil && s.viaLane { - m.useLane(s.scope, s.lane) - } - return opaqueRef{ - expr: expr, - scalar: m.scalarBase(m.typedAs(f)), - object: m.nonScalar(m.typedAs(f)), - plural: plural || manyValued(f), - }, nil + return expr, f, plural, carrier, nil } // manyValued reports whether feature f is known to hold other than exactly one value. @@ -397,8 +446,23 @@ func (m *migration) clockNames() map[string]string { return m.clocks } m.clocks = map[string]string{} - var configs []*sysmlv1.Element - profiled := false + configs, profiled := m.simulationConfigs() + for name, by := range clockNamers(configs) { + if len(by) == 1 { + m.clocks[name] = "named by the configuration " + qualifiedName(by[0]) + } else { + m.clocks[name] = "named by " + strconv.Itoa(len(by)) + " simulation configurations" + } + } + if profiled && len(m.clocks) == 0 { + m.clocks[defaultClockName] = "the simulation profile's default name" + } + return m.clocks +} + +// simulationConfigs lists the elements carrying a simulation configuration, +// and whether any element applies the simulation profile at all. +func (m *migration) simulationConfigs() (configs []*sysmlv1.Element, profiled bool) { var walk func(e *sysmlv1.Element) walk = func(e *sysmlv1.Element) { for _, s := range e.Stereotypes { @@ -417,6 +481,11 @@ func (m *migration) clockNames() map[string]string { walk(r) } sort.Slice(configs, func(i, j int) bool { return configs[i].ID < configs[j].ID }) + return configs, profiled +} + +// clockNamers tallies which configurations name each clock variable. +func clockNamers(configs []*sysmlv1.Element) map[string][]*sysmlv1.Element { namers := map[string][]*sysmlv1.Element{} for _, e := range configs { for _, s := range e.Stereotypes { @@ -432,15 +501,5 @@ func (m *migration) clockNames() map[string]string { } } } - for name, by := range namers { - if len(by) == 1 { - m.clocks[name] = "named by the configuration " + qualifiedName(by[0]) - } else { - m.clocks[name] = "named by " + strconv.Itoa(len(by)) + " simulation configurations" - } - } - if profiled && len(m.clocks) == 0 { - m.clocks[defaultClockName] = "the simulation profile's default name" - } - return m.clocks + return namers } diff --git a/internal/translate/migrate/values.go b/internal/translate/migrate/values.go index e1287b509..f559baaae 100644 --- a/internal/translate/migrate/values.go +++ b/internal/translate/migrate/values.go @@ -18,6 +18,13 @@ import ( // valueExpr writes a UML value specification as a v2 expression. ok is false // when it has no v2 form; note explains an approximation or the refusal. + +// The note fragments the writer repeats. +const ( + notValueOf = " is not a value of " + whichNote = ", which " +) + func (m *migration) valueExpr(v, scope *sysmlv1.Element) (expr string, ok bool, note string) { return m.valueExprAs(v, scope, wanted{}) } @@ -42,7 +49,7 @@ func (m *migration) valueExprAs(v, scope *sysmlv1.Element, want wanted) (expr st return expr, ok, note } } - return "", false, "the " + kind + " " + qualifiedName(inst) + " is not a value of " + want.scalar + ", which " + want.holder + return "", false, "the " + kind + " " + qualifiedName(inst) + notValueOf + want.scalar + whichNote + want.holder } kind, text := literalKind(v, expr) if kind == "" { @@ -84,7 +91,7 @@ func literalAs(kind, expr, text string, want wanted) (value string, ok bool, not value, spelled := scalarLiteral(kind, expr, text, want.scalar) switch { case !spelled: - return "", false, "the " + kind + " " + expr + " is not a value of " + want.scalar + ", which " + want.holder + return "", false, "the " + kind + " " + expr + notValueOf + want.scalar + whichNote + want.holder case value != expr: return value, true, "the " + kind + " " + expr + " is written as the " + want.scalar + " " + want.holder } @@ -130,49 +137,61 @@ func (m *migration) directValue(v, scope *sysmlv1.Element, want wanted) (expr st case "LiteralNull": return "null", true, "" case "InstanceValue": - inst := m.model.Ref(v, "instance") - if inst == nil { - return "", false, "instance value refers to nothing in the document" - } - if inst.Type == "EnumerationLiteral" && inst.Parent != nil { - return m.ref(inst.Parent, scope) + "::" + writeName(inst.Name), true, "" - } - switch cat, _ := m.classify(inst); cat { - case catValue: - return m.ref(inst, scope), true, "" - case catIndividualDef: - return "", false, individualSubject + qualifiedName(inst) + " is a definition, which is not a v2 value" - } - return "", false, "instance value of a " + inst.Type + " has no v2 expression" + return m.instanceValue(v, scope) case "OpaqueExpression": - body, lang := opaqueBody(v) - if body == "" { - return "", false, "opaque expression has no body" - } - if dialectOf(lang) != dialectNone { - expr, note, refused := m.translatedExpr(body, lang, scope, want) - if refused == nil { - m.noted(valueOwner(v, scope), note) - return expr, true, "" - } - if refused.final(lang) { - return "", false, refused.note() - } - } - refs, ok := exprRefs(body) - if !ok { - return "", false, "opaque expression is not v2 expression syntax" + langNote(lang) - } - if problem := m.invisible(refs, scope); problem != "" { - return "", false, "opaque expression " + problem + langNote(lang) - } - return body, true, "opaque expression copied verbatim" + langNote(lang) + return m.opaqueValue(v, scope, want) case "Expression", "TimeExpression", "Duration", "Interval", "StringExpression": return "", false, "a UML " + v.Type + " tree has no v2 form" } return "", false, "no v2 form for a UML " + v.Type } +// instanceValue writes an instance value: an enumeration literal by qualified +// name, a value by reference, nothing else. +func (m *migration) instanceValue(v, scope *sysmlv1.Element) (expr string, ok bool, note string) { + inst := m.model.Ref(v, "instance") + if inst == nil { + return "", false, "instance value refers to nothing in the document" + } + if inst.Type == "EnumerationLiteral" && inst.Parent != nil { + return m.ref(inst.Parent, scope) + "::" + writeName(inst.Name), true, "" + } + switch cat, _ := m.classify(inst); cat { + case catValue: + return m.ref(inst, scope), true, "" + case catIndividualDef: + return "", false, individualSubject + qualifiedName(inst) + " is a definition, which is not a v2 value" + } + return "", false, "instance value of a " + inst.Type + " has no v2 expression" +} + +// opaqueValue writes an opaque expression: translated when its language is a +// known dialect, else copied verbatim once its references are visible. +func (m *migration) opaqueValue(v, scope *sysmlv1.Element, want wanted) (expr string, ok bool, note string) { + body, lang := opaqueBody(v) + if body == "" { + return "", false, "opaque expression has no body" + } + if dialectOf(lang) != dialectNone { + expr, note, refused := m.translatedExpr(body, lang, scope, want) + if refused == nil { + m.noted(valueOwner(v, scope), note) + return expr, true, "" + } + if refused.final(lang) { + return "", false, refused.note() + } + } + refs, ok := exprRefs(body) + if !ok { + return "", false, "opaque expression is not v2 expression syntax" + langNote(lang) + } + if problem := m.invisible(refs, scope); problem != "" { + return "", false, "opaque expression " + problem + langNote(lang) + } + return body, true, "opaque expression copied verbatim" + langNote(lang) +} + func langNote(lang string) string { if lang == "" { return "" @@ -240,14 +259,14 @@ func (m *migration) featureValue(v, f, scope *sysmlv1.Element) (expr string, ok if v.Type == "InstanceValue" && t != nil { inst := m.model.Ref(v, "instance") if inst.Type == "InstanceSpecification" && !m.instanceOf(m.model.Refs(inst, "classifier"), t) { - return "", false, "the instance " + qualifiedName(inst) + " is not a " + qualifiedName(t) + ", which " + featureHolds + return "", false, "the instance " + qualifiedName(inst) + " is not a " + qualifiedName(t) + whichNote + featureHolds } if inst.Type == "EnumerationLiteral" && inst.Parent != t && m.written(t) { - return "", false, "the literal " + qualifiedName(inst) + " is not a " + qualifiedName(t) + ", which " + featureHolds + return "", false, "the literal " + qualifiedName(inst) + " is not a " + qualifiedName(t) + whichNote + featureHolds } } if m.scalarBase(t) == "" && strings.HasPrefix(v.Type, "Literal") && v.Type != "LiteralNull" && (m.structuredValueType(t) || m.written(t)) { - return "", false, "the literal " + expr + " is not a value of " + qualifiedName(t) + ", which has no scalar base" + return "", false, "the literal " + expr + notValueOf + qualifiedName(t) + ", which has no scalar base" } return expr, ok, note } diff --git a/scripts/download-doc-pdf-toolchain.sh b/scripts/download-doc-pdf-toolchain.sh index f667ed39f..5d5079250 100755 --- a/scripts/download-doc-pdf-toolchain.sh +++ b/scripts/download-doc-pdf-toolchain.sh @@ -17,6 +17,9 @@ # binary finds the pinned copies. set -euo pipefail +# HTTPS_ONLY pins curl to TLS URLs so no fetch can fall back to plain http. +HTTPS_ONLY="=https" + PANDOC_VERSION="3.10.2" PANDOC_SHA256_AMD64="c7edd535941c48be6a362081a748272837de81ae11777202d9c341d3d8261c9a" PANDOC_SHA256_ARM64="1c4d69f2a092bd47cb180e58a4aab7b9637101ced928252458c7d41a7f7fa71d" @@ -55,7 +58,7 @@ if [[ -x "$pandoc_dir/bin/pandoc" ]]; then else tarball="$dest/pandoc-$PANDOC_VERSION-linux-$pandoc_arch.tar.gz" echo "Fetching pandoc $PANDOC_VERSION ($pandoc_arch) ..." - curl -fsSL --proto '=https' --proto-redir '=https' -o "$tarball" \ + curl -fsSL --proto "$HTTPS_ONLY" --proto-redir "$HTTPS_ONLY" -o "$tarball" \ "https://github.com/jgm/pandoc/releases/download/$PANDOC_VERSION/pandoc-$PANDOC_VERSION-linux-$pandoc_arch.tar.gz" echo "$pandoc_sha256 $tarball" | sha256sum -c - tar -xzf "$tarball" -C "$dest" @@ -133,7 +136,7 @@ else else echo "Fetching Graphviz $GRAPHVIZ_VERSION (Ubuntu $ubuntu_version) ..." tarball="$dest/graphviz-$GRAPHVIZ_VERSION-debs.tar.xz" - curl -fsSL --proto '=https' --proto-redir '=https' -o "$tarball" \ + curl -fsSL --proto "$HTTPS_ONLY" --proto-redir "$HTTPS_ONLY" -o "$tarball" \ "https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/$GRAPHVIZ_VERSION/ubuntu_${ubuntu_version}_graphviz-$GRAPHVIZ_VERSION-debs.tar.xz" echo "$graphviz_sha256 $tarball" | sha256sum -c - debs="$dest/graphviz-debs" @@ -189,7 +192,7 @@ if [[ -s "$plantuml" ]]; then else echo "Fetching PlantUML $PLANTUML_VERSION ..." mkdir -p "$(dirname "$plantuml")" - curl -fsSL --proto '=https' --proto-redir '=https' -o "$plantuml.part" \ + curl -fsSL --proto "$HTTPS_ONLY" --proto-redir "$HTTPS_ONLY" -o "$plantuml.part" \ "https://github.com/plantuml/plantuml/releases/download/v$PLANTUML_VERSION/plantuml-$PLANTUML_VERSION.jar" echo "$PLANTUML_SHA256 $plantuml.part" | sha256sum -c - mv "$plantuml.part" "$plantuml" diff --git a/scripts/fuml-driver/io/opensysml/fuml/FumlExpected.java b/scripts/fuml-driver/io/opensysml/fuml/FumlExpected.java index 34656fd2e..270fd0fd4 100644 --- a/scripts/fuml-driver/io/opensysml/fuml/FumlExpected.java +++ b/scripts/fuml-driver/io/opensysml/fuml/FumlExpected.java @@ -328,6 +328,19 @@ private static List eventRecords(List lines, Map return out; } + /** The XMI id of the named activity, or of its one node with the action's name; null if shared. */ + private static String elementId(Map byName, String activity, String action) { + ActivityDecl decl = activity == null ? null : byName.get(activity); + if (decl == null) { + return null; + } + if (action == null) { + return decl.id; + } + List ids = decl.nodeIds.get(action); + return ids != null && ids.size() == 1 ? ids.get(0) : null; + } + } public static void main(String[] args) throws Exception { @@ -555,19 +568,6 @@ private static Map activitiesByName(List act return byName; } - /** The XMI id of the named activity, or of its one node with the action's name; null if shared. */ - private static String elementId(Map byName, String activity, String action) { - ActivityDecl decl = activity == null ? null : byName.get(activity); - if (decl == null) { - return null; - } - if (action == null) { - return decl.id; - } - List ids = decl.nodeIds.get(action); - return ids != null && ids.size() == 1 ? ids.get(0) : null; - } - private static List values(ValueList values, Map aliases, IdentityHashMap visiting, int depth) { List out = new ArrayList<>();