#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Value {
Null,
Text(String),
List(Vec<Value>),
Map(Vec<(String, Value)>),
}
impl Value {
pub fn get(&self, key: &str) -> Option<&Value> {
match self {
Value::Map(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
}
}
pub fn text(&self) -> Option<&str> {
match self {
Value::Text(text) => Some(text),
_ => None,
}
}
pub fn list(&self) -> &[Value] {
match self {
Value::List(items) => items,
_ => &[],
}
}
pub fn names(&self) -> Vec<String> {
let names: Vec<String> = match self {
Value::Text(text) => text.split(',').map(str::to_owned).collect(),
Value::List(items) => items
.iter()
.filter_map(Value::text)
.map(str::to_owned)
.collect(),
_ => Vec::new(),
};
(names.into_iter())
.map(|n| n.trim().to_owned())
.filter(|n| !n.is_empty())
.collect()
}
pub fn is_false(&self) -> bool {
matches!(self.text(), Some("false" | "False" | "FALSE" | "no" | "No"))
}
}
pub fn parse(text: &str) -> Value {
let mut lines: Vec<String> = text
.lines()
.map(|l| l.trim_end_matches('\r').to_owned())
.collect();
if lines.first().is_some_and(|l| l.trim_end() == "---") {
lines.remove(0);
}
if let Some(end) = lines.iter().position(|l| l.trim_end() == "...") {
lines.truncate(end);
}
let mut parser = Parser { lines, at: 0 };
parser.block(0)
}
struct Parser {
lines: Vec<String>,
at: usize,
}
fn indent(line: &str) -> usize {
line.len() - line.trim_start_matches(' ').len()
}
fn empty(line: &str) -> bool {
let t = line.trim();
t.is_empty() || t.starts_with('#')
}
fn uncommented(line: &str) -> &str {
let mut quote = None;
let mut previous = ' ';
for (i, c) in line.char_indices() {
match (quote, c) {
(None, '"' | '\'') if previous == ' ' || previous == '[' || previous == ',' => {
quote = Some(c);
}
(Some(q), c) if c == q => quote = None,
(None, '#') if previous == ' ' || previous == '\t' => {
return line.get(..i).unwrap_or(line).trim_end();
}
_ => {}
}
previous = c;
}
line.trim_end()
}
fn dash(content: &str) -> Option<&str> {
match content.strip_prefix('-')? {
"" => Some(""),
rest if rest.starts_with(' ') => Some(rest),
_ => None,
}
}
fn key_value(content: &str) -> Option<(String, &str)> {
let first = content.chars().next()?;
if first == '"' || first == '\'' {
let close = content.get(1..)?.find(first)? + 1;
let key = content.get(1..close)?;
let rest = content.get(close + 1..)?.trim_start();
let rest = rest.strip_prefix(':')?;
return (rest.is_empty() || rest.starts_with(' ')).then(|| (key.to_owned(), rest.trim()));
}
let mut from = 0;
loop {
let colon = from + content.get(from..)?.find(':')?;
let rest = content.get(colon + 1..)?;
if rest.is_empty() || rest.starts_with(' ') {
let key = content.get(..colon)?.trim();
return (!key.is_empty()).then(|| (key.to_owned(), rest.trim()));
}
from = colon + 1;
}
}
impl Parser {
fn peek(&mut self) -> Option<(usize, String)> {
while let Some(line) = self.lines.get(self.at) {
if empty(line) {
self.at += 1;
continue;
}
return Some((indent(line), uncommented(line).trim_start().to_owned()));
}
None
}
fn block(&mut self, min: usize) -> Value {
let Some((at, content)) = self.peek() else {
return Value::Null;
};
if at < min {
return Value::Null;
}
if dash(&content).is_some() {
self.list(at)
} else if key_value(&content).is_some() {
self.map(at)
} else {
self.at += 1;
scalar(&content)
}
}
fn list(&mut self, at: usize) -> Value {
let mut items = Vec::new();
while let Some((here, content)) = self.peek() {
let Some(rest) = dash(&content).filter(|_| here == at) else {
break;
};
let item = rest.trim_start();
if item.is_empty() {
self.at += 1;
items.push(self.block(at + 1));
} else if key_value(item).is_some() {
let column = at + content.len() - item.len();
if let Some(line) = self.lines.get_mut(self.at) {
*line = format!("{}{item}", " ".repeat(column));
}
items.push(self.map(column));
} else {
self.at += 1;
items.push(self.inline(item, at));
}
}
Value::List(items)
}
fn map(&mut self, at: usize) -> Value {
let mut entries = Vec::new();
while let Some((here, content)) = self.peek() {
if here != at || dash(&content).is_some() {
break;
}
let Some((key, value)) = key_value(&content) else {
break;
};
self.at += 1;
let value = if value.is_empty() {
match self.peek() {
Some((next, c)) if next == at && dash(&c).is_some() => self.list(at),
_ => self.block(at + 1),
}
} else {
self.inline(value, at)
};
entries.push((key, value));
}
Value::Map(entries)
}
fn inline(&mut self, value: &str, at: usize) -> Value {
if let Some(style) = value.strip_prefix(['|', '>']) {
let folded = value.starts_with('>');
return Value::Text(self.block_scalar(at, folded, style));
}
flow(value)
}
fn block_scalar(&mut self, at: usize, folded: bool, style: &str) -> String {
let mut raw = Vec::new();
while let Some(line) = self.lines.get(self.at) {
if !line.trim().is_empty() && indent(line) <= at {
break;
}
raw.push(line.as_str());
self.at += 1;
}
let column = (raw.iter())
.find(|l| !l.trim().is_empty())
.map_or(0, |l| indent(l));
let mut lines: Vec<String> = (raw.iter())
.map(|l| l.get(column.min(l.len())..).unwrap_or("").to_owned())
.collect();
let mut trailing = 0;
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
trailing += 1;
}
let mut text = if folded {
let mut out = String::new();
for line in &lines {
if line.is_empty() {
out.push('\n');
} else {
if !out.is_empty() && !out.ends_with('\n') {
out.push(' ');
}
out.push_str(line);
}
}
out
} else {
lines.join("\n")
};
if style.contains('-') || text.is_empty() {
return text;
}
text.push('\n');
if style.contains('+') {
text.push_str(&"\n".repeat(trailing));
}
text
}
}
fn flow(value: &str) -> Value {
let inner = |open: char, close: char| {
let inner = value.strip_prefix(open)?.strip_suffix(close)?;
Some(split_commas(inner))
};
if let Some(items) = inner('[', ']') {
return Value::List(items.iter().map(|i| scalar(i)).collect());
}
if let Some(items) = inner('{', '}') {
let entries = items.iter().filter_map(|i| {
let (key, value) = key_value(i)?;
Some((key, scalar(value)))
});
return Value::Map(entries.collect());
}
scalar(value)
}
fn split_commas(inner: &str) -> Vec<String> {
let mut items = Vec::new();
let mut item = String::new();
let mut quote = None;
for c in inner.chars() {
match (quote, c) {
(None, '"' | '\'') => quote = Some(c),
(Some(q), c) if c == q => quote = None,
(None, ',') => {
items.push(std::mem::take(&mut item));
continue;
}
_ => {}
}
item.push(c);
}
items.push(item);
(items.into_iter())
.map(|i| i.trim().to_owned())
.filter(|i| !i.is_empty())
.collect()
}
fn scalar(value: &str) -> Value {
let value = value.trim();
if let Some(inner) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
let mut out = String::new();
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some(other) => out.push(other),
None => {}
}
}
return Value::Text(out);
}
if let Some(inner) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
return Value::Text(inner.replace("''", "'"));
}
match value {
"" | "~" | "null" | "Null" | "NULL" => Value::Null,
_ => Value::Text(value.to_owned()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn text(s: &str) -> Value {
Value::Text(s.to_owned())
}
#[test]
fn reads_an_issue_form() {
let form = parse(
r#"name: Bug Report # shown in the chooser
description: "File a bug: it's broken"
title: '[Bug]: '
labels: ["bug", triage]
assignees:
- octocat
body:
- type: markdown
attributes:
value: |
Thanks for taking the time!
Really.
- type: textarea
id: what
attributes:
label: What happened?
placeholder: Tell us # not a comment inside? it is
render: shell
validations:
required: true
- type: dropdown
attributes:
label: Version
options:
- "1.0"
- 2.0
"#,
);
assert_eq!(form.get("name"), Some(&text("Bug Report")));
assert_eq!(
form.get("description"),
Some(&text("File a bug: it's broken"))
);
assert_eq!(form.get("title"), Some(&text("[Bug]: ")));
assert_eq!(
form.get("labels").map(Value::names),
Some(vec!["bug".into(), "triage".into()])
);
assert_eq!(
form.get("assignees").map(Value::names),
Some(vec!["octocat".into()])
);
let body = form.get("body").map(Value::list).unwrap_or_default();
assert_eq!(body.len(), 3);
let value = body[0].get("attributes").and_then(|a| a.get("value"));
assert_eq!(
value,
Some(&text("Thanks for taking the time!\n\nReally.\n"))
);
let attributes = body[1].get("attributes").unwrap();
assert_eq!(attributes.get("label"), Some(&text("What happened?")));
assert_eq!(attributes.get("render"), Some(&text("shell")));
let required = body[1].get("validations").and_then(|v| v.get("required"));
assert_eq!(required, Some(&text("true")));
let options = body[2].get("attributes").and_then(|a| a.get("options"));
assert_eq!(
options.map(Value::names),
Some(vec!["1.0".into(), "2.0".into()])
);
}
#[test]
fn reads_front_matter_and_config() {
let front = parse(
"name: Feature\nabout: Suggest an idea\nlabels: enhancement, help wanted\nassignees: ''\n",
);
assert_eq!(
front.get("labels").map(Value::names),
Some(vec!["enhancement".into(), "help wanted".into()])
);
assert_eq!(front.get("assignees").map(Value::names), Some(Vec::new()));
let config = parse(
"blank_issues_enabled: false\ncontact_links:\n - name: Forum\n url: https://example.com\n about: Ask here\n",
);
assert!(
config
.get("blank_issues_enabled")
.is_some_and(Value::is_false)
);
let links = config
.get("contact_links")
.map(Value::list)
.unwrap_or_default();
assert_eq!(links[0].get("url"), Some(&text("https://example.com")));
assert_eq!(links[0].get("about"), Some(&text("Ask here")));
}
#[test]
fn folds_and_chomps_block_scalars() {
let v = parse("a: >-\n one\n two\n\n three\nb: |+\n kept\n\nc: x\n");
assert_eq!(v.get("a"), Some(&text("one two\nthree")));
assert_eq!(v.get("b"), Some(&text("kept\n\n")));
assert_eq!(v.get("c"), Some(&text("x")));
}
}