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

Inline.pudu

Pudu479 lines20.4 KB

GitHub ↗
1/** @Docgen.Inline.Module — phrasing syntax parsed into article inline nodes */2module PuduLangDocgen.Inline34import Std.Char as Char5import Std.Map as Map6import PuduLangDocgen.Markdown.Crossref as Crossref7import PuduLangDocgen.Markdown.Lexicon as Lexicon8import PuduLangDocgen.Markdown.Syntax as Syntax910/// ASCII punctuation, which may be escaped and which decides delimiter flanking.11const PUNCTUATION: Str = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`\{|\}~"1213/** @Docgen.Inline.Scan — characters of one block with line offsets and link definitions */14type Scan = { cs: Array[Char], breaks: Array[Int], definitions: Map[Str, Syntax.Target], line: Int }1516/// Phrasing content of a block whose text starts at the given one-based line.17export fn parse(text: Str, definitions: &Map[Str, Syntax.Target], line: Int) -> Array[Syntax.Inline] {18  let cs = text.chars()19  var breaks: Array[Int] = []20  var count = 021  for character in cs {22    breaks = breaks.push(count)23    if character == '\n' { count = count + 1 }24  }25  let scan = Scan{cs: cs, breaks: breaks.push(count), definitions: *definitions, line: line}26  span(&scan, 0, cs.length(), 0)27}2829/// A reference label compared case-insensitively with collapsed white space.30export fn label(text: Str) -> Str {31  text.trim().toLower().split("\n").join(" ").split("\t").join(" ").split(" ").filter(|part: Str| !part.isEmpty()).join(" ")32}3334/// The text a reader sees, without markup.35export fn plain(inlines: &Array[Syntax.Inline]) -> Str {36  var pieces: Array[Str] = []37  for node in *inlines {38    let piece = match node {39      case Syntax.Text(written) => written40      case Syntax.Code(written) => written41      case Syntax.Emphasis(children) => plain(&children)42      case Syntax.Strong(children) => plain(&children)43      case Syntax.Strike(children) => plain(&children)44      case Syntax.Subscript(children) => plain(&children)45      case Syntax.Superscript(children) => plain(&children)46      case Syntax.Inserted(children) => plain(&children)47      case Syntax.Marked(children) => plain(&children)48      case Syntax.Note(_label) => ""49      case Syntax.Anchor(_target, children) => plain(&children)50      case Syntax.Picture(_target, alt) => alt51      case Syntax.Xref(reference) => if reference.text.isEmpty() { reference.uid } else { plain(&reference.text) }52      case Syntax.Math(written) => written53      case Syntax.Markup(_written) => ""54      case Syntax.LineBreak => " "55      case Syntax.SoftBreak => " "56      case Syntax.Fragment(_path, children) => plain(&children)57    }58    pieces = pieces.push(piece)59  }60  pieces.join("")61}6263/// The character at an index, or a space outside the text.64fn at(scan: &Scan, index: Int) -> Char {65  if index < 0 || index >= scan.cs.length() { ' ' } else { scan.cs[index] }66}6768/// Text of the characters in a half-open range.69fn slice(scan: &Scan, from: Int, to: Int) -> Str { Lexicon.slice(&scan.cs, from, to) }7071/// The one-based source line of a character.72fn lineAt(scan: &Scan, index: Int) -> Int { scan.line + scan.breaks[index] }7374/// Whether a character counts as white space for flanking; text edges do.75fn blank(character: Char) -> Bool { Lexicon.blank(character) }7677/// Whether a character is ASCII punctuation.78fn punctuation(character: Char) -> Bool { PUNCTUATION.contains(character.toText()) }7980/// Whether a character belongs to a word; non-ASCII letters do.81fn word(character: Char) -> Bool { Char.isAlphanumeric(character) || character.code() > 127 }8283/// Pending literal text appended as one node.84fn flushed(nodes: Array[Syntax.Inline], pending: &Array[Str]) -> Array[Syntax.Inline] {85  if pending.isEmpty() { nodes } else { nodes.push(Syntax.Text(pending.join(""))) }86}8788/// Inline nodes of a range, bounded to 32 nested spans.89fn span(scan: &Scan, from: Int, to: Int, depth: Int) -> Array[Syntax.Inline] {90  var nodes: Array[Syntax.Inline] = []91  var pending: Array[Str] = []92  var index = from93  while index < to {94    let character = scan.cs[index]95    let found = if depth > 32 { None } else { construct(scan, index, to, depth) }96    match found {97      case Some((made, next)) => {98        nodes = flushed(nodes, &pending)99        pending = []100        nodes = nodes.concat(made)101        index = next102      }103      case None => {104        if character == '\\' && index + 1 < to && punctuation(scan.cs[index + 1]) {105          pending = pending.push(scan.cs[index + 1].toText())106          index = index + 2107        } else if character == '\\' && index + 1 < to && scan.cs[index + 1] == '\n' {108          nodes = flushed(nodes, &pending).push(Syntax.LineBreak)109          pending = []110          index = index + 2111        } else if character == '\n' {112          var trailing = 0113          while pending.length() > trailing && pending[pending.length() - 1 - trailing] == " " { trailing = trailing + 1 }114          pending = pending.slice(0, pending.length() - trailing)115          nodes = flushed(nodes, &pending).push(if trailing >= 2 { Syntax.LineBreak } else { Syntax.SoftBreak })116          pending = []117          index = index + 1118          while index < to && (scan.cs[index] == ' ' || scan.cs[index] == '\t') { index = index + 1 }119        } else if character == '*' || character == '_' || character == '~' || character == '`' {120          let run = runLength(scan, index, to, character)121          pending = pending.push(slice(scan, index, index + run))122          index = index + run123        } else {124          pending = pending.push(character.toText())125          index = index + 1126        }127      }128    }129  }130  flushed(nodes, &pending)131}132133/// The construct starting at an index, as nodes and the index after it.134fn construct(scan: &Scan, index: Int, to: Int, depth: Int) -> Option[(Array[Syntax.Inline], Int)] {135  let character = scan.cs[index]136  if character == '`' { return code(scan, index, to) }137  if character == '*' || character == '_' || character == '~' { return emphasis(scan, index, to, depth) }138  if character == '^' { return paired(scan, index, to, depth, 1) }139  if character == '+' || character == '=' { return paired(scan, index, to, depth, 2) }140  if character == '[' { return bracket(scan, index, to, depth) }141  if character == '!' && at(scan, index + 1) == '[' { return image(scan, index, to) }142  if character == '<' { return angle(scan, index, to) }143  if character == '@' { return mention(scan, index, to) }144  if character == '&' { return entity(scan, index, to) }145  if character == ':' { return emoji(scan, index, to) }146  if character == '$' { return math(scan, index, to) }147  if character == 'h' || character == 'w' { return bare(scan, index, to) }148  None149}150151/// How many times a character repeats from an index.152fn runLength(scan: &Scan, index: Int, to: Int, character: Char) -> Int {153  var end = index154  while end < to && scan.cs[end] == character { end = end + 1 }155  end - index156}157158/// A code span closed by a backtick run of the same length.159fn code(scan: &Scan, index: Int, to: Int) -> Option[(Array[Syntax.Inline], Int)] {160  let run = runLength(scan, index, to, '`')161  let close = codeEnd(scan, index, to)162  if close < 0 { return None }163  var content = slice(scan, index + run, close).replace("\n", " ")164  if content.length() >= 2 && content.startsWith(" ") && content.endsWith(" ") && !content.trim().isEmpty() {165    content = content.drop(1).take(content.length() - 2)166  }167  Some(([Syntax.Code(content)], close + run))168}169170/// The start of the backtick run closing the code span at an index, or -1.171fn codeEnd(scan: &Scan, index: Int, to: Int) -> Int {172  let run = runLength(scan, index, to, '`')173  var cursor = index + run174  while cursor < to {175    if scan.cs[cursor] == '`' {176      let found = runLength(scan, cursor, to, '`')177      if found == run { return cursor }178      cursor = cursor + found179    } else { cursor = cursor + 1 }180  }181  -1182}183184/// Whether a delimiter run may open emphasis.185fn opens(scan: &Scan, index: Int, run: Int, to: Int) -> Bool {186  let before = at(scan, index - 1)187  let after = if index + run >= to { ' ' } else { scan.cs[index + run] }188  let left = !blank(after) && (!punctuation(after) || blank(before) || punctuation(before))189  if scan.cs[index] == '_' { left && !word(before) } else { left }190}191192/// Whether a delimiter run may close emphasis.193fn closes(scan: &Scan, index: Int, run: Int, to: Int) -> Bool {194  let before = at(scan, index - 1)195  let after = if index + run >= to { ' ' } else { scan.cs[index + run] }196  let right = !blank(before) && (!punctuation(before) || blank(after) || punctuation(after))197  if scan.cs[index] == '_' { right && !word(after) } else { right }198}199200/// The start of the run closing an opener of the given length, skipping nested spans, or -1.201fn closing(scan: &Scan, from: Int, to: Int, delimiter: Char, wanted: Int, depth: Int) -> Int {202  var cursor = from203  while cursor < to {204    let character = scan.cs[cursor]205    if character == '\\' {206      cursor = cursor + 2207    } else if character == '`' {208      let end = codeEnd(scan, cursor, to)209      cursor = if end < 0 { cursor + runLength(scan, cursor, to, '`') } else { end + runLength(scan, cursor, to, '`') }210    } else if character == '*' || character == '_' || character == '~' {211      let run = runLength(scan, cursor, to, character)212      let canClose = closes(scan, cursor, run, to)213      let canOpen = opens(scan, cursor, run, to)214      if character == delimiter && canClose && (!canOpen || run == wanted) { return cursor }215      if canOpen && depth < 32 {216        let nested = closing(scan, cursor + run, to, character, run, depth + 1)217        cursor = if nested < 0 { cursor + run } else { nested + runLength(scan, nested, to, character) }218      } else { cursor = cursor + run }219    } else { cursor = cursor + 1 }220  }221  -1222}223224/// Emphasis, strong emphasis, or strikethrough between matching delimiter runs.225fn emphasis(scan: &Scan, index: Int, to: Int, depth: Int) -> Option[(Array[Syntax.Inline], Int)] {226  let delimiter = scan.cs[index]227  let run = runLength(scan, index, to, delimiter)228  if delimiter == '~' && run > 2 { return None }229  if !opens(scan, index, run, to) { return None }230  let close = closing(scan, index + run, to, delimiter, run, 0)231  if close < 0 { return None }232  let closingRun = runLength(scan, close, to, delimiter)233  var used = if run < closingRun { run } else { closingRun }234  if used > 3 { used = 3 }235  if delimiter == '~' && closingRun != run { return None }236  let start = index + run237  let inner = span(scan, start, close, depth + 1)238  let node = if delimiter == '~' && used == 1 { Syntax.Subscript(inner) }239    else if delimiter == '~' { Syntax.Strike(inner) }240    else if used == 1 { Syntax.Emphasis(inner) }241    else if used == 2 { Syntax.Strong(inner) }242    else { Syntax.Emphasis([Syntax.Strong(inner)]) }243  let extra = run - used244  let lead: Array[Syntax.Inline] = if extra > 0 { [Syntax.Text(slice(scan, index, index + extra))] } else { [] }245  Some((lead.push(node), close + used))246}247248/// Superscript `^x^`, inserted `++x++`, or marked `==x==` text: an exact delimiter run249/// hugging its content on both sides.250fn paired(scan: &Scan, index: Int, to: Int, depth: Int, length: Int) -> Option[(Array[Syntax.Inline], Int)] {251  let delimiter = scan.cs[index]252  if runLength(scan, index, to, delimiter) != length || index + length >= to || blank(scan.cs[index + length]) { return None }253  var cursor = index + length254  while cursor < to {255    if scan.cs[cursor] == '\\' {256      cursor = cursor + 2257      continue258    }259    if scan.cs[cursor] == delimiter {260      let run = runLength(scan, cursor, to, delimiter)261      if run == length && !blank(scan.cs[cursor - 1]) {262        let inner = span(scan, index + length, cursor, depth + 1)263        let node = if delimiter == '^' { Syntax.Superscript(inner) } else if delimiter == '+' { Syntax.Inserted(inner) } else { Syntax.Marked(inner) }264        return Some(([node], cursor + length))265      }266      cursor = cursor + run267    } else { cursor = cursor + 1 }268  }269  None270}271272/// The index of the bracket closing the one at an index, or -1.273fn bracketEnd(scan: &Scan, index: Int, to: Int) -> Int {274  var level = 0275  var cursor = index276  while cursor < to {277    let character = scan.cs[cursor]278    if character == '\\' { cursor = cursor + 2279      continue }280    if character == '`' {281      let end = codeEnd(scan, cursor, to)282      if end >= 0 {283        cursor = end + runLength(scan, cursor, to, '`')284        continue285      }286    }287    if character == '[' { level = level + 1 }288    if character == ']' {289      level = level - 1290      if level == 0 { return cursor }291    }292    cursor = cursor + 1293  }294  -1295}296297/// A parenthesized destination with an optional quoted title, and the index after it.298fn destination(scan: &Scan, index: Int, to: Int) -> Option[(Syntax.Target, Int)] {299  if at(scan, index) != '(' || index >= to { return None }300  var cursor = index + 1301  while cursor < to && blank(scan.cs[cursor]) { cursor = cursor + 1 }302  let begin = cursor303  var written = ""304  if at(scan, cursor) == '<' {305    cursor = cursor + 1306    while cursor < to && scan.cs[cursor] != '>' && scan.cs[cursor] != '\n' { cursor = cursor + 1 }307    if cursor >= to || scan.cs[cursor] != '>' { return None }308    written = slice(scan, begin + 1, cursor)309    cursor = cursor + 1310  } else {311    var level = 0312    while cursor < to && !blank(scan.cs[cursor]) {313      let character = scan.cs[cursor]314      if character == '\\' && cursor + 1 < to { cursor = cursor + 1 }315        else if character == '(' { level = level + 1 }316        else if character == ')' {317        if level == 0 { break }318        level = level - 1319      }320      cursor = cursor + 1321    }322    written = slice(scan, begin, cursor)323  }324  while cursor < to && blank(scan.cs[cursor]) { cursor = cursor + 1 }325  var title = ""326  let opener = at(scan, cursor)327  if cursor < to && (opener == '"' || opener == '\'' || opener == '(') {328    let closer = if opener == '(' { ')' } else { opener }329    let titleStart = cursor + 1330    cursor = titleStart331    while cursor < to && scan.cs[cursor] != closer { cursor = cursor + 1 }332    if cursor >= to { return None }333    title = slice(scan, titleStart, cursor)334    cursor = cursor + 1335    while cursor < to && blank(scan.cs[cursor]) { cursor = cursor + 1 }336  }337  if cursor >= to || scan.cs[cursor] != ')' { return None }338  Some((Syntax.Target{destination: written, title: title, line: lineAt(scan, index)}, cursor + 1))339}340341/// A reference target after link text: `[label]`, `[]`, or the text itself as a label.342fn referenced(scan: &Scan, text: Str, after: Int, to: Int) -> Option[(Syntax.Target, Int)] {343  if at(scan, after) == '[' && after < to {344    let close = bracketEnd(scan, after, to)345    if close < 0 { return None }346    let written = slice(scan, after + 1, close)347    let key = if written.trim().isEmpty() { label(text) } else { label(written) }348    let found = Map.get(&scan.definitions, key) ?349    return Some((found, close + 1))350  }351  let found = Map.get(&scan.definitions, label(text)) ?352  Some((found, after))353}354355/// A link, a cross-reference link, or an inline include opened by a bracket.356fn bracket(scan: &Scan, index: Int, to: Int, depth: Int) -> Option[(Array[Syntax.Inline], Int)] {357  if at(scan, index + 1) == '!' && slice(scan, index + 2, if index + 9 <= to { index + 9 } else { to }).toLower() == "include" {358    return include(scan, index, to)359  }360  let close = bracketEnd(scan, index, to)361  if close < 0 { return None }362  let text = slice(scan, index + 1, close)363  if text.startsWith("^") && text.length() > 1 && !text.contains(" ") && at(scan, close + 1) != '(' && at(scan, close + 1) != ':' {364    return Some(([Syntax.Note(text.drop(1))], close + 1))365  }366  let found = match destination(scan, close + 1, to) {367    case Some(held) => Some(held)368    case None => referenced(scan, text, close + 1, to)369  }370  let (target, next) = found ?371  let children = span(scan, index + 1, close, depth + 1)372  if target.destination.startsWith("xref:") {373    return Some(([Crossref.span(target.destination.drop(5), children, target.line, false)], next))374  }375  Some(([Syntax.Anchor(target, children)], next))376}377378/// An inline include `[!INCLUDE[title](path)]`, resolved later against the docset.379fn include(scan: &Scan, index: Int, to: Int) -> Option[(Array[Syntax.Inline], Int)] {380  var cursor = index + 9381  while cursor < to && scan.cs[cursor] == ' ' { cursor = cursor + 1 }382  if at(scan, cursor) != '[' { return None }383  let close = bracketEnd(scan, cursor, to)384  if close < 0 { return None }385  let (target, next) = destination(scan, close + 1, to) ?386  if at(scan, next) != ']' || next >= to { return None }387  Some(([Syntax.Fragment(target.destination, [Syntax.Text(slice(scan, cursor + 1, close))])], next + 1))388}389390/// An image with alternate text taken from its label.391fn image(scan: &Scan, index: Int, to: Int) -> Option[(Array[Syntax.Inline], Int)] {392  let close = bracketEnd(scan, index + 1, to)393  if close < 0 { return None }394  let text = slice(scan, index + 2, close)395  let found = match destination(scan, close + 1, to) {396    case Some(held) => Some(held)397    case None => referenced(scan, text, close + 1, to)398  }399  let (target, next) = found ?400  let alt = plain(&span(scan, index + 2, close, 1))401  Some(([Syntax.Picture(target, alt)], next))402}403404/// Autolinks, cross references, comments, and allowed inline HTML opened by `<`.405fn angle(scan: &Scan, index: Int, to: Int) -> Option[(Array[Syntax.Inline], Int)] {406  var close = index + 1407  while close < to && scan.cs[close] != '>' && scan.cs[close] != '<' { close = close + 1 }408  if at(scan, index + 1) == '!' && slice(scan, index, if index + 4 <= to { index + 4 } else { to }) == "<!--" {409    var end = index + 4410    while end + 2 < to && !(scan.cs[end] == '-' && scan.cs[end + 1] == '-' && scan.cs[end + 2] == '>') { end = end + 1 }411    if end + 2 >= to { return None }412    return Some(([], end + 3))413  }414  if close >= to || scan.cs[close] != '>' { return None }415  let inner = slice(scan, index + 1, close)416  let line = lineAt(scan, index)417  if inner.startsWith("xref:") && !inner.contains(" ") { return Some(([Crossref.span(inner.drop(5), [], line, false)], close + 1)) }418  if inner.startsWith("xref ") {419    let found = Crossref.element(inner, line) ?420    return Some(([found], close + 1))421  }422  match Lexicon.autolink(inner) {423    case Some(target) => { return Some(([Syntax.Anchor(Syntax.Target{destination: target, title: "", line: line}, [Syntax.Text(inner)])], close + 1)) }424    case None => {}425  }426  let markup = Lexicon.tag(inner) ?427  Some(([Syntax.Markup(markup)], close + 1))428}429430/// The `@uid` shorthand, written after white space or at the start of a block.431fn mention(scan: &Scan, index: Int, to: Int) -> Option[(Array[Syntax.Inline], Int)] {432  let before = at(scan, index - 1)433  if index > 0 && !blank(before) && before != '(' && before != '[' { return None }434  let line = lineAt(scan, index)435  let quote = at(scan, index + 1)436  if index + 1 < to && (quote == '"' || quote == '\'') {437    var end = index + 2438    while end < to && scan.cs[end] != quote { end = end + 1 }439    if end >= to || end == index + 2 { return None }440    return Some(([Crossref.span(slice(scan, index + 2, end), [], line, true)], end + 1))441  }442  if index + 1 >= to || !Char.isLetter(scan.cs[index + 1]) { return None }443  var end = index + 1444  while end < to && (word(scan.cs[end]) || ".-_#*()`<>,".contains(scan.cs[end].toText())) { end = end + 1 }445  while end > index + 1 && ".,;:)>".contains(scan.cs[end - 1].toText()) { end = end - 1 }446  Some(([Crossref.span(slice(scan, index + 1, end), [], line, true)], end))447}448449/// A named or numeric character reference.450fn entity(scan: &Scan, index: Int, to: Int) -> Option[(Array[Syntax.Inline], Int)] {451  let (decoded, next) = Lexicon.entity(&scan.cs, index, to) ?452  Some(([Syntax.Text(decoded)], next))453}454455/// A known emoji short code.456fn emoji(scan: &Scan, index: Int, to: Int) -> Option[(Array[Syntax.Inline], Int)] {457  let (picture, next) = Lexicon.emoji(&scan.cs, index, to) ?458  Some(([Syntax.Text(picture)], next))459}460461/// Inline math between single dollar signs that hug their content.462fn math(scan: &Scan, index: Int, to: Int) -> Option[(Array[Syntax.Inline], Int)] {463  if index + 1 >= to || blank(scan.cs[index + 1]) || scan.cs[index + 1] == '$' { return None }464  var end = index + 1465  while end < to && scan.cs[end] != '$' && scan.cs[end] != '\n' {466    if scan.cs[end] == '\\' { end = end + 1 }467    end = end + 1468  }469  if end >= to || scan.cs[end] != '$' || blank(scan.cs[end - 1]) || Char.isDigit(at(scan, end + 1)) { return None }470  Some(([Syntax.Math(slice(scan, index + 1, end))], end + 1))471}472473/// A bare web address linked to itself; `www.` addresses use HTTPS.474fn bare(scan: &Scan, index: Int, to: Int) -> Option[(Array[Syntax.Inline], Int)] {475  let (written, next) = Lexicon.address(&scan.cs, index, to) ?476  let target = if written.toLower().startsWith("www.") { "https://" + written } else { written }477  Some(([Syntax.Anchor(Syntax.Target{destination: target, title: "", line: lineAt(scan, index)}, [Syntax.Text(written)])], next))478}479