Skip to content

Commit 21e6525

Browse files
reid-spencerclaude
andcommitted
Multi-line do and prompt, braced like doc_block
`do { "a" "b" "c" }` and `prompt({ "a" "b" })`, with the bare single-string form unchanged. The shape is `doc_block`'s, which RIDDL already uses for prose, so this adds no new spelling to the language -- and the bare form deliberately takes EXACTLY ONE string rather than admitting juxtaposition, since `do "a" "b"` parses unambiguously (nothing else starts with a quote) but leaves nothing except the next keyword to mark where the statement ends. `what` and `prompt` become Seq[LiteralString], with `text` deriving the \n-separated prose riddlg reads. One representation, so there is no second field to disagree with it, and a single-line `do` is a Seq of one rather than a special case. Additive at every layer, which is the load-bearing property: a one-line `do` prettifies byte-identically to before, and serializes to a bare JSON string rather than an array, so none of the corpus's 190 models move for a feature they do not use. Several lines get one per line inside braces -- the layout every other block uses. Squashing them onto one line would have been the narrow second copy of a block dispatch that InvariantBlock was already caught being. All four reflectivity surfaces move together: parser, EBNF (a new literal_string_block rule, verified by TatSu accepting the new fixture), prettify with a convergence test, and BAST plus JSON with their own round-trip suites. FORMAT_REVISION 22 -> 23, because both now write a SEQUENCE where they wrote a bare string, so a revision-22 file's string is read as a COUNT and everything after it derails -- a misalignment rather than a clean failure, which is the whole reason the gate exists. The JSON reader accepts either a string or an array, so nothing already written stops loading. Three suites pin the revision number so it cannot be silently reused; they reddened on the bump exactly as intended and now read 23. NotImplemented.bast is regenerated per the recipe in BACKLOG 0.3 -- from its own directory, 93 bytes, differing at byte 12 and nowhere else. Prettify cases were canary-tested: AbstractValidatingTest is a fixture spec, and a `(td: TestData)` lambda on a plain spec constructs a Function1 without ever running the body, so a green suite there proves nothing until you have watched it fail. The 38 corpus round-trip failures are the inherited single-state morph cost, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ba4c3c9 commit 21e6525

31 files changed

Lines changed: 461 additions & 58 deletions
0 Bytes
Binary file not shown.

language/input/multiline-do.riddl

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
domain MultilineDo is {
2+
context Ordering is {
3+
command PlaceOrder is { ??? }
4+
entity Order is {
5+
handler Orders is {
6+
on command PlaceOrder is {
7+
do "a single line stays exactly as it was"
8+
do {
9+
"first line of a longer instruction"
10+
"second line, which a generator joins with a newline"
11+
"third line"
12+
}
13+
}
14+
}
15+
}
16+
function Score is {
17+
requires { amount: Real }
18+
returns { score: Real }
19+
let one = prompt("a single-line hole")
20+
let many = prompt({ "a hole whose prose" "runs to several lines" }) as Real
21+
return many
22+
}
23+
}
24+
}

