Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-docgen

Documentation publishing for Pudu: articles, API references, navigation, search, and static websites

0.1.0Apache-2.01

InstallClose

Yaml.pudu

Pudu212 lines8.6 KB

GitHub ↗
1/** @Docgen.Yaml.Module — YAML read into metadata and metadata written as YAML */2module PuduLangDocgen.Yaml34import Std.Option as Option5import Std.Text as Text6import Std.Yaml as Reader7import PuduLangDocgen as Docgen8import PuduLangDocgen.Meta as Meta910/// YAML text decoded as metadata. Block scalars are read here, keeping blank lines and11/// working inside list items, and sequences written at their parent key's indentation are12/// indented, two layouts the standard decoder loses.13export fn decode(source: Str) -> Result[Docgen.Meta, Str] {14  let (plain, blocks) = extractBlocks(source)15  match Reader.decode(indentSequences(plain)) {16    case Ok(found) => Ok(restore(Meta.fromYaml(&found), &blocks))17    case Err(problem) => Err(Reader.explain(&problem))18  }19}2021/// Compact block sequences indented two columns under their key, keeping every other line.22fn indentSequences(source: Str) -> Str {23  let lines = source.split("\n")24  var shifts: Array[Int] = []25  var result: Array[Str] = []26  var index = 027  for line in lines {28    let content = line.trim()29    if content.isEmpty() || content.startsWith("#") {30      result = result.push(line)31      index = index + 132      continue33    }34    let column = line.length() - Text.trimStart(line).length()35    while !shifts.isEmpty() && !(column > shifts[shifts.length() - 1] || (column == shifts[shifts.length() - 1] && content.startsWith("- "))) {36      shifts = shifts.slice(0, shifts.length() - 1)37    }38    var padding: Array[Str] = []39    for _level in 0..shifts.length() { padding = padding.push("  ") }40    result = result.push(padding.join("") + line)41    if content.endsWith(":") {42      let key = if content.startsWith("- ") { column + 2 } else { column }43      var ahead = index + 144      while ahead < lines.length() && (lines[ahead].trim().isEmpty() || lines[ahead].trim().startsWith("#")) { ahead = ahead + 1 }45      if ahead < lines.length() {46        let next = lines[ahead]47        let nextColumn = next.length() - Text.trimStart(next).length()48        if nextColumn == key && next.trim().startsWith("- ") { shifts = shifts.push(key) }49      }50    }51    index = index + 152  }53  result.join("\n")54}5556/// Marks the start of a placeholder that stands for an extracted block scalar.57const BLOCK: Str = "\u{2}"5859/// Block scalar indicators recognized at the end of a key or list line.60const INDICATORS: Array[Str] = ["|", "|-", "|+", ">", ">-", ">+"]6162/// Text with each block scalar replaced by a quoted placeholder, and the scalars' values.63fn extractBlocks(source: Str) -> (Str, Array[Str]) {64  let all = source.split("\n")65  var kept: Array[Str] = []66  var texts: Array[Str] = []67  var index = 068  while index < all.length() {69    let line = all[index]70    let trimmed = Text.trimEnd(line)71    var indicator = ""72    for candidate in INDICATORS {73      if trimmed.endsWith(": " + candidate) || trimmed == "- " + candidate || trimmed.endsWith(" - " + candidate) { indicator = candidate }74    }75    if indicator.isEmpty() {76      kept = kept.push(line)77      index = index + 178      continue79    }80    let parent = line.length() - Text.trimStart(line).length()81    var end = index + 182    var body: Array[Str] = []83    while end < all.length() && (all[end].trim().isEmpty() || all[end].length() - Text.trimStart(all[end]).length() > parent) {84      body = body.push(all[end])85      end = end + 186    }87    var depth = 100000088    for held in body {89      if !held.trim().isEmpty() {90        let column = held.length() - Text.trimStart(held).length()91        if column < depth { depth = column }92      }93    }94    var content = body.map(|held: Str| if held.trim().isEmpty() { "" } else { held.drop(depth) })95    var trailing = 096    while content.length() > trailing && content[content.length() - 1 - trailing].isEmpty() { trailing = trailing + 1 }97    let lines = content.slice(0, content.length() - trailing)98    var value = if indicator.startsWith(">") { folded(&lines) } else { lines.join("\n") }99    if indicator.endsWith("+") { for _extra in 0..(trailing + 1) { value = value + "\n" } }100      else if !indicator.endsWith("-") && !lines.isEmpty() { value = value + "\n" }101    kept = kept.push(trimmed.take(trimmed.length() - indicator.length()) + "\"" + BLOCK + show(texts.length()) + "\"")102    texts = texts.push(value)103    index = end104  }105  (kept.join("\n"), texts)106}107108/// Lines of a folded scalar joined by spaces, with blank lines kept as line breaks.109fn folded(lines: &Array[Str]) -> Str {110  var pieces: Array[Str] = []111  var previous = ""112  for line in *lines {113    if line.isEmpty() { pieces = pieces.push("\n") }114      else if !pieces.isEmpty() && previous != "" && !line.startsWith(" ") { pieces = pieces.push(" " + line) }115      else { pieces = pieces.push(line) }116    previous = line117  }118  pieces.join("")119}120121/// Metadata with block scalar placeholders replaced by their values.122fn restore(value: Docgen.Meta, texts: &Array[Str]) -> Docgen.Meta {123  if texts.isEmpty() { return value }124  match value {125    case Docgen.Text(written) => {126      if written.startsWith(BLOCK) {127        match written.drop(1).toInt() {128          case Some(position) => { if position >= 0 && position < texts.length() { return Docgen.Text(texts[position]) } }129          case None => {}130        }131      }132      Docgen.Text(written)133    }134    case Docgen.Items(listed) => Docgen.Items(listed.map(|held: Docgen.Meta| restore(held, texts)))135    case Docgen.Fields(fields) => Docgen.Fields(fields.map(fn(entry: (Str, Docgen.Meta)) -> (Str, Docgen.Meta) {136          let (key, held) = entry137          (key, restore(held, texts))138        }))139    case other => other140  }141}142143/// Characters that make a YAML scalar need quotes.144const SPECIAL: Str = ":#[]\{\},&*!|>'\"%@`"145146/// Metadata written as YAML, keys in their order; nested values indent by two spaces.147export fn encode(value: &Docgen.Meta) -> Str { emitted(value, "").join("\n") + "\n" }148149/// YAML lines of a value at an indentation.150fn emitted(value: &Docgen.Meta, indent: Str) -> Array[Str] {151  match value {152    case Docgen.Fields(fields) => {153      if fields.isEmpty() { return [indent + "\{\}"] }154      var lines: Array[Str] = []155      for (key, held) in fields {156        let head = indent + scalar(key) + ":"157        match held {158          case Docgen.Fields(inner) => { lines = if inner.isEmpty() { lines.push(head + " \{\}") } else { lines.push(head).concat(emitted(&held, indent + "  ")) } }159          case Docgen.Items(listed) => { lines = if listed.isEmpty() { lines.push(head + " []") } else { lines.push(head).concat(emitted(&held, indent)) } }160          case other => { lines = lines.push(head + " " + leaf(&other, indent + "  ")) }161        }162      }163      lines164    }165    case Docgen.Items(listed) => {166      if listed.isEmpty() { return [indent + "[]"] }167      var lines: Array[Str] = []168      for held in listed {169        let nested = emitted(&held, indent + "  ")170        match held {171          case Docgen.Fields(_fields) => { lines = lines.push(indent + "- " + nested[0].drop(indent.length() + 2)).concat(nested.slice(1, nested.length())) }172          case Docgen.Items(_items) => { lines = lines.push(indent + "-").concat(nested) }173          case other => { lines = lines.push(indent + "- " + leaf(&other, indent + "  ")) }174        }175      }176      lines177    }178    case other => [indent + leaf(&other, indent + "  ")]179  }180}181182/// A scalar value as YAML; text spanning lines becomes a literal block at the indentation.183fn leaf(value: &Docgen.Meta, indent: Str) -> Str {184  match value {185    case Docgen.Text(written) => {186      if !written.contains("\n") { return scalar(written) }187      let body = written.split("\n").map(|line: Str| if line.isEmpty() { "" } else { indent + line }).join("\n")188      (if written.endsWith("\n") { "|" } else { "|-" }) + "\n" + body189    }190    case Docgen.Whole(count) => show(count)191    case Docgen.Flag(held) => if held { "true" } else { "false" }192    case _ => "null"193  }194}195196/// A YAML scalar, quoted when plain text would be read differently. Quoting prefers a form197/// without escape sequences, which every YAML reader agrees on.198export fn scalar(written: Str) -> Str {199  var plain = !written.isEmpty() && written == written.trim()200  var control = false201  for character in written.chars() {202    if SPECIAL.contains(character.toText()) { plain = false }203    if character < ' ' { control = true }204  }205  let lowered = written.toLower()206  if ["true", "false", "yes", "no", "null", "~", "on", "off"].contains(lowered) || Option.isSome(&written.toInt()) { plain = false }207  if plain && !control { return written }208  if !control && !written.contains("\"") && !written.contains("\\") { return "\"" + written + "\"" }209  if !control && !written.contains("'") { return "'" + written + "'" }210  "\"" + written.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\t", "\\t") + "\""211}212