Skip to content

Commit 1912674

Browse files
reidspencerclaude
andcommitted
Support BAST imports inside domains
Imports can now appear both at root level and inside domains: domain MyApp is { import "shared.bast" context Users is { ... } } Changes: - Added BASTImport to DomainContents type - Moved bastImport parser rule to CommonParser (shared) - Added bastImport to domainDefinitions in DomainParser - Updated BASTLoader to recursively process domain contents - Added test for imports inside domains Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent af7e89a commit 1912674

7 files changed

Lines changed: 155 additions & 46 deletions

File tree

bast/SESSION_HANDOFF.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,22 @@ Phase 3 (serialization/deserialization) is fully working. Phase 4 (import integr
7676
- `bast/jvm/src/test/scala/com/ossuminc/riddl/bast/BASTLoaderTest.scala`
7777

7878
#### Step 2: Support Import in Domains
79-
**Status**: PENDING (NEXT)
79+
**Status**: COMPLETE ✅
8080

81-
**Changes needed:**
82-
- Add `BASTImport` to `DomainContents` type in AST.scala
83-
- Add `bastImport` parser rule to domain content parsing
84-
- Update domain parser to include `bastImport` in content rules
81+
**Changes made:**
82+
- Added `BASTImport` to `DomainContents` type in AST.scala
83+
- Moved `bastImport` parser rule to CommonParser (shared)
84+
- Added `bastImport` to `domainDefinitions` in DomainParser
85+
- Updated BASTLoader to recursively process domains
86+
- Added test for imports inside domains
8587

86-
**Files to modify:**
88+
**Files modified:**
8789
- `language/shared/src/main/scala/com/ossuminc/riddl/language/AST.scala`
90+
- `language/shared/src/main/scala/com/ossuminc/riddl/language/parsing/CommonParser.scala`
91+
- `language/shared/src/main/scala/com/ossuminc/riddl/language/parsing/RootParser.scala`
8892
- `language/shared/src/main/scala/com/ossuminc/riddl/language/parsing/DomainParser.scala`
93+
- `bast/shared/src/main/scala/com/ossuminc/riddl/bast/BASTLoader.scala`
94+
- `bast/jvm/src/test/scala/com/ossuminc/riddl/bast/BASTLoaderTest.scala`
8995

9096
#### Step 3: Update BASTLoader
9197
**Status**: COMPLETE ✅ (done as part of Step 1)
@@ -127,8 +133,8 @@ Phase 3 (serialization/deserialization) is fully working. Phase 4 (import integr
127133
| BASTWriterSpec | 7 | ✅ All passing |
128134
| BASTRoundTripTest | 3 | ✅ All passing |
129135
| BASTPerformanceTest | 4 | ✅ All passing |
130-
| BASTLoaderTest | 3 | ✅ All passing |
131-
| **Total** | **17** |**All passing** |
136+
| BASTLoaderTest | 4 | ✅ All passing |
137+
| **Total** | **18** |**All passing** |
132138

133139
---
134140

bast/jvm/src/test/scala/com/ossuminc/riddl/bast/BASTLoaderTest.scala

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,5 +210,82 @@ class BASTLoaderTest extends AnyWordSpec {
210210
fail("Failed to parse source RIDDL files")
211211
}
212212
}
213+
214+
"load imports inside domains" in { (td: TestData) =>
215+
// Create a RIDDL source with shared types
216+
val sharedRiddl = """domain SharedTypes is {
217+
| type UserId is UUID
218+
| type Email is String
219+
| briefly "Shared type definitions"
220+
|}
221+
|""".stripMargin
222+
223+
// Parse and convert to BAST
224+
val sharedInput = RiddlParserInput(sharedRiddl, "test-shared")
225+
val sharedResult = TopLevelParser.parseInput(sharedInput, withVerboseFailures = true)
226+
227+
sharedResult match {
228+
case Left(messages) =>
229+
fail(s"Shared parse failed: ${messages.format}")
230+
231+
case Right(sharedRoot: Root) =>
232+
// Write to BAST
233+
val passInput = PassInput(sharedRoot)
234+
val writerResult = Pass.runThesePasses(passInput, Seq(BASTWriter.creator()))
235+
val output = writerResult.outputOf[BASTOutput](BASTWriter.name).get
236+
237+
val tempDir = Files.createTempDirectory("bast-domain-import-test")
238+
val bastFile = tempDir.resolve("shared.bast")
239+
Files.write(bastFile, output.bytes)
240+
241+
try {
242+
// Create a RIDDL file with import INSIDE a domain
243+
val riddlContent = s"""domain MyApp is {
244+
| import "${bastFile.toAbsolutePath}"
245+
|
246+
| context Users is {
247+
| briefly "User management"
248+
| }
249+
| briefly "Main application domain"
250+
|}
251+
|""".stripMargin
252+
253+
val rpi = RiddlParserInput(riddlContent, "test-domain-import")
254+
val parseResult = TopLevelParser.parseInput(rpi, withVerboseFailures = true)
255+
256+
parseResult match {
257+
case Left(messages) =>
258+
fail(s"Parse failed: ${messages.format}")
259+
260+
case Right(parsedRoot: Root) =>
261+
// Verify we can find the BASTImport inside the domain
262+
val imports = BASTLoader.getImports(parsedRoot)
263+
assert(imports.size == 1, s"Expected 1 import, got ${imports.size}")
264+
265+
val bastImport = imports.head
266+
assert(bastImport.contents.isEmpty, "BASTImport should be empty before loading")
267+
268+
// Load imports (including those inside domains)
269+
val baseURL = URL.fromCwdPath(".")
270+
val loadResult = BASTLoader.loadImports(parsedRoot, baseURL)
271+
272+
assert(loadResult.failedCount == 0,
273+
s"Expected 0 failed: ${loadResult.messages.map(_.format).mkString("; ")}")
274+
assert(loadResult.loadedCount == 1, s"Expected 1 loaded, got ${loadResult.loadedCount}")
275+
276+
// Verify contents were populated
277+
assert(bastImport.contents.nonEmpty, "BASTImport should have contents after loading")
278+
279+
// Verify the SharedTypes domain was loaded
280+
val loadedDomains = bastImport.contents.toSeq.collect { case d: Domain => d }
281+
assert(loadedDomains.exists(_.id.value == "SharedTypes"),
282+
"Should find SharedTypes domain in loaded contents")
283+
}
284+
} finally {
285+
Files.deleteIfExists(bastFile)
286+
Files.deleteIfExists(tempDir)
287+
}
288+
}
289+
}
213290
}
214291
}