language/src/main/resources/riddl/grammar/ebnf-grammar.ebnf

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ statement_start = ( "set" | "tell" | "send" | "forward" | "yield" | "reply" | "m
362362
| "do" | "prompt" | "let" | "call" | "foreach" | "when" | "match" | "error"
363363
| "require" | "put" | "return" | "terminate" | "code" | "focus" | "stop"
364364
| "read" | "write" | "ask" | "initiate" | "if" | "else" ) ;
365-
prompt_value = "prompt" "(" literal_string ")" [ "as" type_expression ] ;
365+
prompt_value = "prompt" "(" literal_string_block ")" [ "as" type_expression ] ;
366366
(* Numeric literals - integers and reals, written directly. No digit separators and no radix *)
367367
(* prefixes: declined deliberately, and pure additions later if wanted. *)
368368
(* *)
@@ -468,7 +468,7 @@ return_statement = "return" value ;
468468
terminate_statement = "terminate" value [ "with" "(" [constructor_arg {"," constructor_arg}] ")" ] ;
469469
470470
(* General statements. A54: `do` is canonical; the `prompt` statement is a deprecated synonym. *)
471-
prompt_statement = ("do" | "prompt") literal_string ;
471+
prompt_statement = ("do" | "prompt") literal_string_block ;
472472
code_statement = "```" ("scala" | "java" | "python" | "mojo") code_contents "```" ;
473473
code_contents = {any_char_except_triple_backtick} ;
474474
error_statement = "error" literal_string ;
@@ -662,5 +662,9 @@ doc_block = "{" {markdown_lines | literal_strings | undefined} "}" | literal_str
662662
markdown_lines = {markdown_line}+ ;
663663
markdown_line = "|" /[^\n]*\n?/ ;
664664
literal_strings = {literal_string}+ ;
665+
(* One string bare, or several inside braces -- the same shape doc_block uses for prose. The bare
666+
form takes EXACTLY ONE string: admitting juxtaposition would leave nothing but the next keyword
667+
to mark where the statement ends. *)
668+
literal_string_block = "{" literal_strings "}" | literal_string ;
665669
shown_by = "shown" "by" "{" {http_url} "}" ;
666670
mime_type = ("application" | "audio" | "example" | "font" | "image" | "model" | "text" | "video") "/" /[a-z.*-]*/ ;

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

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,17 @@ object AST:
186186

187187
/** Definition of the empty LiteralString */
188188
val empty: LiteralString = LiteralString(At.empty, "")
189+
190+
/** One string bare, several braced -- the shape `doc_block` already uses for prose.
191+
*
192+
* Single-line renders EXACTLY as it did before multi-line existed, which is what keeps the
193+
* feature additive: no existing model's output moves. This is the ONE-LINE form, for messages
194+
* and `.format`; the prettifier lays a multi-line block out one string per line, as it does
195+
* for every other block, and owns that layout rather than deriving it from here.
196+
*/
197+
def blockFormat(lines: Seq[LiteralString]): String =
198+
if lines.sizeIs == 1 then lines.head.format
199+
else lines.map(_.format).mkString("{ ", " ", " }")
189200
end LiteralString
190201

191202
/** A RiddlValue that is a parsed identifier, typically the name of a definition.
@@ -3417,13 +3428,17 @@ object AST:
34173428

34183429
case class PromptValue(
34193430
loc: At,
3420-
prompt: LiteralString,
3431+
prompt: Seq[LiteralString],
34213432
typeEx: Option[TypeExpression] = None
34223433
) extends RiddlValue:
34233434
override def kind: String = "Prompt Value"
3435+
3436+
/** The prose as a generator wants it. See [[DoStatement.text]]. */
3437+
def text: String = prompt.map(_.s).mkString("\n")
3438+
34243439
def format: String =
34253440
val ascription = typeEx.map(t => s" as ${PromptValue.ascriptionFormat(t)}").getOrElse("")
3426-
s"prompt(${prompt.format})$ascription"
3441+
s"prompt(${LiteralString.blockFormat(prompt)})$ascription"
34273442
end PromptValue
34283443

34293444
object PromptValue:
@@ -3688,10 +3703,19 @@ object AST:
36883703
@JSExportTopLevel("DoStatement")
36893704
case class DoStatement(
36903705
loc: At,
3691-
what: LiteralString
3706+
what: Seq[LiteralString]
36923707
) extends Statement {
36933708
override def kind: String = "Do Statement"
3694-
def format: String = what.format
3709+
3710+
/** The prose as a generator wants it: the lines joined by newlines, without quotes.
3711+
*
3712+
* riddlg reads this. It is derived rather than stored, so there is no second representation to
3713+
* disagree with `what` -- the same reason a single-line `do` is a Seq of one instead of a
3714+
* special case.
3715+
*/
3716+
def text: String = what.map(_.s).mkString("\n")
3717+
3718+
def format: String = LiteralString.blockFormat(what)
36953719
}
36963720

