///|
// YAML loading as Ruby's `YAML.safe_load` (Psych 5) does it: the syntax
// comes from moonbit-community/yaml's event parser; the values are built
// here, with Psych's resolution of plain scalars (`ScalarScanner#tokenize`:
// YAML 1.1 booleans such as `yes`/`off`, octal and sexagesimal integers,
// integers with `,`/`_` separators), aliases, merge keys (`<<`), duplicate
// keys (the last value wins, in the first one's place) and the classes
// `safe_load` refuses (dates, times, symbols).

///|
/// Raised when a theme cannot be loaded: malformed YAML, a value
/// `safe_load` refuses, a missing file.
pub suberror ThemeError {
  ThemeError(String)
} derive(Debug)

///|
pub extend ThemeError with Debug::{to_repr}

///|
pub extend ThemeError with Show::{to_string, output}

///|
pub impl Show for ThemeError with fn output(self, logger) {
  let ThemeError(message) = self
  logger.write_string(message)
}

///|
priv enum Frame {
  Seq(Array[Value], Int)
  /// a mapping: its entries, its anchor, the pending key (with whether it
  /// is the merge key `<<`)
  Mapping(Map[String, Value], Int, mut key~ : (String, Bool)?)
}

///|
priv struct Builder {
  stack : Array[Frame]
  anchors : Map[Int, Value]
  mut root : Value?
  mut error : String?
}

///|
impl @yaml.MarkedEventReceiver for Builder with fn on_event(self, ev, mark) {
  if self.error is Some(_) || self.root is Some(_) {
    return
  }
  self.handle(ev) catch {
    ThemeError(message) =>
      self.error = Some("\{message} at line \{mark.line} column \{mark.col}")
  }
}

///|
fn Builder::handle(self : Builder, ev : @yaml.Event) -> Unit raise ThemeError {
  match ev {
    StreamStart | StreamEnd | DocumentStart => ()
    DocumentEnd => if self.root is None { self.root = Some(Null) }
    SequenceStart(id~, ..) => self.stack.push(Seq([], id))
    MappingStart(id~, ..) => self.stack.push(Mapping(Map([]), id, key=None))
    SequenceEnd =>
      match self.stack.pop() {
        Some(Seq(items, id)) => self.insert(Array(items), id, false)
        _ => raise ThemeError("unbalanced sequence")
      }
    MappingEnd =>
      match self.stack.pop() {
        Some(Mapping(map, id, ..)) =>
          self.insert(Hash(map, symbols=false), id, false)
        _ => raise ThemeError("unbalanced mapping")
      }
    Scalar(value~, style~, id~, tag~) => {
      let plain = style is Plain
      let v = if !plain {
        match tag {
          Some(t) => tagged_scalar(t, value, false)
          None => Str(value)
        }
      } else {
        match tag {
          Some(t) => tagged_scalar(t, value, true)
          None => tokenize(value)
        }
      }
      self.insert(v, id, plain && tag is None && value == "<<")
    }
    Alias(id~) =>
      match self.anchors.get(id) {
        Some(v) => self.insert(v, 0, false)
        None => raise ThemeError("unknown alias")
      }
  }
}

///|
fn Builder::insert(
  self : Builder,
  v : Value,
  anchor : Int,
  merge_key : Bool,
) -> Unit {
  if anchor > 0 {
    self.anchors[anchor] = v
  }
  match self.stack.last() {
    None => self.root = Some(v)
    Some(Seq(items, _)) => items.push(v)
    Some(Mapping(_, _, key=None) as frame) =>
      frame.key = Some((hash_key(v), merge_key))
    Some(Mapping(map, _, key=Some((key, is_merge))) as frame) => {
      frame.key = None
      if is_merge {
        // Psych's `revive_hash`: a mapping merges in; a sequence merges its
        // mappings, the earlier ones winning
        match v {
          Hash(other, ..) =>
            for k, x in other {
              map[k] = x
            }
          Array(items) if items.iter().all(i => i is Hash(_, ..)) => {
            let merged : Map[String, Value] = Map([])
            for i = items.length() - 1; i >= 0; i = i - 1 {
              guard items[i] is Hash(other, ..) else { continue }
              for k, x in other {
                merged[k] = x
              }
            }
            for k, x in merged {
              map[k] = x
            }
          }
          _ => map[key] = v
        }
      } else {
        map[key] = v
      }
    }
  }
}

///|
/// A mapping key: theme keys are strings; any other scalar stands for its
/// Ruby `to_s`.
fn hash_key(v : Value) -> String {
  v.to_ruby_s()
}