bast/shared/src/main/scala/com/ossuminc/riddl/bast/BASTLoader.scala

Lines changed: 52 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import scala.util.{Failure, Success, Try}
1919
/** Utility for loading BAST imports.
2020
*
2121
* This loads BAST files referenced by BASTImport nodes and populates their
22-
* contents field with the loaded Nebula contents.
22+
* contents field with the loaded Nebula contents. Imports can appear at the
23+
* root level or inside domains.
2324
*/
2425
object BASTLoader {
2526

@@ -30,10 +31,11 @@ object BASTLoader {
3031
messages: Messages
3132
)
3233

33-
/** Load all BAST imports in a Root.
34+
/** Load all BAST imports in a Root, including those inside domains.
3435
*
35-
* Finds all BASTImport nodes in the Root and loads the referenced BAST files,
36-
* populating each BASTImport's contents field with the loaded Nebula contents.
36+
* Finds all BASTImport nodes in the Root and its domains, loads the referenced
37+
* BAST files, and populates each BASTImport's contents field with the loaded
38+
* Nebula contents.
3739
*
3840
* @param root The Root containing BASTImport nodes to load
3941
* @param baseURL The base URL for resolving relative BAST file paths
@@ -45,26 +47,33 @@ object BASTLoader {
4547
var loaded = 0
4648
var failed = 0
4749

48-
root.contents.foreach {
49-
case bi: BASTImport =>
50-
loadSingleImport(bi, baseURL) match {
51-
case Right(nebula) =>
52-
// Copy Nebula contents into BASTImport contents
53-
nebula.contents.foreach { item =>
54-
bi.contents.append(item)
55-
}
56-
loaded += 1
57-
case Left(error) =>
58-
msgs += Messages.Message(
59-
bi.loc,
60-
s"Failed to load BAST import '${bi.path.s}': $error",
61-
Messages.Error
62-
)
63-
failed += 1
64-
}
65-
case _ => () // Not a BASTImport, skip
50+
def loadImport(bi: BASTImport): Unit = {
51+
loadSingleImport(bi, baseURL) match {
52+
case Right(nebula) =>
53+
// Copy Nebula contents into BASTImport contents
54+
nebula.contents.foreach { item =>
55+
bi.contents.append(item)
56+
}
57+
loaded += 1
58+
case Left(error) =>
59+
msgs += Messages.Message(
60+
bi.loc,
61+
s"Failed to load BAST import '${bi.path.s}': $error",
62+
Messages.Error
63+
)
64+
failed += 1
65+
}
6666
}
6767

68+
def processContents[T <: RiddlValue](contents: Contents[T]): Unit = {
69+
contents.foreach {
70+
case bi: BASTImport => loadImport(bi)
71+
case d: Domain => processContents(d.contents)
72+
case _ => () // Not a BASTImport or Domain, skip
73+
}
74+
}
75+
76+
processContents(root.contents)
6877
LoadResult(loaded, failed, msgs.toList)
6978
}
7079

@@ -108,20 +117,35 @@ object BASTLoader {
108117
* @return true if there are BASTImport nodes with empty contents
109118
*/
110119
def hasUnloadedImports(root: Root): Boolean = {
111-
root.contents.toSeq.exists {
112-
case bi: BASTImport => bi.contents.isEmpty
113-
case _ => false
120+
var found = false
121+
def checkContents[T <: RiddlValue](contents: Contents[T]): Unit = {
122+
if !found then
123+
contents.foreach {
124+
case bi: BASTImport => if bi.contents.isEmpty then found = true
125+
case d: Domain => checkContents(d.contents)
126+
case _ => ()
127+
}
128+
end if
114129
}
130+
checkContents(root.contents)
131+
found
115132
}
116133

117-
/** Get all BASTImport nodes from a Root.
134+
/** Get all BASTImport nodes from a Root, including those inside domains.
118135
*
119136
* @param root The Root to search
120137
* @return Sequence of BASTImport nodes
121138
*/
122139
def getImports(root: Root): Seq[BASTImport] = {
123-
root.contents.toSeq.collect {
124-
case bi: BASTImport => bi
140+
val result = mutable.ListBuffer.empty[BASTImport]
141+
def collectFromContents[T <: RiddlValue](contents: Contents[T]): Unit = {
142+
contents.foreach {
143+
case bi: BASTImport => result += bi
144+
case d: Domain => collectFromContents(d.contents)
145+
case _ => ()
146+
}
125147
}
148+
collectFromContents(root.contents)
149+
result.toSeq
126150
}
127151
}

language/shared/src/main/scala/com/ossuminc/riddl/language/AST.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -816,8 +816,8 @@ object AST:
816816
/** Type of definitions that occur in a [[Domain]] without [[Include]] */
817817
type OccursInDomain = OccursInVitalDefinition | Author | Context | Domain | User | Epic | Saga
818818

819-
/** Type of definitions that occur in a [[Domain]] with [[Include]] */
820-
type DomainContents = OccursInDomain | Include[OccursInDomain]
819+
/** Type of definitions that occur in a [[Domain]] with [[Include]] and [[BASTImport]] */
820+
type DomainContents = OccursInDomain | Include[OccursInDomain] | BASTImport
821821

822822
/** Type of definitions that occur in a [[Context]] without [[Include]] */
823823
type OccursInContext = OccursInProcessor | Entity | Adaptor | Group | Saga | Projector |

language/shared/src/main/scala/com/ossuminc/riddl/language/parsing/CommonParser.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ private[parsing] trait CommonParser(using pc: PlatformContext)
6767
}
6868
}
6969

