use crate::datatypes::values::Value;
use std::collections::BTreeMap;
#[derive(Debug, Default)]
pub(super) struct Tree {
root: BTreeMap<String, Node>,
}
#[derive(Debug)]
enum Node {
Leaf(Value),
Links(Vec<String>),
Branch(BTreeMap<String, Node>),
}
impl Tree {
pub(super) fn insert(&mut self, key: &str, value: Value) {
insert_path(&mut self.root, key, Node::Leaf(value));
}
pub(super) fn insert_wikilinks(&mut self, key: &str, targets: Vec<String>) {
insert_path(&mut self.root, key, Node::Links(targets));
}
fn is_empty(&self) -> bool {
self.root.is_empty()
}
}
fn insert_path(map: &mut BTreeMap<String, Node>, key: &str, node: Node) {
let Some((head, rest)) = key.split_once('.') else {
if matches!(map.get(key), Some(Node::Branch(_))) {
return;
}
map.insert(key.to_string(), node);
return;
};
if head.is_empty() || rest.is_empty() {
map.insert(key.to_string(), node);
return;
}
match map
.entry(head.to_string())
.or_insert_with(|| Node::Branch(BTreeMap::new()))
{
Node::Branch(child) => insert_path(child, rest, node),
_ => {
map.insert(key.to_string(), node);
}
}
}
pub(super) fn render_frontmatter(tree: &Tree) -> String {
if tree.is_empty() {
return String::new();
}
let mut out = String::new();
render_map(&tree.root, 0, &mut out);
out
}
fn render_map(map: &BTreeMap<String, Node>, indent: usize, out: &mut String) {
let pad = " ".repeat(indent);
for (key, node) in map {
let key = render_key(key);
match node {
Node::Leaf(Value::List(items)) => {
if items.is_empty() {
out.push_str(&format!("{pad}{key}: []\n"));
continue;
}
out.push_str(&format!("{pad}{key}:\n"));
for item in items {
out.push_str(&format!("{pad} - {}\n", render_inline(item)));
}
}
Node::Links(targets) => {
out.push_str(&format!("{pad}{key}:\n"));
for target in targets {
out.push_str(&format!("{pad} - {}\n", quote(&format!("[[{target}]]"))));
}
}
Node::Leaf(Value::Map(entries)) => {
let nested: BTreeMap<String, Node> = entries
.iter()
.map(|(k, v)| (k.to_string(), Node::Leaf(v.clone())))
.collect();
if nested.is_empty() {
out.push_str(&format!("{pad}{key}: {{}}\n"));
continue;
}
out.push_str(&format!("{pad}{key}:\n"));
render_map(&nested, indent + 1, out);
}
Node::Leaf(value) => {
out.push_str(&format!("{pad}{key}: {}\n", render_inline(value)));
}
Node::Branch(child) => {
out.push_str(&format!("{pad}{key}:\n"));
render_map(child, indent + 1, out);
}
}
}
}
fn render_key(key: &str) -> String {
if needs_quoting(key) {
quote(key)
} else {
key.to_string()
}
}
fn render_inline(value: &Value) -> String {
match value {
Value::String(s) => {
if needs_quoting(s) {
quote(s)
} else {
s.clone()
}
}
Value::Int64(n) => n.to_string(),
Value::UniqueId(n) => n.to_string(),
Value::Boolean(b) => b.to_string(),
Value::Float64(f) => render_float(*f),
Value::DateTime(d) => d.format("%Y-%m-%d").to_string(),
Value::Timestamp(t) => format!("{}Z", t.format("%Y-%m-%dT%H:%M:%S%.f")),
Value::Point { lat, lon } => format!("POINT({lon} {lat})"),
Value::Null => "null".to_string(),
Value::List(_) | Value::Map(_) => {
serde_json::to_string(&crate::param::kglite_value_to_json(value))
.unwrap_or_else(|_| "null".to_string())
}
other => quote(&crate::datatypes::values::raw_string(other)),
}
}
fn render_float(f: f64) -> String {
if f.is_nan() {
return ".nan".to_string();
}
if f.is_infinite() {
return if f > 0.0 { ".inf" } else { "-.inf" }.to_string();
}
let text = f.to_string();
if text.contains(['.', 'e', 'E']) {
text
} else {
format!("{text}.0")
}
}
fn quote(text: &str) -> String {
serde_json::to_string(text).unwrap_or_else(|_| format!("\"{}\"", text.replace('"', "'")))
}
fn needs_quoting(text: &str) -> bool {
if text.is_empty() || text != text.trim() {
return true;
}
let first = text.chars().next().unwrap_or(' ');
if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
return true;
}
if text.contains(": ") || text.contains(" #") || text.contains('\n') || text.ends_with(':') {
return true;
}
if YAML_KEYWORDS.contains(&text) {
return true;
}
if text.parse::<i64>().is_ok() || text.parse::<f64>().is_ok() {
return true;
}
if text.starts_with("0x") || text.starts_with("0o") {
return true;
}
!matches!(
crate::okf::frontmatter::infer_temporal(Value::String(text.to_string())),
Value::String(_)
)
}
const YAML_KEYWORDS: [&str; 22] = [
"true", "True", "TRUE", "false", "False", "FALSE", "null", "Null", "NULL", "~", "y", "Y", "n",
"N", "yes", "Yes", "YES", "no", "No", "NO", "on", "off",
];
pub(super) fn lower_snake(conn_type: &str) -> String {
let mut out = String::with_capacity(conn_type.len());
let mut pending_sep = false;
for ch in conn_type.chars() {
if ch.is_alphanumeric() {
if pending_sep && !out.is_empty() {
out.push('_');
}
pending_sep = false;
out.extend(ch.to_lowercase());
} else {
pending_sep = true;
}
}
out
}
#[cfg(test)]
#[path = "yaml_out_tests.rs"]
mod yaml_out_tests;