Skip to content

Commit 59c0362

Browse files
reid-spencerclaude
andcommitted
Bump BAST FORMAT_REVISION to 2 and add serialization tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2f15653 commit 59c0362

2 files changed

Lines changed: 268 additions & 1 deletion

File tree

language/shared/src/main/scala/com/ossuminc/riddl/language/bast/package.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ package object bast {
5858
* with revision 0 (pre-check era) will be rejected with a
5959
* clear message.
6060
*/
61-
val FORMAT_REVISION: Short = 1
61+
val FORMAT_REVISION: Short = 2
6262

6363
/** Magic bytes for BAST file identification: "BAST" */
6464
val MAGIC_BYTES: Array[Byte] = Array('B'.toByte, 'A'.toByte, 'S'.toByte, 'T'.toByte)
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/*
2+
* Copyright 2019-2026 Ossum Inc.
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package com.ossuminc.riddl.language.bast
8+
9+
import com.ossuminc.riddl.language.AST.*
10+
import com.ossuminc.riddl.language.{Contents, *}
11+
import com.ossuminc.riddl.language.At
12+
import com.ossuminc.riddl.utils.{AbstractTestingBasis, pc}
13+
import org.scalatest.TestData
14+
15+
/** Unit tests for BAST serialization at the language module level.
16+
*
17+
* These tests verify BASTWriter and BASTReader work correctly without
18+
* depending on the Pass framework. They use a simple manual traversal
19+
* to serialize AST nodes, then verify BASTReader can deserialize them.
20+
*/
21+
class BASTSerializationTest extends AbstractTestingBasis {
22+
23+
/** Simple manual serialization that mirrors what BASTWriterPass does,
24+
* but without depending on the passes module.
25+
*/
26+
private def serializeToBASTBytes(root: Root): Array[Byte] = {
27+
val bw = BASTWriter()
28+
bw.reserveHeader()
29+
30+
// Write the root (Nebula wrapper)
31+
bw.writeNode(root)
32+
33+
// Manually traverse and write all contents
34+
def traverseAndWrite(value: RiddlValue): Unit = {
35+
value match {
36+
case root: Root =>
37+
root.contents.foreach(traverseAndWrite)
38+
39+
case domain: Domain =>
40+
bw.writeNode(domain)
41+
domain.contents.foreach(traverseAndWrite)
42+
if domain.metadata.nonEmpty then
43+
bw.writeMetadataCount(domain.metadata)
44+
45+
case context: Context =>
46+
bw.writeNode(context)
47+
context.contents.foreach(traverseAndWrite)
48+
if context.metadata.nonEmpty then
49+
bw.writeMetadataCount(context.metadata)
50+
51+
case entity: Entity =>
52+
bw.writeNode(entity)
53+
entity.contents.foreach(traverseAndWrite)
54+
if entity.metadata.nonEmpty then
55+
bw.writeMetadataCount(entity.metadata)
56+
57+
case handler: Handler =>
58+
bw.writeNode(handler)
59+
handler.contents.foreach(traverseAndWrite)
60+
if handler.metadata.nonEmpty then
61+
bw.writeMetadataCount(handler.metadata)
62+
63+
case state: State =>
64+
bw.writeNode(state)
65+
state.contents.foreach(traverseAndWrite)
66+
if state.metadata.nonEmpty then
67+
bw.writeMetadataCount(state.metadata)
68+
69+
case t: Type =>
70+
bw.writeNode(t)
71+
if t.metadata.nonEmpty then
72+
bw.writeMetadataCount(t.metadata)
73+
74+
case other =>
75+
bw.writeNode(other)
76+
}
77+
}
78+
79+
traverseAndWrite(root)
80+
81+
val stringTableOffset = bw.writeStringTable()
82+
bw.finalize(stringTableOffset)
83+
}
84+
85+
"BAST Serialization" should {
86+
87+
"round-trip a simple domain with a type" in { (_: TestData) =>
88+
val typeDef = Type(At(), Identifier(At(), "Foo"), String_(At()))
89+
val domain = Domain(At(), Identifier(At(), "Simple"),
90+
Contents(typeDef))
91+
val root = Root(At(), Contents(domain))
92+
93+
val bytes = serializeToBASTBytes(root)
94+
bytes.length must be > 0
95+
96+
BASTReader.read(bytes) match {
97+
case Right(nebula: Nebula) =>
98+
nebula.contents.toSeq.size mustBe 1
99+
case Left(errors) =>
100+
fail(s"BAST read failed: ${errors.format}")
101+
}
102+
}
103+
104+
"round-trip domain with context and entity" in { (_: TestData) =>
105+
val handler = Handler(At(), Identifier(At(), "TestHandler"))
106+
val entity = Entity(At(), Identifier(At(), "TestEntity"),
107+
Contents(handler))
108+
val context = Context(At(), Identifier(At(), "TestCtx"),
109+
Contents(entity))
110+
val domain = Domain(At(), Identifier(At(), "TestDomain"),
111+
Contents(context))
112+
val root = Root(At(), Contents(domain))
113+
114+
val bytes = serializeToBASTBytes(root)
115+
116+
BASTReader.read(bytes) match {
117+
case Right(nebula: Nebula) =>
118+
nebula.contents.toSeq.size mustBe 1
119+
case Left(errors) =>
120+
fail(s"BAST read failed: ${errors.format}")
121+
}
122+
}
123+
124+
"round-trip entity with state containing handlers" in {
125+
(_: TestData) =>
126+
val stateHandler = Handler(At(),
127+
Identifier(At(), "StateHandler"))
128+
val typRef = TypeRef(At(),
129+
"type", PathIdentifier(At(), Seq("MyFields")))
130+
val state = State(At(), Identifier(At(), "Active"),
131+
typRef, Contents(stateHandler))
132+
val entity = Entity(At(), Identifier(At(), "MyEntity"),
133+
Contents(state))
134+
val context = Context(At(), Identifier(At(), "MyCtx"),
135+
Contents(entity))
136+
val domain = Domain(At(), Identifier(At(), "MyDomain"),
137+
Contents(context))
138+
val root = Root(At(), Contents(domain))
139+
140+
val bytes = serializeToBASTBytes(root)
141+
142+
BASTReader.read(bytes) match {
143+
case Right(nebula: Nebula) =>
144+
nebula.contents.toSeq.size mustBe 1
145+
case Left(errors) =>
146+
fail(s"BAST read failed: ${errors.format}")
147+
}
148+
}
149+
150+
"round-trip various type expressions" in { (_: TestData) =>
151+
val types: Seq[Type] = Seq(
152+
Type(At(), Identifier(At(), "AString"), String_(At())),
153+
Type(At(), Identifier(At(), "ANumber"), Number(At())),
154+
Type(At(), Identifier(At(), "ABool"), Bool(At())),
155+
Type(At(), Identifier(At(), "AnOptional"),
156+
Optional(At(), String_(At()))),
157+
Type(At(), Identifier(At(), "AList"),
158+
OneOrMore(At(), String_(At()))),
159+
Type(At(), Identifier(At(), "AMapping"),
160+
Mapping(At(), String_(At()), Number(At())))
161+
)
162+
val domain = Domain(At(), Identifier(At(), "TypesDomain"),
163+
Contents(types*))
164+
val root = Root(At(), Contents(domain))
165+
166+
val bytes = serializeToBASTBytes(root)
167+
168+
BASTReader.read(bytes) match {
169+
case Right(nebula: Nebula) =>
170+
nebula.contents.toSeq.size mustBe 1
171+
case Left(errors) =>
172+
fail(s"BAST read failed: ${errors.format}")
173+
}
174+
}
175+
176+
"round-trip multiple domains" in { (_: TestData) =>
177+
val d1 = Domain(At(), Identifier(At(), "First"),
178+
Contents(Type(At(), Identifier(At(), "A"), String_(At()))))
179+
val d2 = Domain(At(), Identifier(At(), "Second"),
180+
Contents(Type(At(), Identifier(At(), "B"), Number(At()))))
181+
val d3 = Domain(At(), Identifier(At(), "Third"),
182+
Contents(Type(At(), Identifier(At(), "C"), Bool(At()))))
183+
val root = Root(At(), Contents(d1, d2, d3))
184+
185+
val bytes = serializeToBASTBytes(root)
186+
187+
BASTReader.read(bytes) match {
188+
case Right(nebula: Nebula) =>
189+
nebula.contents.toSeq.size mustBe 3
190+
case Left(errors) =>
191+
fail(s"BAST read failed: ${errors.format}")
192+
}
193+
}
194+
195+
"reject BAST with stale format revision" in { (_: TestData) =>
196+
val domain = Domain(At(), Identifier(At(), "RevTest"),
197+
Contents(Type(At(), Identifier(At(), "X"), String_(At()))))
198+
val root = Root(At(), Contents(domain))
199+
val bytes = serializeToBASTBytes(root).clone()
200+
201+
// Patch format revision to 0 (offset 10-11 in header)
202+
bytes(10) = 0.toByte
203+
bytes(11) = 0.toByte
204+
205+
BASTReader.read(bytes) match {
206+
case Left(errors) =>
207+
val msg = errors.map(_.format).mkString
208+
msg must include("format revision")
209+
msg must include("regenerate")
210+
case Right(_) =>
211+
fail("Expected rejection of stale format revision")
212+
}
213+
}
214+
215+
"reject BAST with future format revision" in { (_: TestData) =>
216+
val domain = Domain(At(), Identifier(At(), "RevTest"),
217+
Contents(Type(At(), Identifier(At(), "X"), String_(At()))))
218+
val root = Root(At(), Contents(domain))
219+
val bytes = serializeToBASTBytes(root).clone()
220+
221+
// Patch format revision to 99 (offset 10-11 in header)
222+
bytes(10) = 0.toByte
223+
bytes(11) = 99.toByte
224+
225+
BASTReader.read(bytes) match {
226+
case Left(errors) =>
227+
val msg = errors.map(_.format).mkString
228+
msg must include("format revision")
229+
case Right(_) =>
230+
fail("Expected rejection of future format revision")
231+
}
232+
}
233+
234+
"accept BAST with current format revision" in {
235+
(_: TestData) =>
236+
val domain = Domain(At(), Identifier(At(), "RevOK"),
237+
Contents(Type(At(), Identifier(At(), "Y"), Bool(At()))))
238+
val root = Root(At(), Contents(domain))
239+
val bytes = serializeToBASTBytes(root)
240+
241+
BASTReader.read(bytes) match {
242+
case Right(nebula: Nebula) =>
243+
nebula.contents.toSeq.size mustBe 1
244+
case Left(errors) =>
245+
fail(s"Should accept current revision: ${errors.format}")
246+
}
247+
}
248+
249+
"reject invalid magic bytes" in { (_: TestData) =>
250+
val domain = Domain(At(), Identifier(At(), "MagicTest"),
251+
Contents(Type(At(), Identifier(At(), "X"), String_(At()))))
252+
val root = Root(At(), Contents(domain))
253+
val bytes = serializeToBASTBytes(root).clone()
254+
255+
// Corrupt magic bytes (first 4 bytes)
256+
bytes(0) = 'X'.toByte
257+
258+
BASTReader.read(bytes) match {
259+
case Left(errors) =>
260+
val msg = errors.map(_.format).mkString
261+
msg must include("Not a BAST file")
262+
case Right(_) =>
263+
fail("Expected rejection of invalid magic bytes")
264+
}
265+
}
266+
}
267+
}

0 commit comments

Comments
 (0)