///|
pub fn parse_yaml(text : String) -> YamlParseResult {
let lines : Array[YamlLine] = []
let raw_lines = split_lines_preserve(text)
for line_number, raw in raw_lines {
let stripped = strip_yaml_comment(raw)
if stripped.trim().to_owned() == "" {
continue
}
let mut indent = 0
let mut has_tab = false
for ch in stripped {
if ch == ' ' {
indent += 1
} else if ch == '\t' {
has_tab = true
break
} else {
break
}
}
let content = trim_ascii(stripped)
if has_tab {
let line = YamlLine::{ indent, content, line: line_number + 1 }
let state = YamlState::{ lines: [], index: [0], diagnostics: [] }
state.error(line, "tabs are not supported for YAML indentation")
return { value: None, diagnostics: { items: state.diagnostics } }
}
if indent % 2 != 0 {
let line = YamlLine::{ indent, content, line: line_number + 1 }
let state = YamlState::{ lines: [], index: [0], diagnostics: [] }
state.error(line, "YAML indentation must use multiples of two spaces")
return { value: None, diagnostics: { items: state.diagnostics } }
}
lines.push(YamlLine::{ indent, content, line: line_number + 1 })
}
let state = YamlState::{ lines, index: [0], diagnostics: [] }
if state.lines.length() == 0 {
state.error(
YamlLine::{ indent: 0, content: "", line: 1 },
"YAML document is empty",
)
return { value: None, diagnostics: { items: state.diagnostics } }
}
let root_indent = state.lines[0].indent
let value = parse_yaml_block(state, root_indent)
if state.index[0] < state.lines.length() {
state.error(
state.lines[state.index[0]],
"unexpected YAML content after root value",
)
}
let final_value = if state.diagnostics.length() == 0 { value } else { None }
{ value: final_value, diagnostics: { items: state.diagnostics } }
}
///|
fn parse_yaml_block(state : YamlState, indent : Int) -> JsonValue? {
if state.index[0] >= state.lines.length() {
return Some(JNull)
}
let line = state.lines[state.index[0]]
if line.indent < indent {
return Some(JNull)
}
if line.indent > indent {
state.error(line, "unexpected indentation")
return None
}
if yaml_starts_sequence(line.content) {
parse_yaml_sequence(state, indent)
} else {
parse_yaml_mapping(state, indent)
}
}
///|
fn parse_yaml_mapping(state : YamlState, indent : Int) -> JsonValue? {
let fields : Array[JsonPair] = []
while state.index[0] < state.lines.length() {
let line = state.lines[state.index[0]]
if line.indent < indent {
break
}
if line.indent > indent {
state.error(line, "mapping entry has inconsistent indentation")
return None
}
if yaml_starts_sequence(line.content) {
state.error(
line, "sequence item cannot appear inside a mapping without a key",
)
return None
}
match yaml_split_key(line.content) {
None => {
state.error(line, "expected a YAML key followed by a colon")
return None
}
Some((key, rest)) => {
if fields.any(fn(item) { item.key == key }) {
state.error(line, "duplicate YAML mapping key")
return None
}
state.index[0] += 1
let value = if rest == "" {
if state.index[0] < state.lines.length() &&
state.lines[state.index[0]].indent > indent {
parse_yaml_block(state, state.lines[state.index[0]].indent)
} else {
Some(JNull)
}
} else {
parse_yaml_scalar(state, line, rest)
}
match value {
Some(value) => fields.push(JsonPair::new(key, value))
None => return None
}
}
}
}
Some(JObject(fields))
}
///|
fn parse_yaml_sequence(state : YamlState, indent : Int) -> JsonValue? {
let values : Array[JsonValue] = []
while state.index[0] < state.lines.length() {
let line = state.lines[state.index[0]]
if line.indent < indent {
break
}
if line.indent > indent {
state.error(line, "sequence item has inconsistent indentation")
return None
}
if !yaml_starts_sequence(line.content) {
break
}
let rest = trim_ascii(
substring_owned(line.content, 1, line.content.length()),
)
state.index[0] += 1
if rest == "" {
if state.index[0] < state.lines.length() &&
state.lines[state.index[0]].indent > indent {
match parse_yaml_block(state, state.lines[state.index[0]].indent) {
Some(value) => values.push(value)
None => return None
}
} else {
values.push(JNull)
}
} else if yaml_split_key(rest) is Some((_, _)) {
let first = parse_yaml_inline_mapping(state, line, indent, rest)
match first {
Some(value) => values.push(value)
None => return None
}
} else {
match parse_yaml_scalar(state, line, rest) {
Some(value) => values.push(value)
None => return None
}
if state.index[0] < state.lines.length() &&
state.lines[state.index[0]].indent > indent {
state.error(
state.lines[state.index[0]],
"scalar sequence item cannot have nested content",
)
return None
}
}
}
Some(JArray(values))
}
///|
fn parse_yaml_inline_mapping(
state : YamlState,
sequence_line : YamlLine,
sequence_indent : Int,
first_text : String,
) -> JsonValue? {
let fields : Array[JsonPair] = []
let first = yaml_split_key(first_text).unwrap()
let (first_key, first_rest) = first
let first_value = if first_rest == "" {
if state.index[0] < state.lines.length() &&
state.lines[state.index[0]].indent > sequence_indent {
parse_yaml_block(state, state.lines[state.index[0]].indent)
} else {
Some(JNull)
}
} else {
parse_yaml_scalar(state, sequence_line, first_rest)
}
match first_value {
Some(value) => fields.push(JsonPair::new(first_key, value))
None => return None
}
if state.index[0] < state.lines.length() &&
state.lines[state.index[0]].indent > sequence_indent {
let continuation_indent = state.lines[state.index[0]].indent
match parse_yaml_mapping(state, continuation_indent) {
Some(JObject(more)) =>
for field in more {
if fields.any(fn(existing) { existing.key == field.key }) {
state.error(
sequence_line, "duplicate YAML mapping key in sequence item",
)
return None
}
fields.push(field)
}
Some(_) => {
state.error(
sequence_line, "sequence mapping continuation must be a mapping",
)
return None
}
None => return None
}
}
Some(JObject(fields))
}
///|
fn parse_yaml_scalar(
state : YamlState,
line : YamlLine,
text : String,
) -> JsonValue? {
if text[0] == '&' || text[0] == '*' {
state.error(line, "YAML anchors and aliases are not supported")
return None
}
if is_quoted(text) {
if text[0] == '"' {
let parsed = parse_json(text)
match parsed.value {
Some(JString(value)) => return Some(JString(value))
_ => {
state.error(line, "invalid double-quoted YAML scalar")
return None
}
}
}
return Some(JString(unquote_single(text)))
}
if text == "true" {
return Some(JBool(true))
}
if text == "false" {
return Some(JBool(false))
}
if text == "null" || text == "~" {
return Some(JNull)
}
if text[0] == '[' || text[0] == '{' {
let parsed = parse_json(text)
if parsed.value is Some(value) && !parsed.diagnostics.has_errors() {
return Some(value)
}
}
let number = parse_json(text)
match number.value {
Some(JNumber(value)) if !number.diagnostics.has_errors() =>
Some(JNumber(value))
_ => Some(JString(text))
}
}
///|
fn yaml_starts_sequence(text : String) -> Bool {
text.length() > 0 && text[0] == '-'
}
///|
fn yaml_split_key(text : String) -> (String, String)? {
let mut quote : Char? = None
let mut found : (String, String)? = None
for i, ch in text {
if ch == '\'' || ch == '"' {
match quote {
Some(current) if current == ch => quote = None
None => quote = Some(ch)
_ => ()
}
} else if ch == ':' && quote is None {
let key = trim_ascii(substring_owned(text, 0, i))
let rest = trim_ascii(substring_owned(text, i + 1, text.length()))
if key != "" {
found = Some((unquote_key(key), rest))
}
}
}
found
}
///|
fn strip_yaml_comment(text : String) -> String {
let builder = StringBuilder::new()
let mut quote : Char? = None
let mut previous_space = true
for ch in text {
if ch == '\'' || ch == '"' {
match quote {
Some(current) if current == ch => quote = None
None => quote = Some(ch)
_ => ()
}
builder.write_char(ch)
previous_space = false
} else if ch == '#' && quote is None && previous_space {
break
} else {
builder.write_char(ch)
previous_space = ch == ' ' || ch == '\t'
}
}
builder.to_string()
}
///|
fn substring_owned(text : String, start : Int, end : Int) -> String {
let builder = StringBuilder::new()
let mut index = 0
for ch in text {
if index >= start && index < end {
builder.write_char(ch)
}
index += 1
}
builder.to_string()
}
///|
fn unquote_single(text : String) -> String {
if text.length() < 2 {
text
} else {
let inner = substring_owned(text, 1, text.length() - 1)
inner.replace(old="''", new="'")
}
}
///|
fn unquote_key(text : String) -> String {
if is_quoted(text) {
if text[0] == '\'' {
unquote_single(text)
} else {
match parse_json(text).value {
Some(JString(value)) => value
_ => text
}
}
} else {
text
}
}