70+
/** Parse a BAST import statement: `import "path/to/file.bast"` */
71+
def bastImport[u: P]: P[BASTImport] = {
72+
P(Index ~ Keywords.import_ ~ literalString ~ Index).map {
73+
case (start, path, end) =>
74+
doBASTImport(at(start, end), path)
75+
}
76+
}
77+
7078
def undefined[u: P, RT](f: => RT): P[RT] = {
7179
P(Punctuation.undefinedMark./).map(_ => f)
7280
}

language/shared/src/main/scala/com/ossuminc/riddl/language/parsing/DomainParser.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ private[parsing] trait DomainParser {
3333
private def domainDefinitions[u: P]: P[Seq[DomainContents]] = {
3434
P(
3535
vitalDefinitionContents |
36-
author | context | domain | user | epic | saga | importDef | domainInclude | comment
36+
author | context | domain | user | epic | saga | importDef | bastImport | domainInclude | comment
3737
).asInstanceOf[P[DomainContents]]./.rep(1)
3838
}
3939

language/shared/src/main/scala/com/ossuminc/riddl/language/parsing/RootParser.scala

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,7 @@ trait RootParser { this: ModuleParser & CommonParser & ParsingContext =>
2020
include[u, RootContents](rootContents(_))
2121
}
2222

23-
/** Parse a BAST import statement: `import "path/to/file.bast"` */
24-
private def bastImport[u: P]: P[BASTImport] = {
25-
P(Index ~ Keywords.import_ ~ literalString ~ Index).map {
26-
case (start, path, end) =>
27-
doBASTImport(at(start, end), path)
28-
}
29-
}
23+
// bastImport is inherited from CommonParser
3024

3125
private def rootContent[u: P]: P[RootContents] = {
3226
P(bastImport | moduleContent | module | rootInclude[u]).asInstanceOf[P[RootContents]]

0 commit comments

Comments
 (0)