///|
/// A scalar with an explicit tag (`!!str`, `!!int`, ...).
fn tagged_scalar(
  tag : @yaml.Tag,
  value : String,
  plain : Bool,
) -> Value raise ThemeError {
  let name = if tag.handle == "!!" || tag.handle == "tag:yaml.org,2002:" {
    tag.suffix
  } else {
    raise ThemeError(
      "tried to load unspecified class: \{tag.handle}\{tag.suffix}",
    )
  }
  match name {
    "str" => Str(value)
    "float" =>
      match tokenize(value) {
        Int(i) => Float(i.to_double())
        Float(f) => Float(f)
        _ => raise ThemeError("invalid value for Float(): \{value}")
      }
    "int" | "bool" | "null" => if plain { tokenize(value) } else { Str(value) }
    "seq" | "map" => Str(value)
    _ => raise ThemeError("tried to load unspecified class: !!\{name}")
  }
}

///|
/// Parse YAML text as `YAML.safe_load(text, aliases: true)`: the first
/// document's value (Null for an empty document).
pub fn safe_load(text : String) -> Value raise ThemeError {
  let parser = @yaml.Parser::new(text)
  let builder : Builder = {
    stack: [],
    anchors: Map([]),
    root: None,
    error: None,
  }
  parser.load(builder, false) catch {
    @yaml.YamlError(mark~, info~) =>
      raise ThemeError(
        "(): \{info} at line \{mark.line} column \{mark.col}",
      )
  }
  match builder.error {
    Some(e) => raise ThemeError(e)
    None => ()
  }
  builder.root.unwrap_or(Null)
}

///|
fn is_alpha(c : Char) -> Bool {
  (c >= 'a' && c <= 'z') ||
  (c >= 'A' && c <= 'Z') ||
  (
  // beyond ASCII, approximately `[[:alpha:]]`: letters, not spaces,
  // digits, punctuation or symbols
    c.to_int() >= 0xC0 &&
    !c.is_whitespace() &&
    !c.is_numeric() &&
    c.is_printable() &&
    !(c.to_int() >= 0x2000 && c.to_int() < 0x2C00) &&
    !(c.to_int() >= 0x3000 && c.to_int() < 0x3040) &&
    c != '×' &&
    c != '÷'
  )
}

///|
/// `[[:alpha:]_\s!@#$%\^&*(){}<>|/\\~;=]`: the characters of Psych's
/// string-looking prefix.
fn is_wordish(c : Char) -> Bool {
  is_alpha(c) || is_ruby_space(c) || "_!@#$%^&*(){}<>|/\\~;=".contains_char(c)
}

///|
fn all_chars(s : String, f : (Char) -> Bool) -> Bool {
  s.iter().all(f)
}

///|
/// `[-+]?` then the rest.
fn strip_sign(s : String) -> (String, String) {
  if s.has_prefix("-") || s.has_prefix("+") {
    (s[:1].to_owned(), s[1:].to_owned())
  } else {
    ("", s)
  }
}

///|
/// Psych's `INTEGER_LEGACY`.
fn is_legacy_integer(s : String) -> Bool {
  let (_, body) = strip_sign(s)
  if body == "" {
    return false
  }
  let chars = body.iter().collect()
  // base 2: 0b[_,]*[0-1][0-1_,]*
  if body.has_prefix("0b") {
    let rest = chars[2:]
    let mut i = 0
    while i < rest.length() && (rest[i] == '_' || rest[i] == ',') {
      i += 1
    }
    return i < rest.length() &&
      (rest[i] == '0' || rest[i] == '1') &&
      rest[i:].iter().all(c => c == '0' || c == '1' || c == '_' || c == ',')
  }
  // base 16: 0x[_,]*[0-9a-fA-F][0-9a-fA-F_,]*
  if body.has_prefix("0x") {
    let rest = chars[2:]
    let is_hex = (c : Char) => {
      is_digit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
    }
    let mut i = 0
    while i < rest.length() && (rest[i] == '_' || rest[i] == ',') {
      i += 1
    }
    return i < rest.length() &&
      is_hex(rest[i]) &&
      rest[i:].iter().all(c => is_hex(c) || c == '_' || c == ',')
  }
  // base 8: 0[_,]*[0-7][0-7_,]*
  if chars[0] == '0' && chars.length() > 1 {
    let rest = chars[1:]
    let mut i = 0
    while i < rest.length() && (rest[i] == '_' || rest[i] == ',') {
      i += 1
    }
    if i < rest.length() &&
      rest[i] >= '0' &&
      rest[i] <= '7' &&
      rest[i:].iter().all(c => (c >= '0' && c <= '7') || c == '_' || c == ',') {
      return true
    }
  }
  // base 10: 0|[1-9](?:[0-9]|,[0-9]|_[0-9])*
  if body == "0" {
    return true
  }
  if !(chars[0] >= '1' && chars[0] <= '9') {
    return false
  }
  let mut i = 1
  while i < chars.length() {
    if is_digit(chars[i]) {
      i += 1
    } else if (chars[i] == ',' || chars[i] == '_') &&
      i + 1 < chars.length() &&
      is_digit(chars[i + 1]) {
      i += 2
    } else {
      return false
    }
  }
  true
}

