///|
/// The line ending that a text uses most, so that edits can reuse it.
pub(all) enum LineEnding {
Lf
CrLf
Cr
} derive(Eq, Debug)
///|
pub fn LineEnding::text(self : LineEnding) -> String {
match self {
Lf => "\n"
CrLf => "\r\n"
Cr => "\r"
}
}
///|
/// The line ending of the first line break in `text`, or `Lf` when there is none.
pub fn detect_line_ending(text : String) -> LineEnding {
let n = text.length()
for i in 0.. return Lf
'\r' => return if i + 1 < n && text[i + 1] == '\n' { CrLf } else { Cr }
_ => ()
}
}
Lf
}
///|
/// One line of a text, with its start offset and its line ending. Only
/// `split_lines` makes lines.
pub struct Line {
/// The line text without its line ending.
text : String
/// The UTF-16 offset of the first character of the line.
start : Int32
/// The line ending after the line. A last line can have none.
ending : LineEnding?
} derive(Eq, Debug)
///|
/// The line ending text after the line: `"\n"`, `"\r\n"`, `"\r"`, or `""`
/// for a last line without one.
pub fn Line::ending_text(self : Line) -> String {
match self.ending {
Some(e) => e.text()
None => ""
}
}
///|
/// Splits `text` into lines. LF, CRLF and CR all end a line. A final line
/// without a line ending is kept; an empty text has no lines.
pub fn split_lines(text : String) -> Array[Line] {
let lines = []
let n = text.length()
let mut start = 0
let mut i = 0
while i < n {
let c = text[i]
if c == '\n' || c == '\r' {
let ending : LineEnding = if c == '\r' && i + 1 < n && text[i + 1] == '\n' {
CrLf
} else if c == '\r' {
Cr
} else {
Lf
}
lines.push({
text: text[start:i].to_owned(),
start,
ending: Some(ending),
})
i += ending.text().length()
start = i
} else {
i += 1
}
}
if start < n {
lines.push({ text: text[start:n].to_owned(), start, ending: None, })
}
lines
}
///|
/// How a document starts.
pub(all) enum HeaderShape {
/// The document does not start with a `---` line.
NoBlock
/// A frontmatter block between `---` and a `---` or `...` line.
Block(raw~ : String)
/// A `---` first line with no closing line.
Unterminated
} derive(Eq, Debug)
///|
/// The split of a document into its frontmatter block and its Markdown body.
pub(all) struct Header {
shape : HeaderShape
/// The text after the closing delimiter line, or the whole text without a block.
body : String
/// The UTF-16 offset of the body in the original text.
body_offset : Int32
/// The file line of the first body line, counted from 1.
body_line : Int32
/// True when the text starts with a byte order mark.
bom : Bool
} derive(Eq, Debug)
///|
fn is_delimiter(line : String, marks : Array[String]) -> Bool {
let trimmed = line.trim_end(chars=" \t").to_owned()
marks.contains(trimmed)
}
///|
/// Splits `text` into a frontmatter block and a body, as OKF and
/// `moonrockz/mdc` read it: a first line `---` opens the block, and the next
/// `---` or `...` line closes it. A leading byte order mark is allowed.
pub fn split_header(text : String) -> Header {
let bom = text.length() > 0 && text[0] == 0xFEFF
let start = if bom { 1 } else { 0 }
let content = text[start:].to_owned()
let lines = split_lines(content)
if lines.is_empty() || !is_delimiter(lines[0].text, ["---"]) {
return {
shape: NoBlock,
body: content,
body_offset: start,
body_line: 1,
bom,
}
}
for i in 1.. Bool {
if line.length() == 0 {
return false
}
let c = line[0]
c == ' ' || c == '\t' || line == "-" || line.has_prefix("- ")
}
///|
fn strip_comment(text : String) -> String {
// A comment starts at `#` after white space, outside quotes.
let mut quote : Char? = None
let chars = text.to_array()
for i, ch in chars {
match quote {
Some(q) => if ch == q { quote = None }
None =>
if ch == '"' || ch == '\'' {
quote = Some(ch)
} else if ch == '#' &&
(i == 0 || chars[i - 1] == ' ' || chars[i - 1] == '\t') {
return String::from_array(chars[:i]).trim_end(chars=" \t").to_owned()
}
}
}
text.trim_end(chars=" \t").to_owned()
}
///|
/// Parses a key line, for example `title: Revenue` or `"odd key": 1`.
fn parse_key_line(line : String) -> (String, String)? {
if line.length() == 0 {
return None
}
let c = line[0]
if c == ' ' || c == '\t' || c == '#' || c == '-' {
return None
}
if c == '"' || c == '\'' {
let chars = line.to_array()
let quote = chars[0]
for i in 1.. {
let after = line[i + 1:].to_owned()
if after.length() > 0 && after[0] != ' ' && after[0] != '\t' {
return None
}
let key = line[:i].trim_end(chars=" \t").to_owned()
Some((key, strip_comment(after).trim(chars=" \t").to_owned()))
}
None => None
}
}
///|
/// Finds each top-level key of a raw frontmatter block, in source order, with
/// the lines of its value. Trailing blank and comment lines do not belong to a value.
pub fn scan_entries(raw : String) -> Array[RawEntry] {
let lines = split_lines(raw).map(l => l.text)
let entries : Array[RawEntry] = []
let mut i = 0
while i < lines.length() {
match parse_key_line(lines[i]) {
Some((key, inline)) => {
let mut last = i
let mut j = i + 1
while j < lines.length() {
let line = lines[j]
if is_continuation(line) {
last = j
j += 1
} else if line.trim(chars=" \t").length() == 0 || line.has_prefix("#") {
j += 1
} else {
break
}
}
entries.push({ key, inline, first_line: i, last_line: last, })
i = last + 1
}
None => i += 1
}
}
entries
}
///|
pub extend LineEnding with Eq::{not_equal, equal}
///|
pub extend LineEnding with @debug.Debug::{to_repr}
///|
pub extend Line with Eq::{not_equal, equal}
///|
pub extend Line with @debug.Debug::{to_repr}
///|
pub extend HeaderShape with Eq::{not_equal, equal}
///|
pub extend HeaderShape with @debug.Debug::{to_repr}
///|
pub extend Header with Eq::{not_equal, equal}
///|
pub extend Header with @debug.Debug::{to_repr}
///|
pub extend RawEntry with Eq::{not_equal, equal}
///|
pub extend RawEntry with @debug.Debug::{to_repr}