///|
/// The result of converting a document to a data tree.
pub struct Conversion {
tree : @data_tree.DataTree?
diagnostics : ReadOnlyArray[@diagrams.Diagnostic]
}
///|
/// One value under construction. A value starts undetermined; its first
/// use fixes it as a scalar, a list or a map, as in the pinned profile.
priv enum Shape {
Open
Leaf(String)
List(Array[Int32])
Map(Array[(String, Int32)])
}
///|
priv struct Slot {
mut shape : Shape
/// The line that made the value, for source spans.
line : Int32
}
///|
/// A native refusal: the pinned profile gives no data for the block.
priv suberror Refusal {
Refusal(String, String, Int32)
}
///|
priv struct Builder {
slots : Array[Slot]
indents : Array[Int32]
stack : Array[Int32]
warnings : Array[(String, String, Int32)]
/// The line of the member occurrence that set each map entry value.
entry_lines : Map[Int32, Int32]
}
///|
fn Builder::slot(self : Builder, shape : Shape, line : Int32) -> Int32 {
self.slots.push({ shape, line, })
self.slots.length() - 1
}
///|
fn Builder::last(self : Builder, line : Int32) -> Int32 raise Refusal {
guard self.stack.last() is Some(top) else {
raise Refusal(
"yaml/indentation", "The indentation leaves the top level of the data", line,
)
}
top
}
///|
fn Builder::adjust(
self : Builder,
indent : Int32,
line : Int32,
) -> Unit raise Refusal {
guard self.indents.last() is Some(top) else {
self.indents.push(indent)
return
}
if indent > top {
self.indents.push(indent)
return
}
while self.indents.last() is Some(top) && indent < top {
ignore(self.indents.pop())
guard self.stack.pop() is Some(_) else {
raise Refusal(
"yaml/indentation", "The indentation leaves the top level of the data", line,
)
}
if self.slots[self.last(line)].shape is List(_) {
ignore(self.stack.pop())
}
}
}
///|
/// True when the top of the stack is the last element of the list below it.
fn Builder::in_list(self : Builder) -> Bool {
let n = self.stack.length()
if n < 2 {
return false
}
match self.slots[self.stack[n - 2]].shape {
List(items) => items.last() == Some(self.stack[n - 1])
_ => false
}
}
///|
fn Builder::add(
self : Builder,
target : Int32,
value : Int32,
line : Int32,
) -> Unit raise Refusal {
match self.slots[target].shape {
Open => self.slots[target].shape = List([value])
List(items) => items.push(value)
_ =>
raise Refusal(
"yaml/mixed-collection", "A list item stands where the data has a map or a scalar",
line,
)
}
}
///|
fn Builder::put(
self : Builder,
target : Int32,
key : String,
value : Int32,
line : Int32,
) -> Unit raise Refusal {
match self.slots[target].shape {
Open => {
self.slots[target].shape = Map([(key, value)])
self.entry_lines[value] = line
}
Map(entries) => {
for i, entry in entries {
if entry.0 == key {
// The pinned profile keeps the first position and the last value.
entries[i] = (key, value)
self.entry_lines[value] = line
self.warnings.push(
(
"yaml/duplicate-key",
"Key '\{key}' repeats; PlantUML 1.2026.9 keeps the last value at the first position",
line,
),
)
return
}
}
entries.push((key, value))
self.entry_lines[value] = line
}
_ =>
raise Refusal(
"yaml/mixed-collection", "A key stands where the data has a list or a scalar",
line,
)
}
}
///|
/// Start a list element: leave a finished element of the same list first.
fn Builder::element(self : Builder, line : Int32) -> Int32 raise Refusal {
if self.in_list() {
ignore(self.stack.pop())
}
self.last(line)
}
///|
fn text_after_key(
lines : ReadOnlyArray[Line],
start : Int32,
) -> (String, Int32) {
let out = StringBuilder()
let mut i = start
while i < lines.length() && lines[i].content is Text(text) {
// As upstream, a space separates parts only after non-empty text.
if !out.is_empty() {
out.write_char(' ')
}
out.write_string(text.text)
i += 1
}
(out.to_string(), i)
}
///|
fn block_text(contents : ReadOnlyArray[String]) -> String {
let out = StringBuilder()
for line in contents {
out.write_string(line)
out.write_char('\n')
}
out.to_string()
}
///|
/// Build the native value of the lines. The result is the root slot.
fn Builder::run(
self : Builder,
lines : ReadOnlyArray[Line],
work_limit : Int32,
) -> Int32 raise Refusal {
let root = self.slot(Open, -1)
self.stack.push(root)
let mut i = 0
while i < lines.length() {
if i > work_limit {
raise Refusal(
"yaml/work-limit", "YAML conversion exceeds its work limit", i,
)
}
let line = lines[i]
match line.content {
Text(_) =>
raise Refusal(
"yaml/text-without-key", "PlantUML 1.2026.9 refuses text without a key here",
i,
)
Dash => {
let target = self.element(i)
let value = self.slot(Open, i)
self.add(target, value, i)
self.stack.push(value)
i += 1
continue
}
_ => ()
}
let indent = line.native_indent()
self.adjust(indent, i)
match line.content {
Item(value) => {
let target = self.element(i)
self.add(target, self.slot(Leaf(value.text), i), i)
}
Pair(true, key, entry) => {
let target = self.element(i)
let element = self.slot(Open, i)
self.add(target, element, i)
self.stack.push(element)
match entry {
Empty => {
let child = self.slot(Open, i)
self.put(element, key.text, child, i)
self.stack.push(child)
}
Value(value) =>
self.put(element, key.text, self.slot(Leaf(value.text), i), i)
Flow(items) => self.put(element, key.text, self.list(items, i), i)
Block(_) | Folded =>
raise Refusal(
"yaml/unsupported-item-block", "PlantUML 1.2026.9 refuses '|' and '>' text after '- key:'",
i,
)
}
}
Pair(false, key, entry) => {
let target = self.last(i)
match entry {
Empty => {
let next = if i + 1 < lines.length() {
Some(lines[i + 1])
} else {
None
}
match next {
None => self.put(target, key.text, self.slot(Leaf(""), i), i)
Some(next) if next.native_indent() <= indent =>
self.put(target, key.text, self.slot(Leaf(""), i), i)
Some({ content: Text(_), .. }) => {
let (text, after) = text_after_key(lines, i + 1)
self.put(target, key.text, self.slot(Leaf(text), i), i)
i = after
continue
}
Some(_) => {
let child = self.slot(Open, i)
self.put(target, key.text, child, i)
self.stack.push(child)
}
}
}
Value(value) =>
self.put(target, key.text, self.slot(Leaf(value.text), i), i)
Block(contents) =>
self.put(
target,
key.text,
self.slot(Leaf(block_text(contents)), i),
i,
)
Flow(items) => self.put(target, key.text, self.list(items, i), i)
Folded =>
raise Refusal(
"yaml/unsupported-folded", "PlantUML 1.2026.9 refuses folded '>' text",
i,
)
}
}
_ => ()
}
i += 1
}
root
}
///|
fn Builder::list(
self : Builder,
items : ReadOnlyArray[String],
line : Int32,
) -> Int32 {
let values = []
for item in items {
values.push(self.slot(Leaf(item), line))
}
self.slot(List(values), line)
}
///|
/// Convert a YAML document to a structured-data model with the scalar policy
/// of PlantUML 1.2026.9: every scalar is text, so `enabled: true` gives the
/// text `true`. Anchors, aliases and tags stay literal text. A repeated key
/// keeps the last value at the first position, with a warning; the document
/// keeps every occurrence. Input that the pinned profile refuses gives an
/// error and no tree. Identities come from ordered ownership paths, as in
/// the JSON converter. The title is the last `title` statement.
pub fn to_data_tree(
document : Document,
id? : String,
limits~ : @envelope.Limits = @envelope.Limits::new(),
) -> Conversion {
let diagnostics : Array[@diagrams.Diagnostic] = []
fn span_of(line : Int32) -> @diagrams.Span? {
if line >= 0 && line < document.lines.length() {
document.lines[line].span
} else {
None
}
}
fn failed(code : String, message : String, line : Int32) -> Conversion {
diagnostics.push(
@diagrams.Diagnostic::error(code, message, span?=span_of(line)),
)
{ tree: None, diagnostics: ReadOnlyArray::from_array(diagnostics), }
}
if !limits.valid() {
return failed("yaml/invalid-limits", "YAML limits must be positive", -1)
}
let builder : Builder = {
slots: [],
indents: [],
stack: [],
warnings: [],
entry_lines: Map([]),
}
let root = builder.run(document.lines, limits.work) catch {
Refusal(code, message, line) => return failed(code, message, line)
}
for warning in builder.warnings {
let (code, message, line) = warning
diagnostics.push(
@diagrams.Diagnostic::warning(code, message, span?=span_of(line)),
)
}
if builder.slots[root].shape is Open {
return failed("yaml/no-data", "The YAML block has no data", -1)
}
let view = id.unwrap_or(document.name.unwrap_or("yaml"))
let nodes : Array[@data_tree.Node] = []
let mut work = 0
let stack : Array[(Int32, Array[Int32], Bool)] = [(root, [], false)]
while stack.pop() is Some((slot, path, in_list)) {
work += 1
if work > limits.work {
return failed(
"yaml/work-limit", "YAML conversion exceeds its work limit", -1,
)
}
if nodes.length() >= limits.nodes {
return failed("yaml/node-limit", "YAML data exceeds the node limit", -1)
}
if path.length() > limits.depth {
return failed(
"yaml/depth-limit",
"YAML nesting exceeds the depth limit of \{limits.depth}",
-1,
)
}
let line = builder.slots[slot].line
let node_id = @data.value_id(path)
let child_path = (i : Int32) => {
let next = path.copy()
next.push(i)
next
}
let source = span_of(line)
let node = match builder.slots[slot].shape {
Leaf(text) => @data_tree.Node::new(node_id, Text(text), source?)
Open =>
return failed(
"yaml/missing-value", "PlantUML 1.2026.9 refuses a key or item that has no value",
line,
)
List(items) => {
if in_list {
return failed(
"yaml/unsupported-nested-list", "PlantUML 1.2026.9 refuses a list directly inside a list",
line,
)
}
let entries = []
for i, item in items {
let p = child_path(i)
entries.push(
@data_tree.Member::index(
@data.member_id(p),
i,
@data.value_id(p),
source?=span_of(builder.slots[item].line),
),
)
}
for i in 0.. {
let members = []
for i, entry in entries {
let p = child_path(i)
members.push(
@data_tree.Member::named(
@data.member_id(p),
entry.0,
@data.value_id(p),
source?=span_of(builder.entry_lines.get(entry.1).unwrap_or(line)),
),
)
}
for i in 0.. {
match statement {
Title(text) => Some(text)
_ => last
}
})
let base = @data_tree.DataTree::new(view, @data.value_id([]), nodes, title?)
let resolution = @data.resolve(
base,
document.highlights,
"yaml",
work_limit=limits.work,
)
diagnostics.append(resolution.diagnostics)
if !resolution.complete {
return { tree: None, diagnostics: ReadOnlyArray::from_array(diagnostics), }
}
let tree = @data_tree.DataTree::new(
view,
@data.value_id([]),
nodes,
highlights=resolution.highlights,
title?,
)
let invalid = @data_tree.validate(
tree,
limits=@data_tree.Limits::new(
nodes=limits.nodes,
depth=limits.depth,
work=limits.work,
),
)
if !invalid.is_empty() {
diagnostics.append(invalid)
return { tree: None, diagnostics: ReadOnlyArray::from_array(diagnostics), }
}
{ tree: Some(tree), diagnostics: ReadOnlyArray::from_array(diagnostics), }
}