36973721
/** A statement that is intended to generate a runtime error in the application or otherwise

language/src/main/scala/com/ossuminc/riddl/language/Finder.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ case class Finder[CV <: RiddlValue](root: Container[CV]) {
9595
// audit consistency (review round 1, fix 2). A LiteralString is a leaf so these add no
9696
// actual reachability, but a table claiming to be exhaustive should not have unexplained
9797
// gaps next to a sibling it does cover.
98-
case ps: DoStatement => Seq(ps.what)
98+
case ps: DoStatement => ps.what
9999
case es: ErrorStatement => Seq(es.message)
100100
case cs: CodeStatement => Seq(cs.language)
101101
case fe: ForeachStatement => fe.doStatements.toSeq
@@ -140,7 +140,7 @@ case class Finder[CV <: RiddlValue](root: Container[CV]) {
140140
// ascription's `TypeExpression` is surfaced here too (an `AliasedTypeExpression` — a NAMED
141141
// type ascription — recurses one further level into its `PathIdentifier`, so `prompt("…") as
142142
// SomeType` makes both the ascription node AND the path it names reachable).
143-
case pv: PromptValue => Seq(pv.prompt) ++ pv.typeEx.toSeq
143+
case pv: PromptValue => pv.prompt ++ pv.typeEx.toSeq
144144
case ate: AliasedTypeExpression => Seq(ate.pathId)
145145

146146
case _ => Seq.empty

language/src/main/scala/com/ossuminc/riddl/language/bast/BASTReader.scala

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -992,8 +992,11 @@ class BASTReader(
992992

993993
private def readStatement(loc: At, stmtType: Int): Statement = {
994994
stmtType match {
995-
case 0 => // Prompt
996-
val what = readLiteralString()
995+
case 0 => // Do (spelled `prompt` before 2.0)
996+
// FORMAT_REVISION 23: a SEQUENCE. A revision-22 file wrote a bare string here, so an older
997+
// file misaligns rather than failing cleanly -- which is exactly what the revision gate is
998+
// for.
999+
val what = readSeq(() => readLiteralString())
9971000
DoStatement(loc, what)
9981001

9991002
case 1 => // Error
@@ -2584,7 +2587,8 @@ class BASTReader(
25842587
LiteralString(loc, s)
25852588
case 4 => // PromptValue
25862589
val loc = readLocation()
2587-
val what = readLiteralString()
2590+
// FORMAT_REVISION 23: a SEQUENCE, as for DoStatement.
2591+
val what = readSeq(() => readLiteralString())
25882592
val typeEx = readOption(readTypeExpression()) // A20: optional `as <type>` ascription
25892593
PromptValue(loc, what, typeEx)
25902594
case 12 => // EmptyValue -- `empty` / `empty <type>` (FORMAT_REVISION 21)

language/src/main/scala/com/ossuminc/riddl/language/bast/BASTWriter.scala

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,7 +1116,8 @@ class BASTWriter(val writer: ByteBufferWriter, val stringTable: StringTable) {
11161116
writer.writeU8(NODE_STATEMENT)
11171117
writer.writeU8(0) // Prompt statement type
11181118
writeLocation(s.loc)
1119-
writeLiteralString(s.what)
1119+
// FORMAT_REVISION 23: a SEQUENCE of strings, where revision 22 wrote exactly one.
1120+
writeSeq(s.what)(writeLiteralString)
11201121
}
11211122

11221123
def writeErrorStatement(s: ErrorStatement): Unit = {
@@ -1434,7 +1435,8 @@ class BASTWriter(val writer: ByteBufferWriter, val stringTable: StringTable) {
14341435
case pv: PromptValue =>
14351436
writer.writeU8(4)
14361437
writeLocation(pv.loc)
1437-
writeLiteralString(pv.prompt)
1438+
// FORMAT_REVISION 23: a SEQUENCE, as for DoStatement.
1439+
writeSeq(pv.prompt)(writeLiteralString)
14381440
writeOption(pv.typeEx)(writeTypeExpression) // A20: optional `as <type>` ascription
14391441
case c: Constructor =>
14401442
writer.writeU8(1)

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,11 @@ package object bast {
147147
// reader has no arm for sub-kind 21 at all, so it THROWS rather than misreading -- which is the
148148
// good failure, and the reason the reader's default arm was made to throw instead of
149149
// fabricating a DoStatement. Bumped rather than ridden because 18 SHIPPED in 2.0.0-rc.15.
150-
22 // `system` value: tag 13 in readValue/writeValue. A revision-21 reader hitting tag 13 throws
150+
// 23 makes `do` and `prompt(...)` hold a SEQUENCE of literal strings rather than one, so a
151+
// revision-22 file's bare string is read as a COUNT and everything after it derails. Not a
152+
// clean failure -- which is the whole reason this gate exists.
153+
23 // multi-line `do` / `prompt`
154+
// 22 was: `system` value: tag 13 in readValue/writeValue. A revision-21 reader hitting tag 13 throws
151155
// rather than misreading, which is what the revision gate is for.
152156
// 21 // `empty` value: tag 12 in readValue/writeValue. A revision-20 reader hitting tag 12 throws
153157
// rather than misreading, but the gate is what makes that a clean failure.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,17 @@ private[parsing] trait CommonParser(using pc: PlatformContext)
190190

191191
def literalStrings[u: P]: P[Seq[LiteralString]] = { P(literalString.rep(1)) }
192192

193+
/** One string bare, or several inside braces -- the same shape `docBlock` uses for prose.
194+
*
195+
* The bare form takes EXACTLY ONE string, deliberately. Making it `literalStrings` would admit
196+
* `do "a" "b"` by juxtaposition, and while that parses unambiguously (no statement begins with a
197+
* quote), nothing would mark where the statement ends except the next keyword. The braces make
198+
* the extent explicit, and this is the spelling RIDDL already uses for multi-line prose.
199+
*/
200+
def literalStringBlock[u: P]: P[Seq[LiteralString]] = {
201+
P((open ~ literalStrings ~ close) | literalString.map(Seq(_)))
202+
}
203+
193204
def markdownLines[u: P]: P[Seq[LiteralString]] = {
194205
P(markdownLine.rep(1))
195206
}

