From a1a0c68baa587d9db2e9ec350a188b809b8f6834 Mon Sep 17 00:00:00 2001 From: Reid Spencer Date: Fri, 13 Feb 2026 10:16:31 -0500 Subject: [PATCH 1/5] Recategorize command/query handler warnings from MissingWarning to Warning These are semantic correctness issues (handler not producing expected output), not structurally missing elements. A command handler that doesn't send an event or a query handler that doesn't send a result is doing the wrong thing, not missing content. Co-Authored-By: Claude Opus 4.6 --- .../com/ossuminc/riddl/passes/validate/ValidationPass.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/passes/shared/src/main/scala/com/ossuminc/riddl/passes/validate/ValidationPass.scala b/passes/shared/src/main/scala/com/ossuminc/riddl/passes/validate/ValidationPass.scala index d6db7b6f9..906907128 100644 --- a/passes/shared/src/main/scala/com/ossuminc/riddl/passes/validate/ValidationPass.scala +++ b/passes/shared/src/main/scala/com/ossuminc/riddl/passes/validate/ValidationPass.scala @@ -195,7 +195,7 @@ case class ValidationPass( tells.exists(_.msg.messageKind == AggregateUseCase.EventCase) if !(foundSend || foundTell) then messages.add( - missing("Processing for commands should result in sending an event", omc.errorLoc) + warning("Processing for commands should result in sending an event", omc.errorLoc) ) case AggregateUseCase.QueryCase => val finder = Finder(omc.contents) @@ -207,7 +207,7 @@ case class ValidationPass( tells.exists(_.msg.messageKind == AggregateUseCase.ResultCase) if !(foundSend || foundTell) then messages.add( - missing("Processing for queries should result in sending a result", omc.errorLoc) + warning("Processing for queries should result in sending a result", omc.errorLoc) ) case _ => } From 0e8b87d66f754cb993967463b26bfe2313825e90 Mon Sep 17 00:00:00 2001 From: Reid Spencer Date: Fri, 13 Feb 2026 10:45:56 -0500 Subject: [PATCH 2/5] Add bastify/unbastify to Native CommandLoader and add UnbastifyCommand test Move BastifyCommand and UnbastifyCommand from commands/jvm to commands/shared so they compile for both JVM and Native platforms. Register both commands in the Native CommandLoader and shared Commands.loadCommandNamed. Add round-trip UnbastifyCommandTest. Co-Authored-By: Claude Opus 4.6 --- .../riddl/commands/UnbastifyCommandTest.scala | 100 ++++++++++++++++++ .../riddl/commands/CommandLoader.scala | 32 +++--- .../riddl/commands/BastifyCommand.scala | 0 .../ossuminc/riddl/commands/Commands.scala | 30 +++--- .../riddl/commands/UnbastifyCommand.scala | 0 5 files changed, 134 insertions(+), 28 deletions(-) create mode 100644 commands/jvm/src/test/scala/com/ossuminc/riddl/commands/UnbastifyCommandTest.scala rename commands/{jvm => shared}/src/main/scala/com/ossuminc/riddl/commands/BastifyCommand.scala (100%) rename commands/{jvm => shared}/src/main/scala/com/ossuminc/riddl/commands/UnbastifyCommand.scala (100%) diff --git a/commands/jvm/src/test/scala/com/ossuminc/riddl/commands/UnbastifyCommandTest.scala b/commands/jvm/src/test/scala/com/ossuminc/riddl/commands/UnbastifyCommandTest.scala new file mode 100644 index 000000000..534316b12 --- /dev/null +++ b/commands/jvm/src/test/scala/com/ossuminc/riddl/commands/UnbastifyCommandTest.scala @@ -0,0 +1,100 @@ +/* + * Copyright 2019-2026 Ossum, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.ossuminc.riddl.commands + +import com.ossuminc.riddl.utils.{pc, PlatformContext} +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.nio.file.{Files, Path} + +class UnbastifyCommandTest extends AnyWordSpec with Matchers { + + given io: PlatformContext = pc + + "UnbastifyCommand" should { + "round-trip RIDDL through bastify and unbastify" in { + val tempDir = Files.createTempDirectory("unbastify-test") + val tempInput = tempDir.resolve("test.riddl") + val bastFile = tempDir.resolve("test.bast") + val outputDir = tempDir.resolve("output") + + try { + // Step 1: Write a RIDDL source file + val riddlContent = """domain TestDomain is { + | type MyType is String + |} with { briefly "test" } + |""".stripMargin + Files.writeString(tempInput, riddlContent) + + // Step 2: Run bastify to produce a .bast file + val bastifyArgs = Array( + "--quiet", + "--show-missing-warnings=false", + "--show-style-warnings=false", + "bastify", + tempInput.toString + ) + + val bastifyResult = Commands.runMainForTest(bastifyArgs) + bastifyResult match { + case Left(messages) => + fail(s"bastify failed: ${messages.format}") + case Right(_) => + assert(Files.exists(bastFile), s"BAST file $bastFile was not created") + } + + // Step 3: Run unbastify on the .bast file + val unbastifyArgs = Array( + "--quiet", + "--show-missing-warnings=false", + "--show-style-warnings=false", + "unbastify", + bastFile.toString, + "-o", + outputDir.toString + ) + + val unbastifyResult = Commands.runMainForTest(unbastifyArgs) + unbastifyResult match { + case Left(messages) => + fail(s"unbastify failed: ${messages.format}") + case Right(_) => + // Step 4: Verify output directory contains .riddl file(s) + assert(Files.exists(outputDir), s"Output directory $outputDir was not created") + + val riddlFiles = Files.list(outputDir).toArray.map(_.asInstanceOf[Path]) + .filter(_.toString.endsWith(".riddl")) + assert(riddlFiles.nonEmpty, "No .riddl files generated in output directory") + + // Step 5: Verify output content contains expected domain definition + val outputContent = riddlFiles.map(p => Files.readString(p)).mkString("\n") + assert(outputContent.contains("TestDomain"), s"Output does not contain 'TestDomain': $outputContent") + } + } finally { + // Clean up + def deleteRecursively(path: Path): Unit = { + if Files.isDirectory(path) then + Files.list(path).forEach(p => deleteRecursively(p.asInstanceOf[Path])) + Files.deleteIfExists(path) + } + deleteRecursively(tempDir) + } + } + + "fail gracefully for non-existent input file" in { + val args = Array( + "--quiet", + "unbastify", + "nonexistent-file.bast" + ) + + val result = Commands.runMainForTest(args) + result.isLeft mustBe true + } + } +} diff --git a/commands/native/src/main/scala/com/ossuminc/riddl/commands/CommandLoader.scala b/commands/native/src/main/scala/com/ossuminc/riddl/commands/CommandLoader.scala index 4cd2eb756..81c28d623 100644 --- a/commands/native/src/main/scala/com/ossuminc/riddl/commands/CommandLoader.scala +++ b/commands/native/src/main/scala/com/ossuminc/riddl/commands/CommandLoader.scala @@ -23,26 +23,29 @@ object CommandLoader: def loadCommandNamed(name: String)(using io: PlatformContext): Either[Messages, Command[?]] = if io.options.verbose then io.log.info(s"Loading command: $name") else () name match - case "about" => Right(AboutCommand()) - case "dump" => Right(DumpCommand()) - case "flatten" => Right(FlattenCommand()) - case "from" => Right(FromCommand()) - case "help" => Right(HelpCommand()) - case "info" => Right(InfoCommand()) - case "onchange" => Right(OnChangeCommand()) - case "parse" => Right(ParseCommand()) - case "prettify" => Right(PrettifyCommand()) - case "repeat" => Right(RepeatCommand()) - case "stats" => Right(StatsCommand()) - case "validate" => Right(ValidateCommand()) - case "version" => Right(VersionCommand()) - case _ => Left(errors(s"No command found for '$name'")) + case "about" => Right(AboutCommand()) + case "bastify" => Right(BastifyCommand()) + case "dump" => Right(DumpCommand()) + case "flatten" => Right(FlattenCommand()) + case "from" => Right(FromCommand()) + case "help" => Right(HelpCommand()) + case "info" => Right(InfoCommand()) + case "onchange" => Right(OnChangeCommand()) + case "parse" => Right(ParseCommand()) + case "prettify" => Right(PrettifyCommand()) + case "repeat" => Right(RepeatCommand()) + case "stats" => Right(StatsCommand()) + case "unbastify" => Right(UnbastifyCommand()) + case "validate" => Right(ValidateCommand()) + case "version" => Right(VersionCommand()) + case _ => Left(errors(s"No command found for '$name'")) end match end loadCommandNamed def commandOptionsParser(using io: PlatformContext): OParser[Unit, ?] = val optionParsers = Seq( AboutCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]], + BastifyCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]], DumpCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]], FlattenCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]], FromCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]], @@ -53,6 +56,7 @@ object CommandLoader: PrettifyCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]], RepeatCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]], StatsCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]], + UnbastifyCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]], ValidateCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]], VersionCommand().getOptionsParser._1.asInstanceOf[OParser[Unit, CommandOptions]] ) diff --git a/commands/jvm/src/main/scala/com/ossuminc/riddl/commands/BastifyCommand.scala b/commands/shared/src/main/scala/com/ossuminc/riddl/commands/BastifyCommand.scala similarity index 100% rename from commands/jvm/src/main/scala/com/ossuminc/riddl/commands/BastifyCommand.scala rename to commands/shared/src/main/scala/com/ossuminc/riddl/commands/BastifyCommand.scala diff --git a/commands/shared/src/main/scala/com/ossuminc/riddl/commands/Commands.scala b/commands/shared/src/main/scala/com/ossuminc/riddl/commands/Commands.scala index ec577c20e..ed80effdc 100644 --- a/commands/shared/src/main/scala/com/ossuminc/riddl/commands/Commands.scala +++ b/commands/shared/src/main/scala/com/ossuminc/riddl/commands/Commands.scala @@ -31,20 +31,22 @@ object Commands: )(using io: PlatformContext): Either[Messages, Command[?]] = if io.options.verbose then io.log.info(s"Loading command: $name") else () name match - case "about" => Right(AboutCommand()) - case "dump" => Right(DumpCommand()) - case "flatten" => Right(FlattenCommand()) - case "from" => Right(FromCommand()) - case "help" => Right(HelpCommand()) - case "info" => Right(InfoCommand()) - case "onchange" => Right(OnChangeCommand()) - case "parse" => Right(ParseCommand()) - case "prettify" => Right(PrettifyCommand()) - case "repeat" => Right(RepeatCommand()) - case "stats" => Right(StatsCommand()) - case "validate" => Right(ValidateCommand()) - case "version" => Right(VersionCommand()) - case _ => Left(errors(s"No command found for '$name'")) + case "about" => Right(AboutCommand()) + case "bastify" => Right(BastifyCommand()) + case "dump" => Right(DumpCommand()) + case "flatten" => Right(FlattenCommand()) + case "from" => Right(FromCommand()) + case "help" => Right(HelpCommand()) + case "info" => Right(InfoCommand()) + case "onchange" => Right(OnChangeCommand()) + case "parse" => Right(ParseCommand()) + case "prettify" => Right(PrettifyCommand()) + case "repeat" => Right(RepeatCommand()) + case "stats" => Right(StatsCommand()) + case "unbastify" => Right(UnbastifyCommand()) + case "validate" => Right(ValidateCommand()) + case "version" => Right(VersionCommand()) + case _ => Left(errors(s"No command found for '$name'")) end match end loadCommandNamed diff --git a/commands/jvm/src/main/scala/com/ossuminc/riddl/commands/UnbastifyCommand.scala b/commands/shared/src/main/scala/com/ossuminc/riddl/commands/UnbastifyCommand.scala similarity index 100% rename from commands/jvm/src/main/scala/com/ossuminc/riddl/commands/UnbastifyCommand.scala rename to commands/shared/src/main/scala/com/ossuminc/riddl/commands/UnbastifyCommand.scala From e25f99ae6ca816a599d4db06927b67f819acde58 Mon Sep 17 00:00:00 2001 From: Reid Spencer Date: Fri, 13 Feb 2026 22:31:02 -0500 Subject: [PATCH 3/5] Add bast2FlatAST function for BAST-to-flattened-AST conversion Adds cross-platform bast2FlatAST(bytes) to RiddlLib trait, RiddlAPI JS facade (Int8Array input), TypeScript declaration, and a shared round-trip test. Enables ossum.ai Playground to load pre-built BAST files and get a flattened Root for display. Co-Authored-By: Claude Opus 4.6 --- .../scala/com/ossuminc/riddl/RiddlAPI.scala | 21 ++++++++++ riddlLib/js/types/index.d.ts | 24 ++++++++++++ .../scala/com/ossuminc/riddl/RiddlLib.scala | 38 ++++++++++++++++++- .../com/ossuminc/riddl/RiddlLibTest.scala | 17 +++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) diff --git a/riddlLib/js/src/main/scala/com/ossuminc/riddl/RiddlAPI.scala b/riddlLib/js/src/main/scala/com/ossuminc/riddl/RiddlAPI.scala index 40c1e52d7..ebe3ad2f5 100644 --- a/riddlLib/js/src/main/scala/com/ossuminc/riddl/RiddlAPI.scala +++ b/riddlLib/js/src/main/scala/com/ossuminc/riddl/RiddlAPI.scala @@ -490,6 +490,27 @@ object RiddlAPI { view end ast2bast + /** Deserialize BAST binary bytes to a flattened AST Root. + * + * Reads BAST binary data, converts to AST, flattens + * Include/BASTImport wrappers, and returns an opaque Root. + * + * @param bytes BAST binary data as Int8Array + * @return Result with opaque Root handle or errors + */ + @JSExport("bast2FlatAST") + def bast2FlatAST( + bytes: js.typedarray.Int8Array + ): js.Dynamic = + val scalaBytes = Array.tabulate(bytes.length)(i => + bytes(i) + ) + toJsResult( + RiddlLib.bast2FlatAST(scalaBytes), + root => root.asInstanceOf[js.Any] + ) + end bast2FlatAST + /** Get entity lifecycle (state machine) data. */ @JSExport("getEntityLifecycles") def getEntityLifecycles( diff --git a/riddlLib/js/types/index.d.ts b/riddlLib/js/types/index.d.ts index 02c7d4456..7c6af44c3 100644 --- a/riddlLib/js/types/index.d.ts +++ b/riddlLib/js/types/index.d.ts @@ -615,6 +615,30 @@ export declare const RiddlAPI: { * @returns BAST binary bytes as Int8Array */ ast2bast(root: RootAST): Int8Array; + + /** + * Deserialize BAST binary bytes to a flattened AST Root. + * + * Reads BAST binary data, deserializes to AST, removes + * Include/BASTImport wrapper nodes (flattening), and + * returns an opaque Root handle. + * + * Usage with HTTP URL: + * ```typescript + * const response = await fetch(bastUrl); + * const buffer = await response.arrayBuffer(); + * const result = RiddlAPI.bast2FlatAST( + * new Int8Array(buffer) + * ); + * if (result.succeeded) { + * const info = RiddlAPI.inspectRoot(result.value); + * } + * ``` + * + * @param bytes - BAST binary data as Int8Array + * @returns Result with opaque Root handle or errors + */ + bast2FlatAST(bytes: Int8Array): ParseResult; }; /** diff --git a/riddlLib/shared/src/main/scala/com/ossuminc/riddl/RiddlLib.scala b/riddlLib/shared/src/main/scala/com/ossuminc/riddl/RiddlLib.scala index 0f491d018..fc956bf64 100644 --- a/riddlLib/shared/src/main/scala/com/ossuminc/riddl/RiddlLib.scala +++ b/riddlLib/shared/src/main/scala/com/ossuminc/riddl/RiddlLib.scala @@ -6,8 +6,13 @@ package com.ossuminc.riddl -import com.ossuminc.riddl.language.AST.{Entity, Nebula, Root, Token} +import com.ossuminc.riddl.language.AST.{ + Author, Domain, Entity, Module, Nebula, Root, + RootContents, Token +} +import com.ossuminc.riddl.language.{Contents, Messages, toSeq} import com.ossuminc.riddl.language.Messages.Messages +import com.ossuminc.riddl.language.bast.BASTReader import com.ossuminc.riddl.language.parsing.{ RiddlParserInput, TopLevelParser } @@ -144,6 +149,19 @@ trait RiddlLib: root: Root )(using PlatformContext): Array[Byte] + /** Deserialize BAST binary bytes to a flattened AST Root. + * + * Reads BAST binary data, converts the resulting Nebula to + * a Root (filtering to valid RootContents), then flattens + * Include/BASTImport wrapper nodes. + * + * @param bytes The BAST binary data + * @return Right(Root) on success, Left(Messages) on failure + */ + def bast2FlatAST( + bytes: Array[Byte] + )(using PlatformContext): Either[Messages, Root] + /** Get the RIDDL library version string. */ def version: String @@ -397,6 +415,24 @@ object RiddlLib extends RiddlLib: end match end ast2bast + override def bast2FlatAST( + bytes: Array[Byte] + )(using PlatformContext): Either[Messages, Root] = + BASTReader.read(bytes).map { nebula => + val rootItems: Seq[RootContents] = + nebula.contents.toSeq.collect { + case d: Domain => d + case m: Module => m + case a: Author => a + } + val root = Root( + nebula.loc, + Contents[RootContents](rootItems*) + ) + flattenAST(root) + } + end bast2FlatAST + override def version: String = RiddlBuildInfo.version diff --git a/riddlLib/shared/src/test/scala/com/ossuminc/riddl/RiddlLibTest.scala b/riddlLib/shared/src/test/scala/com/ossuminc/riddl/RiddlLibTest.scala index 9ab1a4c4f..523c7b312 100644 --- a/riddlLib/shared/src/test/scala/com/ossuminc/riddl/RiddlLibTest.scala +++ b/riddlLib/shared/src/test/scala/com/ossuminc/riddl/RiddlLibTest.scala @@ -89,6 +89,23 @@ class RiddlLibTest extends AnyWordSpec with Matchers { RiddlLib.formatInfo must not be empty } + "bast2FlatAST round-trips parse to bast to flatAST" in { + val source = """domain TestDomain is { + context TestCtx is { ??? } + }""" + val parseResult = RiddlLib.parseString(source) + parseResult.isRight mustBe true + val root = parseResult.toOption.get + val bastBytes = RiddlLib.ast2bast(root) + bastBytes.length must be > 0 + + val flatResult = RiddlLib.bast2FlatAST(bastBytes) + flatResult.isRight mustBe true + val flatRoot = flatResult.toOption.get + flatRoot.domains must not be empty + flatRoot.domains.head.id.value mustBe "TestDomain" + } + "ast2bast converts parsed AST to bytes" in { val result = RiddlLib.parseString( "domain D is { context C is { ??? } }" From 6b29fd1ba7d258060077ffdb3677fcb94b82eeaa Mon Sep 17 00:00:00 2001 From: Reid Spencer Date: Fri, 13 Feb 2026 22:34:34 -0500 Subject: [PATCH 4/5] Fix BAST reader metadata flag, Epic/UseCase subtype, and unbastify output - Centralize metadata flag save/restore in readNode() instead of each individual read*Node() method - Add Epic/UseCase subtype byte to disambiguate during read - Use RepositorySchemaKind.fromOrdinal() instead of manual match - Simplify unbastify to emit single flattened .riddl file - Add RiddlModelsRoundTripTest for BAST round-trip validation Co-Authored-By: Claude Opus 4.6 --- .../commands/RiddlModelsRoundTripTest.scala | 243 ++++++++++++++++++ .../riddl/commands/UnbastifyCommand.scala | 37 +-- .../riddl/language/bast/BASTReader.scala | 53 +--- .../riddl/language/bast/BASTWriter.scala | 4 +- 4 files changed, 267 insertions(+), 70 deletions(-) create mode 100644 commands/jvm/src/test/scala/com/ossuminc/riddl/commands/RiddlModelsRoundTripTest.scala diff --git a/commands/jvm/src/test/scala/com/ossuminc/riddl/commands/RiddlModelsRoundTripTest.scala b/commands/jvm/src/test/scala/com/ossuminc/riddl/commands/RiddlModelsRoundTripTest.scala new file mode 100644 index 000000000..bfec2d176 --- /dev/null +++ b/commands/jvm/src/test/scala/com/ossuminc/riddl/commands/RiddlModelsRoundTripTest.scala @@ -0,0 +1,243 @@ +/* + * Copyright 2019-2026 Ossum, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.ossuminc.riddl.commands + +import com.ossuminc.riddl.utils.{pc, PlatformContext} +import org.ekrich.config.* +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.nio.file.{Files, Path} +import scala.jdk.StreamConverters.* + +/** Comprehensive BAST round-trip test against all riddl-models. + * + * For each model in the riddl-models repository: + * 1. Validate the original RIDDL source + * 2. Bastify (RIDDL -> BAST) + * 3. Unbastify (BAST -> RIDDL via PrettifyPass with flatten=true) + * 4. Prettify original with --single-file (same code path) + * 5. Compare unbastify output with prettified original + * + * Both outputs go through PrettifyPass, so any PrettifyPass + * formatting quirks cancel out. What we're testing is that + * the BAST serialization/deserialization is lossless. + */ +class RiddlModelsRoundTripTest extends AnyWordSpec with Matchers { + + given io: PlatformContext = pc + + private val riddlModelsDir = Path.of("../riddl-models") + + private val commonArgs = Array( + "--quiet", + "--show-missing-warnings=false", + "--show-style-warnings=false", + "--show-usage-warnings=false" + ) + + "BAST round-trip" should { + assume( + Files.isDirectory(riddlModelsDir), + "riddl-models not found at ../riddl-models — skipping" + ) + + val confFiles = discoverModels(riddlModelsDir) + assume(confFiles.nonEmpty, "No .conf files found in riddl-models") + + confFiles.foreach { case (confFile, riddlFile) => + val relPath = riddlModelsDir.relativize(confFile.getParent) + + s"round-trip $relPath" in { + roundTripTest(confFile, riddlFile) + } + } + } + + /** Discover models: find .conf files at depth 3, parse input-file */ + private def discoverModels(base: Path): Seq[(Path, Path)] = { + val allConf = Files + .walk(base, 5) + .filter(p => + p.toString.endsWith(".conf") && Files.isRegularFile(p) + ) + .toScala(Seq) + + allConf.flatMap { confFile => + val depth = base.relativize(confFile).getNameCount - 1 + if depth == 3 then + parseInputFile(confFile).map(riddlFile => + (confFile, riddlFile) + ) + else None + } + } + + /** Parse a .conf file to extract the input-file path */ + private def parseInputFile(confFile: Path): Option[Path] = { + try { + val config = ConfigFactory.parseFile(confFile.toFile) + if config.hasPath("validate.input-file") then + val inputFile = config.getString("validate.input-file") + Some(confFile.getParent.resolve(inputFile)) + else None + } catch { + case _: ConfigException => None + } + } + + /** Run the round-trip for a single model: + * 1. Validate original + * 2. Bastify + * 3. Unbastify (produces flattened .riddl) + * 4. Prettify original with --single-file + * 5. Compare unbastify output with prettified original + */ + private def roundTripTest( + confFile: Path, + riddlFile: Path + ): Unit = { + val tempDir = Files.createTempDirectory("bast-roundtrip") + val unbastDir = tempDir.resolve("unbast") + val prettyDir = tempDir.resolve("pretty-original") + + try { + val riddlPath = riddlFile.toAbsolutePath.toString + val bastPath = riddlPath.replaceAll("\\.riddl$", ".bast") + + // Step 1: Validate original + val validateArgs = + commonArgs ++ Array("validate", riddlPath) + Commands.runMainForTest(validateArgs) match { + case Left(messages) => + fail( + s"Step 1 (validate original) failed:\n" + + s"${messages.format}" + ) + case Right(_) => // ok + } + + // Step 2: Bastify + val bastifyArgs = + commonArgs ++ Array("bastify", riddlPath) + Commands.runMainForTest(bastifyArgs) match { + case Left(messages) => + fail(s"Step 2 (bastify) failed:\n${messages.format}") + case Right(_) => + assert( + Files.exists(Path.of(bastPath)), + s"BAST file not created: $bastPath" + ) + } + + try { + // Step 3: Unbastify (uses PrettifyPass with flatten=true) + val unbastifyArgs = commonArgs ++ Array( + "unbastify", + bastPath, + "-o", + unbastDir.toAbsolutePath.toString + ) + Commands.runMainForTest(unbastifyArgs) match { + case Left(messages) => + fail( + s"Step 3 (unbastify) failed:\n${messages.format}" + ) + case Right(_) => + assert( + Files.exists(unbastDir), + s"Unbastify output dir not created" + ) + } + + // Find the unbastify output file + val outputRiddlFiles = Files + .list(unbastDir) + .filter(p => p.toString.endsWith(".riddl")) + .toScala(Seq) + assert( + outputRiddlFiles.nonEmpty, + "No .riddl files in unbastify output" + ) + val unbastContent = + Files.readString(outputRiddlFiles.head) + + // Step 4: Prettify original with --single-file + val prettyArgs = commonArgs ++ Array( + "prettify", + riddlPath, + "-o", + prettyDir.toAbsolutePath.toString, + "-s", + "true" + ) + Commands.runMainForTest(prettyArgs) match { + case Left(messages) => + fail( + s"Step 4 (prettify original) failed:\n" + + s"${messages.format}" + ) + case Right(_) => // ok + } + + val prettyFile = + prettyDir.resolve("prettify-output.riddl") + assert( + Files.exists(prettyFile), + "Prettified original not found" + ) + val prettyContent = Files.readString(prettyFile) + + // Step 5: Compare unbastify output with prettified + // original. Both go through PrettifyPass, so format + // quirks cancel out. Differences = BAST data loss. + if unbastContent != prettyContent then + val lines1 = + prettyContent.linesIterator.toIndexedSeq + val lines2 = + unbastContent.linesIterator.toIndexedSeq + val firstDiff = lines1 + .zipAll(lines2, "", "") + .zipWithIndex + .find { case ((a, b), _) => a != b } + + firstDiff match { + case Some(((line1, line2), idx)) => + fail( + s"Round-trip differs at line ${idx + 1}:\n" + + s" original: $line1\n" + + s" round-trip: $line2" + ) + case None => + if lines1.length != lines2.length then + fail( + s"Round-trip differs in length: " + + s"${lines1.length} vs ${lines2.length}" + ) + end if + } + end if + } finally { + // Clean up .bast file created next to source + Files.deleteIfExists(Path.of(bastPath)) + } + } finally { + deleteRecursively(tempDir) + } + } + + private def deleteRecursively(path: Path): Unit = { + if Files.isDirectory(path) then + Files + .list(path) + .forEach(p => + deleteRecursively(p.asInstanceOf[Path]) + ) + end if + Files.deleteIfExists(path) + } +} diff --git a/commands/shared/src/main/scala/com/ossuminc/riddl/commands/UnbastifyCommand.scala b/commands/shared/src/main/scala/com/ossuminc/riddl/commands/UnbastifyCommand.scala index e6e8b436d..3403c1bc1 100644 --- a/commands/shared/src/main/scala/com/ossuminc/riddl/commands/UnbastifyCommand.scala +++ b/commands/shared/src/main/scala/com/ossuminc/riddl/commands/UnbastifyCommand.scala @@ -12,8 +12,8 @@ import com.ossuminc.riddl.language.Messages import com.ossuminc.riddl.language.Messages.Messages import com.ossuminc.riddl.language.bast.BASTReader import com.ossuminc.riddl.passes.* -import com.ossuminc.riddl.passes.prettify.{PrettifyOutput, PrettifyPass, RiddlFileEmitter} -import com.ossuminc.riddl.utils.{PlatformContext, URL} +import com.ossuminc.riddl.passes.prettify.{PrettifyOutput, PrettifyPass} +import com.ossuminc.riddl.utils.PlatformContext import org.ekrich.config.Config import scopt.OParser @@ -109,7 +109,9 @@ class UnbastifyCommand(using pc: PlatformContext) extends Command[UnbastifyComma // Step 5: Run PrettifyPass to convert AST back to RIDDL text // Use Nebula directly as the PassRoot (since Nebula extends Branch[?]) val passInput = PassInput(nebula) - val prettifyOptions = PrettifyPass.Options(flatten = false) + // BAST stores all content inline (includes are resolved), so + // flatten output to produce a single self-contained .riddl file + val prettifyOptions = PrettifyPass.Options(flatten = true) // Create the pass and run it using Pass.runThesePasses val passes: PassCreators = Seq( @@ -127,31 +129,16 @@ class UnbastifyCommand(using pc: PlatformContext) extends Command[UnbastifyComma case None => return Left(Messages.errors("PrettifyPass did not produce output")) case Some(prettifyOutput) => - // Step 7: Write output files + // Step 7: Write output as a single flattened file try { Files.createDirectories(outputDir) - var fileCount = 0 - prettifyOutput.state.withFiles { (file: RiddlFileEmitter) => - val content = file.toString - // Determine file path - use the URL path or default to top-level name - val filePath = if file.url.path.nonEmpty && file.url.path != "nada" then - outputDir.resolve(file.url.path) - else - outputDir.resolve(riddlName) - - // Create parent directories if needed - val parent = filePath.getParent - if parent != null && !Files.exists(parent) then - Files.createDirectories(parent) - end if - - Files.writeString(filePath, content, StandardCharsets.UTF_8) - fileCount += 1 - pc.log.info(s"Generated: $filePath") - } - - pc.log.info(s"Unbastify complete: $fileCount file(s) written to $outputDir") + val filePath = outputDir.resolve(riddlName) + val content = prettifyOutput.state.filesAsString + Files.writeString(filePath, content, StandardCharsets.UTF_8) + pc.log.info(s"Generated: $filePath") + + pc.log.info(s"Unbastify complete: 1 file written to $outputDir") Right(result) } catch { diff --git a/language/shared/src/main/scala/com/ossuminc/riddl/language/bast/BASTReader.scala b/language/shared/src/main/scala/com/ossuminc/riddl/language/bast/BASTReader.scala index f0a54a865..9220ad24c 100644 --- a/language/shared/src/main/scala/com/ossuminc/riddl/language/bast/BASTReader.scala +++ b/language/shared/src/main/scala/com/ossuminc/riddl/language/bast/BASTReader.scala @@ -322,6 +322,7 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { private def readNode(): RiddlValue = { val posBeforeNode = reader.position checkBoundary(s"readNode at position $posBeforeNode") + val savedMetadataFlag = currentNodeHasMetadata var tagByte = reader.readU8() debugLog(f"[DEBUG] readNode at pos $posBeforeNode: tagByte=${tagByte & 0xFF}%d (0x${tagByte & 0xFF}%02X)") @@ -470,6 +471,7 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { LiteralString(lastLocation, s"") } popContext() + currentNodeHasMetadata = savedMetadataFlag val posAfterNode = reader.position recordNodeRead(nodeName, posBeforeNode) debugLog(f"[DEBUG] finished $nodeName at pos $posAfterNode (read ${posAfterNode - posBeforeNode} bytes)") @@ -477,6 +479,7 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { } catch { case e: Exception => popContext() + currentNodeHasMetadata = savedMetadataFlag throw e // Re-throw with context already in error message } } @@ -492,45 +495,37 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { } private def readDomainNode(): Domain = { - val hasMetadata = currentNodeHasMetadata // Save before contents (children overwrite flag) - debugLog(f"[DEBUG] readDomainNode: hasMetadata=$hasMetadata at pos ${reader.position}") + debugLog(f"[DEBUG] readDomainNode: hasMetadata=$currentNodeHasMetadata at pos ${reader.position}") val loc = readLocation() val id = readIdentifierInline() // Inline - no tag debugLog(f"[DEBUG] readDomainNode: domain '${id.value}' at pos ${reader.position}") val contents = readContentsDeferred[OccursInDomain]().asInstanceOf[Contents[DomainContents]] - currentNodeHasMetadata = hasMetadata // Restore for metadata read - debugLog(f"[DEBUG] readDomainNode: reading metadata (hasMetadata=$hasMetadata) at pos ${reader.position}") + debugLog(f"[DEBUG] readDomainNode: reading metadata (hasMetadata=$currentNodeHasMetadata) at pos ${reader.position}") val metadata = readMetadataDeferred() debugLog(f"[DEBUG] readDomainNode: finished, metadata count=${metadata.length}") Domain(loc, id, contents, metadata) } private def readContextNode(): Context = { - val hasMetadata = currentNodeHasMetadata // Save before contents val loc = readLocation() val id = readIdentifierInline() // Inline - no tag val contents = readContentsDeferred[OccursInContext]().asInstanceOf[Contents[ContextContents]] - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Context(loc, id, contents, metadata) } private def readEntityNode(): Entity = { - val hasMetadata = currentNodeHasMetadata // Save before contents val loc = readLocation() val id = readIdentifierInline() // Inline - no tag val contents = readContentsDeferred[OccursInProcessor | State]().asInstanceOf[Contents[EntityContents]] - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Entity(loc, id, contents, metadata) } private def readModuleNode(): Module = { - val hasMetadata = currentNodeHasMetadata // Save before contents val loc = readLocation() val id = readIdentifierInline() // Inline - no tag val contents = readContentsDeferred[Domain | Author | Comment]().asInstanceOf[Contents[ModuleContents]] - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Module(loc, id, contents, metadata) } @@ -557,17 +552,14 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { // ========== Type Definitions ========== private def readTypeNode(): Type = { - val hasMetadata = currentNodeHasMetadata // Save before reading nested content val loc = readLocation() val id = readIdentifierInline() // Inline - no tag val typEx = readTypeExpression() - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Type(loc, id, typEx, metadata) } private def readFieldOrConstantOrMethod(): RiddlValue = { - val hasMetadata = currentNodeHasMetadata // Save before reading nested content val loc = readLocation() val idOrName = reader.peekU8() @@ -579,7 +571,6 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { // Check if this is a Constant (has value) or Method (has args) or Field // This is ambiguous - we need to peek ahead // For now, assume Field. Writer should disambiguate better. - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Field(loc, id, typEx, metadata) else @@ -601,7 +592,6 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { // ========== Processor Definitions ========== private def readAdaptorNode(): Adaptor = { - val hasMetadata = currentNodeHasMetadata // Save before contents val loc = readLocation() val id = readIdentifierInline() // Inline - no tag val directionTag = reader.readU8() @@ -612,13 +602,11 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { } val referent = readContextRef() val contents = readContentsDeferred[OccursInProcessor]().asInstanceOf[Contents[AdaptorContents]] - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Adaptor(loc, id, direction, referent, contents, metadata) } private def readFunctionNode(): Function = { - val hasMetadata = currentNodeHasMetadata // Save before contents val loc = readLocation() val id = readIdentifierInline() // Inline - no tag // Debug: Read input with type checking @@ -640,13 +628,11 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { } } val contents = readContentsDeferred[OccursInVitalDefinition | Statement | Function]().asInstanceOf[Contents[FunctionContents]] - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Function(loc, id, input, output, contents, metadata) } private def readSagaNode(): Saga = { - val hasMetadata = currentNodeHasMetadata // Save before contents val loc = readLocation() val id = readIdentifierInline() // Inline - no tag // Debug: Read input with type checking @@ -668,27 +654,22 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { } } val contents = readContentsDeferred[OccursInVitalDefinition | SagaStep]().asInstanceOf[Contents[SagaContents]] - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Saga(loc, id, input, output, contents, metadata) } private def readProjectorNode(): Projector = { - val hasMetadata = currentNodeHasMetadata // Save before contents val loc = readLocation() val id = readIdentifierInline() // Inline - no tag val contents = readContentsDeferred[OccursInProcessor | RepositoryRef]().asInstanceOf[Contents[ProjectorContents]] - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Projector(loc, id, contents, metadata) } private def readRepositoryNode(): Repository = { - val hasMetadata = currentNodeHasMetadata // Save before contents val loc = readLocation() val id = readIdentifierInline() // Inline - no tag val contents = readContentsDeferred[OccursInProcessor | Schema]().asInstanceOf[Contents[RepositoryContents]] - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Repository(loc, id, contents, metadata) } @@ -699,12 +680,7 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { val loc = readLocation() val id = readIdentifierInline() // Inline - no tag - val schemaKind = subtype match { - case 0 => RepositorySchemaKind.Relational - case 1 => RepositorySchemaKind.Document - case 2 => RepositorySchemaKind.Graphical - case _ => RepositorySchemaKind.Relational - } + val schemaKind = RepositorySchemaKind.fromOrdinal(subtype) // Read data map - keys use writeIdentifier (with tag) val dataCount = reader.readVarInt() @@ -731,7 +707,6 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { } private def readStreamletNode(): Streamlet = { - val hasMetadata = currentNodeHasMetadata // Save before contents val loc = readLocation() val id = readIdentifierInline() // Inline - no tag val shapeTag = reader.readU8() @@ -745,7 +720,6 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { case _ => Void(loc) } val contents = readContentsDeferred[OccursInProcessor | Inlet | Outlet | Connector]().asInstanceOf[Contents[StreamletContents]] - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Streamlet(loc, id, shape, contents, metadata) } @@ -753,26 +727,19 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { // ========== Epic Definitions ========== private def readEpicOrUseCaseNode(): RiddlValue = { - val hasMetadata = currentNodeHasMetadata // Save before contents + val subtype = reader.readU8() // 0 = Epic, 1 = UseCase val loc = readLocation() val id = readIdentifierInline() // Inline - no tag - // Check if next is UserStory (NODE_USER tag) or Contents - val saved = reader.position - val nextTag = reader.readU8() - reader.seek(saved) - - if nextTag == NODE_USER then + if subtype == 0 then val userStory = readUserStoryNode() val contents = readContentsDeferred[OccursInVitalDefinition | ShownBy | UseCase]().asInstanceOf[Contents[EpicContents]] - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Epic(loc, id, userStory, contents, metadata) else - // UseCase + // UseCase (subtype == 1) val userStory = readUserStoryNode() val contents = readContentsDeferred[UseCaseContents]() - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() UseCase(loc, id, userStory, contents, metadata) end if @@ -782,11 +749,9 @@ class BASTReader(bytes: Array[Byte])(using pc: PlatformContext) { /** Read a Handler node (Phase 7: handlers have dedicated NODE_HANDLER tag) */ private def readHandlerNode(): Handler = { - val hasMetadata = currentNodeHasMetadata // Save before contents val loc = readLocation() val id = readIdentifierInline() // Inline - no tag val contents = readContentsDeferred[HandlerContents]() - currentNodeHasMetadata = hasMetadata // Restore for metadata read val metadata = readMetadataDeferred() Handler(loc, id, contents, metadata) } diff --git a/language/shared/src/main/scala/com/ossuminc/riddl/language/bast/BASTWriter.scala b/language/shared/src/main/scala/com/ossuminc/riddl/language/bast/BASTWriter.scala index d9cb51743..c525adbfd 100644 --- a/language/shared/src/main/scala/com/ossuminc/riddl/language/bast/BASTWriter.scala +++ b/language/shared/src/main/scala/com/ossuminc/riddl/language/bast/BASTWriter.scala @@ -471,6 +471,7 @@ class BASTWriter(val writer: ByteBufferWriter, val stringTable: StringTable) { def writeEpic(e: Epic): Unit = { writeNodeTag(NODE_EPIC, e.metadata.nonEmpty) + writer.writeU8(0) // Subtype: 0 = Epic, 1 = UseCase writeLocation(e.loc) writeIdentifierInline(e.id) // Inline - no tag needed writeUserStory(e.userStory) @@ -663,7 +664,8 @@ class BASTWriter(val writer: ByteBufferWriter, val stringTable: StringTable) { // ========== Epic/UseCase Component Serialization ========== def writeUseCase(uc: UseCase): Unit = { - writeNodeTag(NODE_EPIC, uc.metadata.nonEmpty) // UseCase similar to Epic + writeNodeTag(NODE_EPIC, uc.metadata.nonEmpty) + writer.writeU8(1) // Subtype: 0 = Epic, 1 = UseCase writeLocation(uc.loc) writeIdentifierInline(uc.id) // Inline - no tag needed writeUserStory(uc.userStory) From fe95d1ee3b18668951da3ce1c884601ee984ec48 Mon Sep 17 00:00:00 2001 From: Reid Spencer Date: Sat, 14 Feb 2026 12:07:05 -0500 Subject: [PATCH 5/5] Add RiddlResult[T] ADT replacing Either[Messages, T] returns Introduces a cross-platform sealed trait RiddlResult[T] with Success[T] and Failure cases. All RiddlLib methods that returned Either[Messages, T] now return RiddlResult[T]. ast2bast changed from Array[Byte] to RiddlResult[Array[Byte]] to surface errors. TypeScript: ParseResult renamed to RiddlResult with deprecated alias for migration. RiddlResult provides map, flatMap, toEither for Scala interop and fromEither for bridging. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 13 +- NOTEBOOK.md | 74 ++++--- .../scala/com/ossuminc/riddl/RiddlAPI.scala | 55 ++++-- riddlLib/js/types/index.d.ts | 57 ++++-- .../scala/com/ossuminc/riddl/RiddlLib.scala | 184 ++++++++++++------ .../com/ossuminc/riddl/RiddlResult.scala | 74 +++++++ .../com/ossuminc/riddl/RiddlLibTest.scala | 152 +++++++++------ 7 files changed, 427 insertions(+), 182 deletions(-) create mode 100644 riddlLib/shared/src/main/scala/com/ossuminc/riddl/RiddlResult.scala diff --git a/CLAUDE.md b/CLAUDE.md index 7f3c58319..5b4378b42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -712,9 +712,8 @@ Then add to root aggregation: `.aggregate(..., mymodule, mymoduleJS, mymoduleNat `gh pr merge --admin --merge --delete-branch=false` to bypass branch protection when merging development→main for releases 45. **RiddlLib.ast2bast(root)** - Converts parsed AST to BAST - binary bytes. Shared trait returns `Array[Byte]`, JS facade - returns `Int8Array`. Uses BASTWriterPass internally. Test - verifies BAST magic header bytes + binary bytes. Returns `RiddlResult[Array[Byte]]` (shared) / + `RiddlResult` (TS). Uses BASTWriterPass internally 46. **Consumer update notes** - `RIDDL-UPDATE-NOTES.md` in synapify, riddl-mcp-server, ossum.ai covers 1.5.0 breaking change (opaque Root) and 1.7.0 new functions. Separate from @@ -734,3 +733,11 @@ Then add to root aggregation: `.aggregate(..., mymodule, mymoduleJS, mymoduleNat handlers. The parent-keyed overload fails because the resolution pass stores refs keyed under the OnMessageClause parent, not the adaptor's parent +50. **RiddlResult[T] replaces Either[Messages, T]** — Sealed + ADT with `Success[T]` and `Failure` cases. All RiddlLib + methods that previously returned `Either` now return + `RiddlResult`. Use `result.toEither` for backward compat. + TypeScript: `RiddlResult` (deprecated `ParseResult` + alias kept). Lives in `RiddlResult.scala` alongside + `RiddlLib.scala`. `ast2bast` now surfaces errors via + `RiddlResult` instead of silently returning empty array diff --git a/NOTEBOOK.md b/NOTEBOOK.md index 5cbf12abf..ce0918f3a 100644 --- a/NOTEBOOK.md +++ b/NOTEBOOK.md @@ -6,7 +6,7 @@ This is the central engineering notebook for the RIDDL project. It tracks curren ## Current Status -**Last Updated**: February 11, 2026 +**Last Updated**: February 14, 2026 **Scala Version**: 3.7.4 (overrides sbt-ossuminc's 3.3.7 LTS default due to compiler infinite loop bug with opaque @@ -248,29 +248,59 @@ The `pseudoCodeBlock` parser now allows comments before and/or after `???`: --- -## Changes Since v1.7.0 - -- Added `RiddlLib.ast2bast(root)` — converts parsed AST to - BAST binary bytes (shared trait + JS facade) -- Added TypeScript declarations for `getHandlerCompleteness`, - `getMessageFlow`, `getEntityLifecycles`, `ast2bast` with - return type interfaces -- Added `ast2bast` test in `RiddlLibTest` -- ValidationPass: Schema kind-specific deep checks (Flat, - TimeSeries, Hierarchical, Star data/link structure warnings) -- ValidationPass: Relational FK type mismatch upgraded from - Warning to Error -- ValidationPass: Handler message type vs container type - checks (Repository handles events → warning, Projector - handles commands/queries → warning) -- ValidationPass: Adaptor message direction compatibility - (inbound handling commands → error, outbound handling - events → error) -- StreamingValidation: Sink reverse reachability check (warns - if connected sink has no upstream path from any source) +## Changes Since v1.8.2 + +- **RiddlResult[T] ADT** — New sealed trait replacing + `Either[Messages, T]` as return type for all RiddlLib + methods. `Success[T]` and `Failure` cases with `map`, + `flatMap`, `toEither`. TypeScript: `RiddlResult` + (deprecated `ParseResult` alias kept for migration) +- **ast2bast returns RiddlResult** — Previously returned + `Array[Byte]` (silently empty on failure); now returns + `RiddlResult[Array[Byte]]` with proper error reporting +- **RiddlResult.scala** — New file in riddlLib/shared +- **TypeScript index.d.ts** — `ParseResult` renamed to + `RiddlResult` across all method signatures ## Session Log +### February 14, 2026 (RiddlResult ADT) + +**Focus**: Add cross-platform `RiddlResult[T]` result type to +replace ad-hoc `Either[Messages, T]` returns in RiddlLib. + +**Work Completed**: +1. **Created `RiddlResult.scala`** — Sealed trait with + `Success[T]` and `Failure` cases. Includes `map`, + `flatMap`, `toEither`, `fromEither` for interop. +2. **Updated `RiddlLib.scala`** — Changed all 10 methods + returning `Either[Messages, T]` to `RiddlResult[T]`. + Changed `ast2bast` from `Array[Byte]` to + `RiddlResult[Array[Byte]]` (surfaces errors properly). +3. **Updated `RiddlAPI.scala`** — `toJsResult` now accepts + `RiddlResult[T]`. `ast2bast` wrapped in `toJsResult`. +4. **Updated `index.d.ts`** — `ParseResult` renamed to + `RiddlResult`. Deprecated alias kept. `ast2bast` + return type changed to `RiddlResult`. +5. **Updated `RiddlLibTest.scala`** — All tests use + `RiddlResult.Success`/`RiddlResult.Failure` matching. + +**Test Results**: 13/13 JVM, 11/11 JS — all pass. + +**Version**: 1.9.0 (MINOR — new `RiddlResult` type, API +return type changes with `toEither` migration path) + +**Files Created**: +- `riddlLib/shared/.../RiddlResult.scala` + +**Files Modified**: +- `riddlLib/shared/.../RiddlLib.scala` +- `riddlLib/js/.../RiddlAPI.scala` +- `riddlLib/js/types/index.d.ts` +- `riddlLib/shared/test/.../RiddlLibTest.scala` + +--- + ### February 11, 2026 (ValidationPass Gap Analysis Completion) **Focus**: Implement remaining 4 items from the Feb 7 gap @@ -1737,4 +1767,4 @@ Tool( ## Git Information **Branch**: `development` -**Latest release**: 1.8.0 (February 11, 2026) +**Latest release**: 1.9.0 (February 14, 2026) diff --git a/riddlLib/js/src/main/scala/com/ossuminc/riddl/RiddlAPI.scala b/riddlLib/js/src/main/scala/com/ossuminc/riddl/RiddlAPI.scala index ebe3ad2f5..6deefd077 100644 --- a/riddlLib/js/src/main/scala/com/ossuminc/riddl/RiddlAPI.scala +++ b/riddlLib/js/src/main/scala/com/ossuminc/riddl/RiddlAPI.scala @@ -53,18 +53,20 @@ object RiddlAPI { // ── JS conversion helpers ────────────────────────────── - /** Convert Either to JavaScript-friendly result object */ + /** Convert RiddlResult to JavaScript-friendly result + * object. + */ private def toJsResult[T]( - either: Either[Messages, T], + result: RiddlResult[T], converter: T => js.Any = (v: T) => v.asInstanceOf[js.Any] ): js.Dynamic = - either match - case Right(value) => + result match + case RiddlResult.Success(value) => js.Dynamic.literal( succeeded = true, value = converter(value) ) - case Left(messages) => + case RiddlResult.Failure(messages) => js.Dynamic.literal( succeeded = false, errors = formatMessagesAsArray(messages) @@ -474,20 +476,24 @@ object RiddlAPI { /** Convert a parsed AST Root to BAST binary bytes. * - * Returns an Int8Array suitable for structured-clone - * transfer (e.g., Electron IPC). + * Returns a RiddlResult containing an Int8Array suitable + * for structured-clone transfer (e.g., Electron IPC). */ @JSExport("ast2bast") - def ast2bast(root: Root): js.typedarray.Int8Array = - val bytes = RiddlLib.ast2bast(root) - val buffer = new js.typedarray.ArrayBuffer(bytes.length) - val view = new js.typedarray.Int8Array(buffer) - var i = 0 - while i < bytes.length do - view(i) = bytes(i) - i += 1 - end while - view + def ast2bast(root: Root): js.Dynamic = + toJsResult( + RiddlLib.ast2bast(root), + bytes => + val buffer = + new js.typedarray.ArrayBuffer(bytes.length) + val view = new js.typedarray.Int8Array(buffer) + var i = 0 + while i < bytes.length do + view(i) = bytes(i) + i += 1 + end while + view.asInstanceOf[js.Any] + ) end ast2bast /** Deserialize BAST binary bytes to a flattened AST Root. @@ -511,6 +517,21 @@ object RiddlAPI { ) end bast2FlatAST + /** Convert an AST Root to RIDDL source text. + * + * Runs PrettifyPass to regenerate RIDDL source code from + * the AST. Produces a single flattened string with all + * definitions inline (no include directives). + * + * @param root The opaque Root handle from parseString + * or bast2FlatAST + * @return RIDDL source code as a string + */ + @JSExport("root2RiddlSource") + def root2RiddlSource(root: Root): String = + RiddlLib.root2RiddlSource(root) + end root2RiddlSource + /** Get entity lifecycle (state machine) data. */ @JSExport("getEntityLifecycles") def getEntityLifecycles( diff --git a/riddlLib/js/types/index.d.ts b/riddlLib/js/types/index.d.ts index 7c6af44c3..a79760f92 100644 --- a/riddlLib/js/types/index.d.ts +++ b/riddlLib/js/types/index.d.ts @@ -38,19 +38,25 @@ export interface ErrorInfo { } /** - * Result from a parse operation. + * Result from a RiddlAPI operation. * - * @typeParam T - The type of the parsed value (RootAST, Nebula, Token[], etc.) + * On success: succeeded=true, value contains the result. + * On failure: succeeded=false, errors contains diagnostics. + * + * @typeParam T - The type of the success value */ -export interface ParseResult { - /** Whether parsing succeeded without errors */ +export interface RiddlResult { + /** Whether the operation succeeded */ succeeded: boolean; - /** The parsed value, present when succeeded is true */ + /** The result value, present when succeeded is true */ value?: T; /** Array of error objects, present when succeeded is false */ errors?: ErrorInfo[]; } +/** @deprecated Use RiddlResult instead */ +export type ParseResult = RiddlResult; + /** * Lexical token from tokenization. */ @@ -293,7 +299,7 @@ export declare const RiddlAPI: { * } * ``` */ - parseString(source: string, origin?: string, verbose?: boolean): ParseResult; + parseString(source: string, origin?: string, verbose?: boolean): RiddlResult; /** * Parse a RIDDL source string with a custom platform context. @@ -309,7 +315,7 @@ export declare const RiddlAPI: { origin: string, verbose: boolean, context: PlatformContext - ): ParseResult; + ): RiddlResult; /** * Flatten Include and BASTImport wrapper nodes from the AST. @@ -391,7 +397,7 @@ export declare const RiddlAPI: { * `); * ``` */ - parseNebula(source: string, origin?: string, verbose?: boolean): ParseResult; + parseNebula(source: string, origin?: string, verbose?: boolean): RiddlResult; /** * Parse RIDDL source into a list of tokens for syntax highlighting. @@ -414,7 +420,7 @@ export declare const RiddlAPI: { * } * ``` */ - parseToTokens(source: string, origin?: string, verbose?: boolean): ParseResult; + parseToTokens(source: string, origin?: string, verbose?: boolean): RiddlResult; /** * Parse and validate RIDDL source, returning both syntax and semantic errors. @@ -528,7 +534,7 @@ export declare const RiddlAPI: { * } * ``` */ - getOutline(source: string, origin?: string): ParseResult; + getOutline(source: string, origin?: string): RiddlResult; /** * Get a recursive tree of all named definitions in RIDDL source. @@ -554,7 +560,7 @@ export declare const RiddlAPI: { * } * ``` */ - getTree(source: string, origin?: string): ParseResult; + getTree(source: string, origin?: string): RiddlResult; /** * Classify all handlers by behavioral completeness. @@ -570,7 +576,7 @@ export declare const RiddlAPI: { getHandlerCompleteness( source: string, origin?: string - ): ParseResult; + ): RiddlResult; /** * Build a directed message flow graph for the model. @@ -586,7 +592,7 @@ export declare const RiddlAPI: { getMessageFlow( source: string, origin?: string - ): ParseResult; + ): RiddlResult; /** * Extract entity lifecycle (state machine) data. @@ -602,19 +608,19 @@ export declare const RiddlAPI: { getEntityLifecycles( source: string, origin?: string - ): ParseResult; + ): RiddlResult; /** * Convert a parsed AST Root to BAST binary bytes. * * Serializes the AST using BASTWriterPass for efficient - * storage or IPC transport. The returned Int8Array is - * structured-clone compatible. + * storage or IPC transport. On success, the value is an + * Int8Array that is structured-clone compatible. * * @param root - The opaque Root handle from parseString - * @returns BAST binary bytes as Int8Array + * @returns Result with BAST binary bytes or errors */ - ast2bast(root: RootAST): Int8Array; + ast2bast(root: RootAST): RiddlResult; /** * Deserialize BAST binary bytes to a flattened AST Root. @@ -638,7 +644,20 @@ export declare const RiddlAPI: { * @param bytes - BAST binary data as Int8Array * @returns Result with opaque Root handle or errors */ - bast2FlatAST(bytes: Int8Array): ParseResult; + bast2FlatAST(bytes: Int8Array): RiddlResult; + + /** + * Convert an AST Root to RIDDL source text. + * + * Runs PrettifyPass to regenerate RIDDL source code from + * the AST. Produces a single flattened string with all + * definitions inline (no include directives). + * + * @param root - The opaque Root handle from parseString + * or bast2FlatAST + * @returns RIDDL source code as a string + */ + root2RiddlSource(root: RootAST): string; }; /** diff --git a/riddlLib/shared/src/main/scala/com/ossuminc/riddl/RiddlLib.scala b/riddlLib/shared/src/main/scala/com/ossuminc/riddl/RiddlLib.scala index fc956bf64..56a924bdd 100644 --- a/riddlLib/shared/src/main/scala/com/ossuminc/riddl/RiddlLib.scala +++ b/riddlLib/shared/src/main/scala/com/ossuminc/riddl/RiddlLib.scala @@ -10,7 +10,7 @@ import com.ossuminc.riddl.language.AST.{ Author, Domain, Entity, Module, Nebula, Root, RootContents, Token } -import com.ossuminc.riddl.language.{Contents, Messages, toSeq} +import com.ossuminc.riddl.language.{At, Contents, Messages, toSeq} import com.ossuminc.riddl.language.Messages.Messages import com.ossuminc.riddl.language.bast.BASTReader import com.ossuminc.riddl.language.parsing.{ @@ -26,6 +26,7 @@ import com.ossuminc.riddl.passes.analysis.{ EntityLifecycle, EntityLifecycleOutput, EntityLifecyclePass, MessageFlowOutput, MessageFlowPass } +import com.ossuminc.riddl.passes.prettify.{PrettifyOutput, PrettifyPass} import com.ossuminc.riddl.passes.transforms.FlattenPass import com.ossuminc.riddl.passes.validate.{ HandlerCompleteness, ValidationOutput, ValidationPass @@ -49,13 +50,14 @@ trait RiddlLib: * @param origin Origin identifier (e.g., filename) for error * messages * @param verbose Enable verbose failure messages - * @return Right(Root) on success, Left(Messages) on failure + * @return Success(Root) on success, Failure(Messages) on + * failure */ def parseString( source: String, origin: String = "string", verbose: Boolean = false - )(using PlatformContext): Either[Messages, Root] + )(using PlatformContext): RiddlResult[Root] /** Parse arbitrary RIDDL definitions (nebula). * @@ -66,7 +68,7 @@ trait RiddlLib: source: String, origin: String = "string", verbose: Boolean = false - )(using PlatformContext): Either[Messages, Nebula] + )(using PlatformContext): RiddlResult[Nebula] /** Parse RIDDL source into a list of tokens for syntax * highlighting. @@ -75,7 +77,7 @@ trait RiddlLib: source: String, origin: String = "string", verbose: Boolean = false - )(using PlatformContext): Either[Messages, List[Token]] + )(using PlatformContext): RiddlResult[List[Token]] /** Flatten Include and BASTImport wrapper nodes from the * AST. Modifies the Root in-place and returns the same @@ -99,13 +101,13 @@ trait RiddlLib: def getOutline( source: String, origin: String = "string" - )(using PlatformContext): Either[Messages, Seq[OutlineEntry]] + )(using PlatformContext): RiddlResult[Seq[OutlineEntry]] /** Get a recursive tree of all named definitions. */ def getTree( source: String, origin: String = "string" - )(using PlatformContext): Either[Messages, Seq[TreeNode]] + )(using PlatformContext): RiddlResult[Seq[TreeNode]] /** Get handler completeness classifications from validation. * @@ -115,7 +117,7 @@ trait RiddlLib: def getHandlerCompleteness( source: String, origin: String = "string" - )(using PlatformContext): Either[Messages, Seq[HandlerCompleteness]] + )(using PlatformContext): RiddlResult[Seq[HandlerCompleteness]] /** Get the message flow graph for a RIDDL model. * @@ -125,7 +127,7 @@ trait RiddlLib: def getMessageFlow( source: String, origin: String = "string" - )(using PlatformContext): Either[Messages, MessageFlowOutput] + )(using PlatformContext): RiddlResult[MessageFlowOutput] /** Get entity lifecycle (state machine) data. * @@ -135,7 +137,8 @@ trait RiddlLib: def getEntityLifecycles( source: String, origin: String = "string" - )(using PlatformContext): Either[Messages, Map[Entity, EntityLifecycle]] + )(using PlatformContext + ): RiddlResult[Map[Entity, EntityLifecycle]] /** Convert a parsed AST Root to BAST binary bytes. * @@ -143,11 +146,11 @@ trait RiddlLib: * binary format for efficient storage or IPC transport. * * @param root The parsed AST Root - * @return The BAST bytes + * @return Success(bytes) or Failure with diagnostics */ def ast2bast( root: Root - )(using PlatformContext): Array[Byte] + )(using PlatformContext): RiddlResult[Array[Byte]] /** Deserialize BAST binary bytes to a flattened AST Root. * @@ -156,11 +159,25 @@ trait RiddlLib: * Include/BASTImport wrapper nodes. * * @param bytes The BAST binary data - * @return Right(Root) on success, Left(Messages) on failure + * @return Success(Root) on success, Failure(Messages) on + * failure */ def bast2FlatAST( bytes: Array[Byte] - )(using PlatformContext): Either[Messages, Root] + )(using PlatformContext): RiddlResult[Root] + + /** Convert a parsed AST Root to RIDDL source text. + * + * Runs PrettifyPass with flatten=true to regenerate RIDDL + * source code from the AST as a single self-contained string + * with all definitions inline (no include directives). + * + * @param root The parsed AST Root + * @return RIDDL source code as a string + */ + def root2RiddlSource( + root: Root + )(using PlatformContext): String /** Get the RIDDL library version string. */ def version: String @@ -200,27 +217,33 @@ object RiddlLib extends RiddlLib: source: String, origin: String, verbose: Boolean - )(using PlatformContext): Either[Messages, Root] = + )(using PlatformContext): RiddlResult[Root] = val input = RiddlParserInput(source, originToURL(origin)) - TopLevelParser.parseInput(input, verbose) + RiddlResult.fromEither( + TopLevelParser.parseInput(input, verbose) + ) end parseString override def parseNebula( source: String, origin: String, verbose: Boolean - )(using PlatformContext): Either[Messages, Nebula] = + )(using PlatformContext): RiddlResult[Nebula] = val input = RiddlParserInput(source, originToURL(origin)) - TopLevelParser.parseNebula(input, verbose) + RiddlResult.fromEither( + TopLevelParser.parseNebula(input, verbose) + ) end parseNebula override def parseToTokens( source: String, origin: String, verbose: Boolean - )(using PlatformContext): Either[Messages, List[Token]] = + )(using PlatformContext): RiddlResult[List[Token]] = val input = RiddlParserInput(source, originToURL(origin)) - TopLevelParser.parseToTokens(input, verbose) + RiddlResult.fromEither( + TopLevelParser.parseToTokens(input, verbose) + ) end parseToTokens override def flattenAST( @@ -300,10 +323,10 @@ object RiddlLib extends RiddlLib: override def getOutline( source: String, origin: String - )(using PlatformContext): Either[Messages, Seq[OutlineEntry]] = + )(using PlatformContext): RiddlResult[Seq[OutlineEntry]] = val rpi = RiddlParserInput(source, originToURL(origin)) val parseResult = TopLevelParser.parseInput(rpi) - parseResult.flatMap { root => + RiddlResult.fromEither(parseResult).flatMap { root => val passInput = PassInput(root) val passesResult = Pass.runThesePasses( passInput, @@ -312,9 +335,9 @@ object RiddlLib extends RiddlLib: passesResult.outputs .outputOf[OutlineOutput](OutlinePass.name) match case Some(outlineOutput) => - Right(outlineOutput.entries) + RiddlResult.Success(outlineOutput.entries) case None => - Left(List.empty) + RiddlResult.Failure(List.empty) end match } end getOutline @@ -322,10 +345,10 @@ object RiddlLib extends RiddlLib: override def getTree( source: String, origin: String - )(using PlatformContext): Either[Messages, Seq[TreeNode]] = + )(using PlatformContext): RiddlResult[Seq[TreeNode]] = val rpi = RiddlParserInput(source, originToURL(origin)) val parseResult = TopLevelParser.parseInput(rpi) - parseResult.flatMap { root => + RiddlResult.fromEither(parseResult).flatMap { root => val passInput = PassInput(root) val passesResult = Pass.runThesePasses( passInput, @@ -334,9 +357,9 @@ object RiddlLib extends RiddlLib: passesResult.outputs .outputOf[TreeOutput](TreePass.name) match case Some(treeOutput) => - Right(treeOutput.tree) + RiddlResult.Success(treeOutput.tree) case None => - Left(List.empty) + RiddlResult.Failure(List.empty) end match } end getTree @@ -344,18 +367,21 @@ object RiddlLib extends RiddlLib: override def getHandlerCompleteness( source: String, origin: String - )(using PlatformContext): Either[Messages, Seq[HandlerCompleteness]] = + )(using PlatformContext + ): RiddlResult[Seq[HandlerCompleteness]] = val rpi = RiddlParserInput(source, originToURL(origin)) val parseResult = TopLevelParser.parseInput(rpi) - parseResult.flatMap { root => + RiddlResult.fromEither(parseResult).flatMap { root => val passInput = PassInput(root) val passesResult = Pass.runStandardPasses(passInput) passesResult.outputs - .outputOf[ValidationOutput](ValidationPass.name) match + .outputOf[ValidationOutput]( + ValidationPass.name + ) match case Some(vo) => - Right(vo.handlerCompleteness) + RiddlResult.Success(vo.handlerCompleteness) case None => - Left(List.empty) + RiddlResult.Failure(List.empty) end match } end getHandlerCompleteness @@ -363,19 +389,24 @@ object RiddlLib extends RiddlLib: override def getMessageFlow( source: String, origin: String - )(using PlatformContext): Either[Messages, MessageFlowOutput] = + )(using PlatformContext + ): RiddlResult[MessageFlowOutput] = val rpi = RiddlParserInput(source, originToURL(origin)) val parseResult = TopLevelParser.parseInput(rpi) - parseResult.flatMap { root => + RiddlResult.fromEither(parseResult).flatMap { root => val passInput = PassInput(root) - val passes = Pass.standardPasses :+ MessageFlowPass.creator() - val passesResult = Pass.runThesePasses(passInput, passes) + val passes = + Pass.standardPasses :+ MessageFlowPass.creator() + val passesResult = + Pass.runThesePasses(passInput, passes) passesResult.outputs - .outputOf[MessageFlowOutput](MessageFlowPass.name) match + .outputOf[MessageFlowOutput]( + MessageFlowPass.name + ) match case Some(mfo) => - Right(mfo) + RiddlResult.Success(mfo) case None => - Left(List.empty) + RiddlResult.Failure(List.empty) end match } end getMessageFlow @@ -383,26 +414,31 @@ object RiddlLib extends RiddlLib: override def getEntityLifecycles( source: String, origin: String - )(using PlatformContext): Either[Messages, Map[Entity, EntityLifecycle]] = + )(using PlatformContext + ): RiddlResult[Map[Entity, EntityLifecycle]] = val rpi = RiddlParserInput(source, originToURL(origin)) val parseResult = TopLevelParser.parseInput(rpi) - parseResult.flatMap { root => + RiddlResult.fromEither(parseResult).flatMap { root => val passInput = PassInput(root) - val passes = Pass.standardPasses :+ EntityLifecyclePass.creator() - val passesResult = Pass.runThesePasses(passInput, passes) + val passes = + Pass.standardPasses :+ EntityLifecyclePass.creator() + val passesResult = + Pass.runThesePasses(passInput, passes) passesResult.outputs - .outputOf[EntityLifecycleOutput](EntityLifecyclePass.name) match + .outputOf[EntityLifecycleOutput]( + EntityLifecyclePass.name + ) match case Some(elo) => - Right(elo.lifecycles) + RiddlResult.Success(elo.lifecycles) case None => - Left(List.empty) + RiddlResult.Failure(List.empty) end match } end getEntityLifecycles override def ast2bast( root: Root - )(using PlatformContext): Array[Byte] = + )(using PlatformContext): RiddlResult[Array[Byte]] = val passInput = PassInput(root) val passesResult = Pass.runThesePasses( passInput, @@ -410,29 +446,51 @@ object RiddlLib extends RiddlLib: ) passesResult.outputs .outputOf[BASTOutput](BASTWriterPass.name) match - case Some(bastOutput) => bastOutput.bytes - case None => Array.empty + case Some(bastOutput) => + RiddlResult.Success(bastOutput.bytes) + case None => + RiddlResult.Failure(List( + Messages.error("BASTWriterPass produced no output") + )) end match end ast2bast override def bast2FlatAST( bytes: Array[Byte] - )(using PlatformContext): Either[Messages, Root] = - BASTReader.read(bytes).map { nebula => - val rootItems: Seq[RootContents] = - nebula.contents.toSeq.collect { - case d: Domain => d - case m: Module => m - case a: Author => a - } - val root = Root( - nebula.loc, - Contents[RootContents](rootItems*) - ) - flattenAST(root) + )(using PlatformContext): RiddlResult[Root] = + RiddlResult.fromEither(BASTReader.read(bytes)).map { + nebula => + val rootItems: Seq[RootContents] = + nebula.contents.toSeq.collect { + case d: Domain => d + case m: Module => m + case a: Author => a + } + val root = Root( + nebula.loc, + Contents[RootContents](rootItems*) + ) + flattenAST(root) } end bast2FlatAST + override def root2RiddlSource( + root: Root + )(using PlatformContext): String = + val passInput = PassInput(root) + val passes = Seq( + PrettifyPass.creator( + PrettifyPass.Options(flatten = true) + ) + ) + val result = Pass.runThesePasses(passInput, passes) + result.outputs + .outputOf[PrettifyOutput](PrettifyPass.name) match + case Some(po) => po.state.filesAsString + case None => "" + end match + end root2RiddlSource + override def version: String = RiddlBuildInfo.version diff --git a/riddlLib/shared/src/main/scala/com/ossuminc/riddl/RiddlResult.scala b/riddlLib/shared/src/main/scala/com/ossuminc/riddl/RiddlResult.scala new file mode 100644 index 000000000..395f13531 --- /dev/null +++ b/riddlLib/shared/src/main/scala/com/ossuminc/riddl/RiddlResult.scala @@ -0,0 +1,74 @@ +/* + * Copyright 2019-2026 Ossum, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.ossuminc.riddl + +import com.ossuminc.riddl.language.Messages.Messages + +/** Cross-platform result type for RiddlLib operations. + * + * Provides a structured result that works natively on all + * platforms (JVM, JS, Native) and maps cleanly to TypeScript's + * `RiddlResult` interface. + * + * @tparam T The type of the success value + */ +sealed trait RiddlResult[+T]: + + /** Whether the operation succeeded. */ + def succeeded: Boolean + + /** The success value, if present. */ + def value: Option[T] + + /** Error messages from a failed operation. */ + def errors: Messages + + /** Convert to Either for Scala interop. */ + def toEither: Either[Messages, T] + + /** Map over the success value. */ + def map[U](f: T => U): RiddlResult[U] + + /** FlatMap over the success value. */ + def flatMap[U](f: T => RiddlResult[U]): RiddlResult[U] +end RiddlResult + +object RiddlResult: + + case class Success[T](result: T) extends RiddlResult[T]: + def succeeded: Boolean = true + def value: Option[T] = Some(result) + def errors: Messages = List.empty + def toEither: Either[Messages, T] = Right(result) + def map[U](f: T => U): RiddlResult[U] = + Success(f(result)) + def flatMap[U]( + f: T => RiddlResult[U] + ): RiddlResult[U] = f(result) + end Success + + case class Failure(errors: Messages) + extends RiddlResult[Nothing]: + def succeeded: Boolean = false + def value: Option[Nothing] = None + def toEither: Either[Messages, Nothing] = Left(errors) + def map[U](f: Nothing => U): RiddlResult[U] = this + def flatMap[U]( + f: Nothing => RiddlResult[U] + ): RiddlResult[U] = this + end Failure + + /** Convert from Either. */ + def fromEither[T]( + e: Either[Messages, T] + ): RiddlResult[T] = + e match + case Right(v) => Success(v) + case Left(m) => Failure(m) + end match + end fromEither +end RiddlResult diff --git a/riddlLib/shared/src/test/scala/com/ossuminc/riddl/RiddlLibTest.scala b/riddlLib/shared/src/test/scala/com/ossuminc/riddl/RiddlLibTest.scala index 523c7b312..8d86240c9 100644 --- a/riddlLib/shared/src/test/scala/com/ossuminc/riddl/RiddlLibTest.scala +++ b/riddlLib/shared/src/test/scala/com/ossuminc/riddl/RiddlLibTest.scala @@ -18,66 +18,75 @@ class RiddlLibTest extends AnyWordSpec with Matchers { "RiddlLib" should { "parse a simple domain" in { - val result = RiddlLib.parseString( + RiddlLib.parseString( "domain MyDomain is { ??? }" - ) - result.isRight mustBe true - val root = result.toOption.get - root.domains must not be empty - root.domains.head.id.value mustBe "MyDomain" + ) match + case RiddlResult.Success(root) => + root.domains must not be empty + root.domains.head.id.value mustBe "MyDomain" + case RiddlResult.Failure(errors) => + fail(s"Parse failed: $errors") + end match } "return errors for invalid input" in { - val result = RiddlLib.parseString( + RiddlLib.parseString( "this is not valid riddl" - ) - result.isLeft mustBe true - val messages = result.swap.toOption.get - messages must not be empty + ) match + case RiddlResult.Failure(errors) => + errors must not be empty + case RiddlResult.Success(_) => + fail("Expected parse failure") + end match } "flattenAST on a parsed result" in { - val result = RiddlLib.parseString( + RiddlLib.parseString( "domain D is { context C is { ??? } }" - ) - result.isRight mustBe true - val root = result.toOption.get - val flattened = RiddlLib.flattenAST(root) - flattened.domains must not be empty - flattened.domains.head.id.value mustBe "D" + ) match + case RiddlResult.Success(root) => + val flattened = RiddlLib.flattenAST(root) + flattened.domains must not be empty + flattened.domains.head.id.value mustBe "D" + case RiddlResult.Failure(errors) => + fail(s"Parse failed: $errors") + end match } "getOutline returns entries" in { - val result = RiddlLib.getOutline( + RiddlLib.getOutline( "domain D is { context C is { ??? } }" - ) - result.isRight mustBe true - val entries = result.toOption.get - entries must not be empty - entries.exists(_.kind == "Domain") mustBe true + ) match + case RiddlResult.Success(entries) => + entries must not be empty + entries.exists( + _.kind == "Domain" + ) mustBe true + case RiddlResult.Failure(errors) => + fail(s"getOutline failed: $errors") + end match } "getTree returns nodes" in { - val result = RiddlLib.getTree( + RiddlLib.getTree( "domain D is { context C is { ??? } }" - ) - result.isRight mustBe true - val nodes = result.toOption.get - nodes must not be empty - // Top level is the Root node with Domain children - val rootNode = nodes.head - rootNode.kind mustBe "Root" - rootNode.children.exists( - _.kind == "Domain" - ) mustBe true + ) match + case RiddlResult.Success(nodes) => + nodes must not be empty + val rootNode = nodes.head + rootNode.kind mustBe "Root" + rootNode.children.exists( + _.kind == "Domain" + ) mustBe true + case RiddlResult.Failure(errors) => + fail(s"getTree failed: $errors") + end match } "validateString returns a ValidateResult" in { val vr = RiddlLib.validateString( "domain D is { context C is { ??? } }" ) - // Should not throw; may or may not succeed - // depending on validation rules vr.parseErrors mustBe empty } @@ -93,32 +102,59 @@ class RiddlLibTest extends AnyWordSpec with Matchers { val source = """domain TestDomain is { context TestCtx is { ??? } }""" - val parseResult = RiddlLib.parseString(source) - parseResult.isRight mustBe true - val root = parseResult.toOption.get - val bastBytes = RiddlLib.ast2bast(root) - bastBytes.length must be > 0 + RiddlLib.parseString(source) match + case RiddlResult.Success(root) => + RiddlLib.ast2bast(root) match + case RiddlResult.Success(bastBytes) => + bastBytes.length must be > 0 + RiddlLib.bast2FlatAST(bastBytes) match + case RiddlResult.Success(flatRoot) => + flatRoot.domains must not be empty + flatRoot.domains.head.id + .value mustBe "TestDomain" + case RiddlResult.Failure(errors) => + fail(s"bast2FlatAST failed: $errors") + end match + case RiddlResult.Failure(errors) => + fail(s"ast2bast failed: $errors") + end match + case RiddlResult.Failure(errors) => + fail(s"Parse failed: $errors") + end match + } - val flatResult = RiddlLib.bast2FlatAST(bastBytes) - flatResult.isRight mustBe true - val flatRoot = flatResult.toOption.get - flatRoot.domains must not be empty - flatRoot.domains.head.id.value mustBe "TestDomain" + "root2RiddlSource round-trips parse to source" in { + val source = """domain TestDomain is { + context TestCtx is { ??? } + }""" + RiddlLib.parseString(source) match + case RiddlResult.Success(root) => + val riddlText = RiddlLib.root2RiddlSource(root) + riddlText must include("domain TestDomain") + riddlText must include("context TestCtx") + case RiddlResult.Failure(errors) => + fail(s"Parse failed: $errors") + end match } "ast2bast converts parsed AST to bytes" in { - val result = RiddlLib.parseString( + RiddlLib.parseString( "domain D is { context C is { ??? } }" - ) - result.isRight mustBe true - val root = result.toOption.get - val bytes = RiddlLib.ast2bast(root) - bytes must not be empty - // BAST magic number check (first 4 bytes = "BAST") - bytes(0) mustBe 'B'.toByte - bytes(1) mustBe 'A'.toByte - bytes(2) mustBe 'S'.toByte - bytes(3) mustBe 'T'.toByte + ) match + case RiddlResult.Success(root) => + RiddlLib.ast2bast(root) match + case RiddlResult.Success(bytes) => + bytes must not be empty + bytes(0) mustBe 'B'.toByte + bytes(1) mustBe 'A'.toByte + bytes(2) mustBe 'S'.toByte + bytes(3) mustBe 'T'.toByte + case RiddlResult.Failure(errors) => + fail(s"ast2bast failed: $errors") + end match + case RiddlResult.Failure(errors) => + fail(s"Parse failed: $errors") + end match } } }