///|
/// Load YAML the way PyYAML's `safe_load` does.
///
/// The `yaml` package resolves plain scalars with MoonBit's `parse_int64` and
/// `parse_double`, which disagree with PyYAML about inputs that are common in
/// compose files (`yes`, `on`, `1_000`, `12:30`) and about ones that are not
/// (`08`, `0o7`, `1e3`, `1.0e16`). `config` prints `yaml.safe_dump` of the
/// loaded model, so the model has to agree with PyYAML's before the emitter
/// can.
///
/// The package exposes `Parser` and its events, so only the constructor — the
/// `Scalar` event to value step — is replaced here. The lexer, the parser and
/// the emitter of the package are still used as they are.
///
/// Three deliberate differences remain:
///
/// * an unquoted timestamp (`2024-01-01`) becomes a `datetime.date` in
/// PyYAML and then makes `podman-compose config` die in `json.dumps`, so
/// there is no output to match; we keep it a string;
/// * a non-string mapping key is an error here, since the compose schema
/// only has string keys;
/// * an integer beyond `Int64` keeps its exact digits but is a string in the
/// model, so the emitter quotes it (`'9223372036854775808'`) where
/// PyYAML's unbounded `int()` prints it plain. The digits are what matter
/// for a compose file; `Yaml` has no wider integer to hold them.
///|
/// Load every document in `source`, with PyYAML's scalar rules.
pub fn load_yaml_documents(
source : StringView,
) -> Array[@yaml.Yaml] raise Error {
let parser = @yaml.Parser::new(source)
let receiver : PyYamlConstructor = {
docs: [],
stack: [],
keys: [],
anchors: Map([]),
error: None,
}
parser.load(receiver, true) catch {
e => raise fail("YAML parse error: \{error_text(e)}")
}
match receiver.error {
Some(message) => raise fail(message)
None => receiver.docs
}
}
///|
/// What a plain `<<` key becomes in the constructed tree. PyYAML tags it
/// `tag:yaml.org,2002:merge` and expands it while constructing the mapping, but
/// a quoted `"<<"` is an ordinary key, and the tag is gone once a scalar has
/// become a `String`. Marking the key keeps the two apart; `merge.mbt`
/// consumes the marker, so it never reaches the emitter. A NUL byte cannot
/// appear in a YAML key, since the reader rejects control characters.
fn merge_key_marker() -> String {
"\u{0}<<"
}
///|
/// Whether a scalar event carries PyYAML's merge tag: a plain, untagged `<<`.
fn is_merge_scalar(
value : String,
style : @yaml.TScalarStyle,
tag : @yaml.Tag?,
) -> Bool {
if value != "<<" || explicit_yaml_tag(tag) is Some(_) {
return false
}
match style {
@yaml.TScalarStyle::Plain => true
_ => false
}
}
///|
priv struct PyYamlConstructor {
docs : Array[@yaml.Yaml]
stack : Array[(@yaml.Yaml, Int32)]
/// One slot per open mapping: `None` while the key is still expected, which
/// is what lets an empty string be a key.
keys : Array[String?]
anchors : Map[Int32, @yaml.Yaml]
mut error : String?
}
///|
impl @yaml.MarkedEventReceiver for PyYamlConstructor with fn on_event(
self,
event,
mark,
) {
if self.error is Some(_) {
return
}
self.handle(event, mark) catch {
e => self.error = Some(error_text(e))
}
}
///|
fn PyYamlConstructor::handle(
self : PyYamlConstructor,
event : @yaml.Event,
mark : @yaml.Marker,
) -> Unit raise Error {
match event {
@yaml.Event::StreamStart
| @yaml.Event::StreamEnd
| @yaml.Event::DocumentStart => ()
@yaml.Event::DocumentEnd =>
match self.stack.length() {
0 => self.docs.push(@yaml.Yaml::BadValue)
1 => self.docs.push(self.stack.pop().unwrap().0)
_ => raise fail("unbalanced YAML document")
}
@yaml.Event::SequenceStart(id~, ..) =>
self.stack.push((@yaml.Yaml::Array([]), id))
@yaml.Event::SequenceEnd => {
let node = self.stack.pop().unwrap()
self.insert(node, mark)
}
@yaml.Event::MappingStart(id~, ..) => {
self.stack.push((@yaml.Yaml::Map(Map([])), id))
self.keys.push(None)
}
@yaml.Event::MappingEnd => {
if self.keys.pop() is None {
raise fail("unbalanced YAML mapping")
}
let node = self.stack.pop().unwrap()
self.insert(node, mark)
}
@yaml.Event::Scalar(value~, style~, id~, tag~) => {
let node = construct_yaml_scalar(value, style, tag)
self.insert((node, id), mark, merge=is_merge_scalar(value, style, tag))
}
@yaml.Event::Alias(id~) =>
match self.anchors.get(id) {
Some(node) => self.insert((node, 0), mark)
None => raise fail("unknown YAML anchor \{id}")
}
}
}
///|
fn PyYamlConstructor::insert(
self : PyYamlConstructor,
node : (@yaml.Yaml, Int32),
mark : @yaml.Marker,
merge~ : Bool = false,
) -> Unit raise Error {
if node.1 > 0 {
self.anchors[node.1] = node.0
}
if self.stack.is_empty() {
self.stack.push(node)
return
}
let parent = self.stack.last().unwrap()
match parent {
(@yaml.Yaml::Array(items), _) => items.push(node.0)
(@yaml.Yaml::Map(m), _) => {
let slot = self.keys.last().unwrap()
match slot {
None =>
// This node is the key of the mapping.
match node.0 {
@yaml.Yaml::String(key) =>
self.keys[self.keys.length() - 1] = Some(
if merge {
merge_key_marker()
} else {
key
},
)
_ =>
raise fail(
"non-string mapping key at line \{mark.line + 1}, column \{mark.col + 1}",
)
}
Some(key) => {
self.keys[self.keys.length() - 1] = None
// PyYAML's constructor keeps the last value for a repeated key.
m[key] = node.0
}
}
}
_ => raise fail("malformed YAML document")
}
}
///|
/// PyYAML's tag for the `yaml.org` implicit resolvers, when one is written out
/// explicitly (`!!int 5`).
fn explicit_yaml_tag(tag : @yaml.Tag?) -> String? {
match tag {
Some(t) if t.handle == "tag:yaml.org,2002:" => Some(t.suffix)
_ => None
}
}
///|
/// Port of the composer's `compose_scalar_node` plus the constructors
/// `SafeConstructor` dispatches to.
fn construct_yaml_scalar(
value : String,
style : @yaml.TScalarStyle,
tag : @yaml.Tag?,
) -> @yaml.Yaml {
match explicit_yaml_tag(tag) {
Some("bool") => construct_yaml_bool(value)
Some("int") => construct_yaml_int(value)
Some("float") => construct_yaml_float(value)
Some("null") => @yaml.Yaml::Null
Some(_) => @yaml.Yaml::String(value)
// No explicit tag: only a plain scalar is resolved, a quoted one is
// always a string.
None =>
match style {
@yaml.TScalarStyle::Plain => resolve_plain_scalar(value)
_ => @yaml.Yaml::String(value)
}
}
}
///|
/// The implicit resolver table from `yaml/resolver.py`, in the order PyYAML
/// registers the resolvers. The tags are disjoint for these patterns, so the
/// order only matters for readability.
fn resolve_plain_scalar(value : String) -> @yaml.Yaml {
let ch = value.to_array()
if matches_yaml_bool(ch) {
construct_yaml_bool(value)
} else if matches_yaml_int(ch) {
construct_yaml_int(value)
} else if matches_yaml_float(ch) {
construct_yaml_float(value)
} else if matches_yaml_null(ch) {
@yaml.Yaml::Null
} else {
// `<<`, `=`, a timestamp, or an ordinary string. PyYAML turns `<<` into a
// merge and a timestamp into a `datetime`, which `merge.mbt` and the note
// at the top of this file cover.
@yaml.Yaml::String(value)
}
}
///|
fn construct_yaml_bool(value : String) -> @yaml.Yaml {
match value {
"yes" | "Yes" | "YES" | "true" | "True" | "TRUE" | "on" | "On" | "ON" =>
@yaml.Yaml::Boolean(true)
_ => @yaml.Yaml::Boolean(false)
}
}
///|
/// Parse a signed integer in `base`, or `None` when it does not fit.
fn parse_yaml_int(text : StringView, base : Int32) -> Int64? {
Some(@string.parse_int64(text, base~)) catch {
_ => None
}
}
///|
/// A non-negative integer as base 10^9 limbs, little-endian.
///
/// PyYAML's `int()` is unbounded, so an integer literal too large for an
/// `Int64` still has to print as the decimal text `yaml.safe_dump` produces.
/// The limbs keep every intermediate product below 2^63.
priv struct BigInt {
limbs : Array[Int64]
}
///|
const BIGINT_BASE : Int64 = 1000000000
///|
fn BigInt::new() -> BigInt {
{ limbs: [0], }
}
///|
/// `self = self * mul + add`.
fn BigInt::mul_add(self : BigInt, mul : Int64, add : Int64) -> Unit {
let mut carry = add
for i in 0.. 0 {
self.limbs.push(carry % BIGINT_BASE)
carry /= BIGINT_BASE
}
}
///|
/// `self = self + other`.
fn BigInt::add(self : BigInt, other : BigInt) -> Unit {
let mut carry : Int64 = 0
let mut index = 0
while index < other.limbs.length() {
if index >= self.limbs.length() {
self.limbs.push(0)
}
let sum = self.limbs[index] + other.limbs[index] + carry
self.limbs[index] = sum % BIGINT_BASE
carry = sum / BIGINT_BASE
index += 1
}
while carry > 0 {
if index >= self.limbs.length() {
self.limbs.push(0)
}
let sum = self.limbs[index] + carry
self.limbs[index] = sum % BIGINT_BASE
carry = sum / BIGINT_BASE
index += 1
}
}
///|
/// The value of a digit of any base PyYAML accepts. The resolver patterns
/// guarantee a valid digit, so anything else counts as zero.
fn yaml_digit_value(c : Char) -> Int64 {
if c >= '0' && c <= '9' {
(c.to_int() - 48).to_int64()
} else if c >= 'a' && c <= 'f' {
(c.to_int() - 87).to_int64()
} else if c >= 'A' && c <= 'F' {
(c.to_int() - 55).to_int64()
} else {
0
}
}
///|
fn BigInt::from_digits(text : StringView, base : Int64) -> BigInt {
let out = BigInt::new()
for c in text {
out.mul_add(base, yaml_digit_value(c))
}
out
}
///|
fn BigInt::to_decimal(self : BigInt) -> String {
let mut end = self.limbs.length()
while end > 1 && self.limbs[end - 1] == 0 {
end -= 1
}
let out = StringBuilder()
out.write_string(self.limbs[end - 1].to_string())
let mut index = end - 1
while index > 0 {
index -= 1
let text = self.limbs[index].to_string()
out.write_string(String::make(9 - text.length(), '0'))
out.write_string(text)
}
out.to_string()
}
///|
/// `parse_double` rejects a value outside the range of a `Double`, while
/// Python's `float()` rounds it to infinity (`1.8e+308` is `.inf`) or to zero
/// (`1e-999`). Rebuild the value digit by digit in that case, so the overflow
/// happens in the arithmetic. Values in range never reach this path.
fn parse_yaml_float(text : StringView) -> Double {
@string.parse_double(text) catch {
_ => decimal_to_double(text)
}
}
///|
fn decimal_to_double(text : StringView) -> Double {
let ch = text.to_array()
let mut index = 0
let mut negative = false
if index < ch.length() && (ch[index] == '+' || ch[index] == '-') {
negative = ch[index] == '-'
index += 1
}
let mut mantissa = 0.0
let mut exponent = 0
let mut seen_dot = false
while index < ch.length() && ch[index] != 'e' && ch[index] != 'E' {
let c = ch[index]
if c >= '0' && c <= '9' {
mantissa = mantissa * 10.0 + (c.to_int() - 48).to_double()
if seen_dot {
exponent -= 1
}
} else if c == '.' {
seen_dot = true
} else if c != '_' {
// Not a number at all.
return 0.0
}
index += 1
}
if index < ch.length() {
index += 1
let mut exp_negative = false
if index < ch.length() && (ch[index] == '+' || ch[index] == '-') {
exp_negative = ch[index] == '-'
index += 1
}
let mut magnitude = 0
while index < ch.length() {
let c = ch[index]
if c < '0' || c > '9' {
return 0.0
}
magnitude = magnitude * 10 + (c.to_int() - 48)
index += 1
}
// A huge exponent only has to reach the overflow point, so the loop is cut
// short once the value can no longer change.
if magnitude > 100000 {
magnitude = 100000
}
exponent += if exp_negative { -magnitude } else { magnitude }
}
let mut remaining = if exponent < 0 { -exponent } else { exponent }
while remaining > 0 &&
!mantissa.is_inf() &&
!mantissa.is_nan() &&
mantissa != 0.0 {
if exponent > 0 {
mantissa *= 10.0
} else {
mantissa /= 10.0
}
remaining -= 1
}
if negative {
-mantissa
} else {
mantissa
}
}
///|
/// Port of `SafeConstructor.construct_yaml_int`: the base prefixes and the
/// sexagesimal form PyYAML accepts but YAML 1.2 does not.
///
/// PyYAML parses through Python's unbounded `int()`. An integer that does not
/// fit in an `Int64` becomes its exact decimal text instead, which the emitter
/// writes back unquoted, so `config` still matches.
fn construct_yaml_int(value : String) -> @yaml.Yaml {
let cleaned = value.replace_all(old="_", new="")
let mut text = cleaned
let mut negative = false
if text.length() > 0 && (text[0] == '+' || text[0] == '-') {
negative = text[0] == '-'
text = text[1:].to_owned()
}
let magnitude = if text.contains(":") {
let total = BigInt::new()
for part in text.split(":") {
total.mul_add(60, 0)
total.add(BigInt::from_digits(part, 10))
}
total
} else if text.has_prefix("0b") {
BigInt::from_digits(text[2:], 2)
} else if text.has_prefix("0x") {
BigInt::from_digits(text[2:], 16)
} else if text.length() > 0 && text[0] == '0' {
BigInt::from_digits(text, 8)
} else {
BigInt::from_digits(text, 10)
}
let decimal = magnitude.to_decimal()
let signed = if negative { "-" + decimal } else { decimal }
match parse_yaml_int(signed, 10) {
Some(number) => @yaml.Yaml::Integer(number)
None => @yaml.Yaml::String(signed)
}
}
///|
/// Port of `SafeConstructor.construct_yaml_float`. The `repr` keeps the source
/// text, like the package's own loader, but the emitter formats from the value.
fn construct_yaml_float(value : String) -> @yaml.Yaml {
let cleaned = ascii_lower(value.replace_all(old="_", new=""))
let mut text = cleaned
let mut negative = false
if text.length() > 0 && (text[0] == '+' || text[0] == '-') {
negative = text[0] == '-'
text = text[1:].to_owned()
}
let magnitude : Double = if text == ".inf" {
@double.infinity
} else if text == ".nan" {
@double.not_a_number
} else if text.contains(":") {
let parts = text.split(":").to_array()
let mut total : Double = 0.0
let mut base : Double = 1.0
for index in 0..