language/src/main/scala/com/ossuminc/riddl/language/parsing/StatementParser.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ private[parsing] trait StatementParser {
2929

3030
// `do "…"` is the canonical AI-action statement (A54). It builds a DoStatement.
3131
private def doStatement[u: P]: P[DoStatement] = {
32-
P(Index ~ Keywords.do_ ~ literalString ~/ Index)./ map { case (start, str, end) =>
32+
P(Index ~ Keywords.do_ ~ literalStringBlock ~/ Index)./ map { case (start, str, end) =>
3333
DoStatement(at(start, end), str)
3434
}
3535
}
@@ -38,7 +38,7 @@ private[parsing] trait StatementParser {
3838
// deprecation at the keyword (pattern mirrors replyStatement's `reply` -> `yield`). Note: the
3939
// parenthesized `prompt("…")` value form is handled by `promptValue`, not here.
4040
private def promptStatement[u: P]: P[DoStatement] = {
41-
P(Index ~ Keywords.prompt ~ literalString ~/ Index)./ map { case (start, str, end) =>
41+
P(Index ~ Keywords.prompt ~ literalStringBlock ~/ Index)./ map { case (start, str, end) =>
4242
val kwLoc = at(start, start + Keyword.prompt.length)
4343
deprecation(
4444
kwLoc,
@@ -541,7 +541,7 @@ private[parsing] trait StatementParser {
541541

542542
private[parsing] def promptValue[u: P]: P[PromptValue] = {
543543
P(
544-
Index ~ Keywords.prompt ~ Punctuation.roundOpen ~/ literalString ~
544+
Index ~ Keywords.prompt ~ Punctuation.roundOpen ~/ literalStringBlock ~
545545
Punctuation.roundClose ~ (Keywords.keyword("as") ~/ typeExpression).? ~/ Index
546546
)./.map { case (start, str, typeEx, end) => PromptValue(at(start, end), str, typeEx) }
547547
}

0 commit comments

Comments
 (0)