///|
/// Ruby's `Integer(str)` for what `is_legacy_integer` accepts, with the
/// separators deleted.
fn parse_legacy_integer(s : String) -> Value {
  let cleaned = s.iter().filter(c => c != ',' && c != '_').collect()
  let text = String::from_array(cleaned)
  let (sign, body) = strip_sign(text)
  let (digits, radix) = if body.has_prefix("0b") {
    (body[2:].to_owned(), 2)
  } else if body.has_prefix("0x") {
    (body[2:].to_owned(), 16)
  } else if body.length() > 1 && body.has_prefix("0") {
    (body[1:].to_owned(), 8)
  } else {
    (body, 10)
  }
  let v = @string.parse_int64(digits, base=radix) catch { _ => return Str(s) }
  Int(if sign == "-" { -v } else { v })
}

///|
/// Psych's `FLOAT`: `[-+]?([0-9][0-9_,]*)?\.[0-9]*([eE][-+][0-9]+)?`.
fn is_psych_float(s : String) -> Bool {
  let (_, body) = strip_sign(s)
  let chars : Array[Char] = body.iter().collect()
  let mut i = 0
  if i < chars.length() && is_digit(chars[i]) {
    i += 1
    while i < chars.length() &&
          (is_digit(chars[i]) || chars[i] == '_' || chars[i] == ',') {
      i += 1
    }
  }
  if i >= chars.length() || chars[i] != '.' {
    return false
  }
  i += 1
  while i < chars.length() && is_digit(chars[i]) {
    i += 1
  }
  if i < chars.length() && (chars[i] == 'e' || chars[i] == 'E') {
    i += 1
    if i >= chars.length() || !(chars[i] == '-' || chars[i] == '+') {
      return false
    }
    i += 1
    let start = i
    while i < chars.length() && is_digit(chars[i]) {
      i += 1
    }
    if i == start {
      return false
    }
  }
  i == chars.length()
}

///|
/// `^[-+]?[0-9][0-9_]*(:[0-5]?[0-9]){1,2}` then `$` (integer) or
/// `\.[0-9_]*$` (float); returns the parts.
fn sexagesimal(s : String, float : Bool) -> Array[String]? {
  let (_, body) = strip_sign(s)
  let (main, frac_ok) = if float {
    match body.find(".") {
      Some(i) => {
        let frac = body[i + 1:].to_owned()
        (body[:i].to_owned(), all_chars(frac, c => is_digit(c) || c == '_'))
      }
      None => return None
    }
  } else {
    (body, true)
  }
  if !frac_ok {
    return None
  }
  let parts = main.split(":").map(p => p.to_owned()).collect()
  if parts.length() < 2 || parts.length() > 3 {
    return None
  }
  let head = parts[0]
  if head == "" ||
    !is_digit(head.get_char(0).unwrap_or('x')) ||
    !all_chars(head, c => is_digit(c) || c == '_') {
    return None
  }
  for p in parts[1:] {
    let ok = match p.length() {
      1 => is_digit(p.get_char(0).unwrap_or('x'))
      2 => {
        let a = p.get_char(0).unwrap_or('x')
        a >= '0' && a <= '5' && is_digit(p.get_char(1).unwrap_or('x'))
      }
      _ => false
    }
    if !ok {
      return None
    }
  }
  Some(s.split(":").map(p => p.to_owned()).collect())
}

///|
/// `^-?\d{4}-\d{1,2}-\d{1,2}(?:[Tt]|\s+)\d{1,2}:\d\d:\d\d...`: a
/// timestamp (a Ruby Time, which `safe_load` refuses).
fn looks_like_time(s : String) -> Bool {
  let body = if s.has_prefix("-") { s[1:].to_owned() } else { s }
  let chars : Array[Char] = body.iter().collect()
  let mut i = 0
  let digits = (min : Int, max : Int) => {
    let start = i
    while i < chars.length() && is_digit(chars[i]) && i - start < max {
      i += 1
    }
    i - start >= min
  }
  if !digits(4, 4) || i >= chars.length() || chars[i] != '-' {
    return false
  }
  i += 1
  if !digits(1, 2) || i >= chars.length() || chars[i] != '-' {
    return false
  }
  i += 1
  if !digits(1, 2) || i >= chars.length() {
    return false
  }
  if chars[i] == 'T' || chars[i] == 't' {
    i += 1
  } else if is_ruby_space(chars[i]) {
    while i < chars.length() && is_ruby_space(chars[i]) {
      i += 1
    }
  } else {
    return false
  }
  if !digits(1, 2) || i >= chars.length() || chars[i] != ':' {
    return false
  }
  i += 1
  if !digits(2, 2) || i >= chars.length() || chars[i] != ':' {
    return false
  }
  i += 1
  // the seconds; the fraction and zone are not checked further (a
  // timestamp-looking value is refused either way)
  digits(2, 2)
}

///|
/// `^\d{4}-(?:1[012]|0\d|\d)-(?:[12]\d|3[01]|0\d|\d)$` with a valid date:
/// a Ruby Date, which `safe_load` refuses.
fn looks_like_date(s : String) -> Bool {
  let parts = s.split("-").map(p => p.to_owned()).collect()
  if parts.length() != 3 {
    return false
  }
  let (y, m, d) = (parts[0], parts[1], parts[2])
  if y.length() != 4 || !all_chars(y, is_digit) {
    return false
  }
  if m.length() < 1 || m.length() > 2 || !all_chars(m, is_digit) {
    return false
  }
  if d.length() < 1 || d.length() > 2 || !all_chars(d, is_digit) {
    return false
  }
  let month = @string.parse_int(m) catch { _ => 0 }
  let day = @string.parse_int(d) catch { _ => 0 }
  if m.length() == 2 && m[0] == '1' && month > 12 {
    return false
  }
  if d.length() == 2 && ((d[0] == '3' && day > 31) || d[0] > '3') {
    return false
  }
  // strptime refuses an impossible date (the scalar stays a string)
  let year = @string.parse_int(y) catch { _ => 0 }
  let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
  let days = [
    31,
    if leap {
      29
    } else {
      28
    },
    31,
    30,
    31,
    30,
    31,
    31,
    30,
    31,
    30,
    31,
  ]
  month >= 1 && month <= 12 && day >= 1 && day <= days[month - 1]
}

///|
/// Psych's `ScalarScanner#tokenize` (strict_integer: false) for a plain
/// scalar.
pub fn tokenize(s : String) -> Value raise ThemeError {
  if s == "" {
    return Null
  }
  let chars : Array[Char] = s.iter().collect()
  // `^[^\d.:-]?[[:alpha:]_\s!@#$%\^&*(){}<>|/\\~;=]+` or a newline
  let wordish = is_wordish(chars[0]) ||
    (
      chars.length() > 1 &&
      !(is_digit(chars[0]) ||
      chars[0] == '.' ||
      chars[0] == ':' ||
      chars[0] == '-') &&
      is_wordish(chars[1])
    )
  if wordish || s.contains("\n") {
    if chars.length() > 5 {
      return Str(s)
    }
    let lower = s.to_lower()
    let first = lower.get_char(0).unwrap_or(' ')
    if !"ytonf~".contains_char(first) {
      return Str(s)
    } else if s == "~" || lower == "null" {
      return Null
    } else if lower == "yes" || lower == "true" || lower == "on" {
      return Bool(true)
    } else if lower == "no" || lower == "false" || lower == "off" {
      return Bool(false)
    } else {
      return Str(s)
    }
  }
  if looks_like_time(s) {
    raise ThemeError("Tried to load unspecified class: Time")
  }
  if looks_like_date(s) {
    raise ThemeError("Tried to load unspecified class: Date")
  }
  let lower = s.to_lower()
  if lower == ".inf" || lower == "+.inf" {
    return Float(@double.infinity)
  }
  if lower == "-.inf" {
    return Float(@double.neg_infinity)
  }
  if lower == ".nan" {
    return Float(@double.not_a_number)
  }
  if chars.length() > 1 && chars[0] == ':' {
    raise ThemeError("Tried to load unspecified class: Symbol")
  }
  match sexagesimal(s, false) {
    Some(parts) => {
      let mut total = 0L
      for e, n in parts {
        let p = (e - 2).abs()
        let mut scale = 1L
        for _ in 0..

() } match sexagesimal(s, true) { Some(parts) => { let mut total = 0.0 for e, n in parts { let p = (e - 2).abs() let mut scale = 1.0 for _ in 0..

() } if is_psych_float(s) { if s == "." || s == "-." || s == "+." { return Str(s) } // Float(string.delete(',_').gsub(/\.([Ee]|$)/, '\1')) let cleaned = String::from_array( s.iter().filter(c => c != ',' && c != '_').collect(), ) let cleaned = cleaned .replace_all(old=".e", new="e") .replace_all(old=".E", new="E") let cleaned = if cleaned.has_suffix(".") { cleaned[:cleaned.length() - 1].to_owned() } else { cleaned } return Float(ruby_string_to_f(cleaned)) } if is_legacy_integer(s) { return parse_legacy_integer(s) } Str(s) }