use crate::datatypes::values::Value;
use crate::datatypes::PropMap;
use crate::okf::vault_config::kind_of;
use regex::Regex;
use std::sync::OnceLock;
const STRUCTURE_KEYS: [&str; 9] = [
"sections",
"chunks",
"callouts",
"code_fences",
"ordered_lists",
"tables",
"key_from_heading",
"inherit",
"embed_text",
];
const DERIVED_PROPERTIES: [&str; 16] = [
"title",
"text",
"tags",
"level",
"ordinal",
"path",
"note_id",
"section_id",
"kind",
"fold",
"lang",
"code",
"caption",
"chunk_hash",
"step_count",
"signature",
];
const RESERVED_FRONTMATTER: [&str; 7] = [
"id", "type", "title", "aliases", "tags", "kg_skip", "parent",
];
pub(crate) fn is_reserved_property(key: &str) -> bool {
DERIVED_PROPERTIES.contains(&key)
|| RESERVED_FRONTMATTER.contains(&key)
|| matches!(key, "concept_id" | "file_path")
}
const PLACEHOLDERS: [&str; 5] = ["title", "section_title", "heading_path", "text", "id"];
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct StructureProfile {
pub sections: Option<SectionRule>,
pub chunks: Option<ChunkRule>,
pub callouts: Option<CalloutRule>,
pub code_fences: Option<FenceRule>,
pub ordered_lists: Option<OrderedListRule>,
pub tables: Vec<TableRule>,
pub key_from_heading: Option<KeyFromHeadingRule>,
pub inherit: Vec<String>,
pub embed_text: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SectionRule {
pub label: String,
pub edge: String,
pub parent: String,
pub next: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChunkRule {
pub label: String,
pub edge: String,
pub next: String,
pub max_words: usize,
pub max_chars: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CalloutRule {
pub label: String,
pub edge: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FenceRule {
pub label: String,
pub edge: String,
pub langs: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OrderedListRule {
pub label: String,
pub container: String,
pub edge: String,
pub next: String,
pub under_heading: Option<HeadingMatcher>,
pub min_items: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TableRule {
pub under_heading: HeadingMatcher,
pub label: Option<String>,
pub key_column: Option<String>,
pub edge: String,
pub edges: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct KeyFromHeadingRule {
pub label: String,
pub when_matches: HeadingMatcher,
pub property: String,
pub under_label: String,
}
const DEFAULT_SYMBOL_PATTERN: &str = r"^[\w.]+\.[\w]+(\(.*\))?(\s*→.*)?$";
#[derive(Debug, Clone)]
pub(crate) struct HeadingMatcher(Regex);
impl HeadingMatcher {
pub fn is_match(&self, title: &str) -> bool {
self.0.is_match(title)
}
}
impl PartialEq for HeadingMatcher {
fn eq(&self, other: &Self) -> bool {
self.0.as_str() == other.0.as_str()
}
}
impl Eq for HeadingMatcher {}
impl StructureProfile {
pub fn derives_anything(&self) -> bool {
self.sections.is_some()
|| self.chunks.is_some()
|| self.callouts.is_some()
|| self.code_fences.is_some()
|| self.ordered_lists.is_some()
|| !self.tables.is_empty()
|| self.key_from_heading.is_some()
}
}
pub(crate) fn parse(v: &Value) -> Result<StructureProfile, String> {
let map = match v {
Value::Map(map) => map,
Value::Null => return Ok(StructureProfile::default()),
other => {
return Err(format!(
"`structure` must be a mapping, not {}",
kind_of(other)
))
}
};
for (key, _) in map.iter() {
if !STRUCTURE_KEYS.contains(&key) {
return Err(format!(
"unknown key `structure.{key}`; this build accepts {}",
STRUCTURE_KEYS.join(", ")
));
}
}
let mut out = StructureProfile::default();
if let Some(v) = map.get("sections") {
out.sections = Some(section_rule(v)?);
}
if let Some(v) = map.get("chunks") {
out.chunks = Some(chunk_rule(v)?);
}
if let Some(v) = map.get("callouts") {
out.callouts = Some(callout_rule(v)?);
}
if let Some(v) = map.get("code_fences") {
out.code_fences = Some(fence_rule(v)?);
}
if let Some(v) = map.get("ordered_lists") {
out.ordered_lists = Some(ordered_list_rule(v)?);
}
if let Some(v) = map.get("tables") {
out.tables = table_rules(v)?;
}
if let Some(v) = map.get("key_from_heading") {
out.key_from_heading = Some(key_from_heading_rule(v)?);
}
if let Some(v) = map.get("inherit") {
out.inherit = inherit_list(v)?;
}
if let Some(v) = map.get("embed_text") {
let template = match v {
Value::String(s) => s.clone(),
other => {
return Err(format!(
"`structure.embed_text` must be a string, not {}",
kind_of(other)
))
}
};
check_placeholders(&template)?;
out.embed_text = Some(template);
}
Ok(out)
}
fn section_rule(v: &Value) -> Result<SectionRule, String> {
let spec = rule_map(v, "structure.sections")?;
check_keys(
&spec,
"structure.sections",
&["label", "edge", "parent", "next"],
)?;
Ok(SectionRule {
label: field(&spec, "structure.sections", "label", "Section")?,
edge: field(&spec, "structure.sections", "edge", "HAS_SECTION")?,
parent: field(&spec, "structure.sections", "parent", "PARENT_SECTION")?,
next: field(&spec, "structure.sections", "next", "NEXT_SECTION")?,
})
}
fn chunk_rule(v: &Value) -> Result<ChunkRule, String> {
let spec = rule_map(v, "structure.chunks")?;
check_keys(
&spec,
"structure.chunks",
&["label", "edge", "next", "max_words", "max_chars"],
)?;
Ok(ChunkRule {
label: field(&spec, "structure.chunks", "label", "Chunk")?,
edge: field(&spec, "structure.chunks", "edge", "HAS_CHUNK")?,
next: field(&spec, "structure.chunks", "next", "NEXT_CHUNK")?,
max_words: limit(&spec, "structure.chunks", "max_words", 650)?,
max_chars: limit(&spec, "structure.chunks", "max_chars", 6000)?,
})
}
fn callout_rule(v: &Value) -> Result<CalloutRule, String> {
let spec = rule_map(v, "structure.callouts")?;
check_keys(&spec, "structure.callouts", &["label", "edge"])?;
Ok(CalloutRule {
label: field(&spec, "structure.callouts", "label", "Note")?,
edge: field(&spec, "structure.callouts", "edge", "HAS_NOTE")?,
})
}
fn fence_rule(v: &Value) -> Result<FenceRule, String> {
let spec = rule_map(v, "structure.code_fences")?;
check_keys(&spec, "structure.code_fences", &["label", "edge", "langs"])?;
Ok(FenceRule {
label: field(&spec, "structure.code_fences", "label", "Example")?,
edge: field(&spec, "structure.code_fences", "edge", "HAS_EXAMPLE")?,
langs: match spec.get("langs") {
None | Some(Value::Null) => None,
Some(v) => Some(lang_list(v)?),
},
})
}
fn ordered_list_rule(v: &Value) -> Result<OrderedListRule, String> {
const CTX: &str = "structure.ordered_lists";
let spec = rule_map(v, CTX)?;
check_keys(
&spec,
CTX,
&[
"label",
"container",
"edge",
"next",
"under_heading",
"min_items",
],
)?;
Ok(OrderedListRule {
label: field(&spec, CTX, "label", "ProcedureStep")?,
container: field(&spec, CTX, "container", "Procedure")?,
edge: field(&spec, CTX, "edge", "HAS_STEP")?,
next: field(&spec, CTX, "next", "NEXT_STEP")?,
under_heading: heading_matcher(
spec.get("under_heading"),
"structure.ordered_lists.under_heading",
)?,
min_items: limit(&spec, CTX, "min_items", 2)?,
})
}
fn heading_matcher(v: Option<&Value>, ctx: &str) -> Result<Option<HeadingMatcher>, String> {
match v {
None | Some(Value::Null) => Ok(None),
Some(Value::String(pattern)) => compile(pattern, ctx).map(Some),
Some(other) => Err(format!("`{ctx}` must be a string, not {}", kind_of(other))),
}
}
fn compile(pattern: &str, ctx: &str) -> Result<HeadingMatcher, String> {
Regex::new(pattern)
.map(HeadingMatcher)
.map_err(|e| format!("`{ctx}` is not a regular expression: {e}"))
}
fn table_rules(v: &Value) -> Result<Vec<TableRule>, String> {
const CTX: &str = "structure.tables";
let Value::List(items) = v else {
return Err(format!(
"`{CTX}` must be a list of rules, not {}",
kind_of(v)
));
};
items.iter().map(table_rule).collect()
}
fn table_rule(v: &Value) -> Result<TableRule, String> {
const CTX: &str = "structure.tables";
let Value::Map(spec) = v else {
return Err(format!(
"`{CTX}` must be a list of rules, not a list of {}",
kind_of(v)
));
};
check_keys(
spec,
CTX,
&["under_heading", "label", "key_column", "edge", "edges"],
)?;
let edges = match spec.get("edges") {
None | Some(Value::Null) => false,
Some(Value::Boolean(b)) => *b,
Some(other) => {
return Err(format!(
"`{CTX}.edges` must be true or false, not {}",
kind_of(other)
))
}
};
let under_heading =
match heading_matcher(spec.get("under_heading"), "structure.tables.under_heading")? {
Some(matcher) => matcher,
None => {
return Err(format!(
"`{CTX}` needs an `under_heading:` naming the heading its tables sit under"
))
}
};
let label = match spec.get("label") {
None | Some(Value::Null) => None,
Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
Some(Value::String(_)) => return Err(format!("`{CTX}.label` must not be empty")),
Some(other) => {
return Err(format!(
"`{CTX}.label` must be a string, not {}",
kind_of(other)
))
}
};
if edges && label.is_some() {
return Err(format!(
"`{CTX}.label` cannot be set with `edges: true`: a row of an edge table \
states an edge, not a node"
));
}
let edge = match (&label, spec.get("edge")) {
(_, Some(Value::String(s))) if !s.is_empty() => s.clone(),
(_, Some(Value::String(_))) => return Err(format!("`{CTX}.edge` must not be empty")),
(_, Some(other)) if !matches!(other, Value::Null) => {
return Err(format!(
"`{CTX}.edge` must be a string, not {}",
kind_of(other)
))
}
(Some(label), _) => format!("HAS_{}", crate::okf::links::upper_snake(label)),
(None, _) => {
return Err(format!(
"`{CTX}` needs a `label:` for the row nodes, or `edges: true` and an `edge:`"
))
}
};
Ok(TableRule {
under_heading,
label,
key_column: optional_field(spec, CTX, "key_column")?,
edge,
edges,
})
}
fn key_from_heading_rule(v: &Value) -> Result<KeyFromHeadingRule, String> {
const CTX: &str = "structure.key_from_heading";
let spec = rule_map(v, CTX)?;
check_keys(
&spec,
CTX,
&["label", "when_matches", "property", "under_label"],
)?;
let under_label = optional_field(&spec, CTX, "under_label")?.ok_or_else(|| {
format!(
"`{CTX}` needs an `under_label:`: the shape of a symbol name is cheap to match \
by accident, so the rule is restricted to the notes that hold symbols"
)
})?;
let when_matches = match spec.get("when_matches") {
None | Some(Value::Null) => compile(
DEFAULT_SYMBOL_PATTERN,
"structure.key_from_heading.when_matches",
)?,
Some(_) => heading_matcher(
spec.get("when_matches"),
"structure.key_from_heading.when_matches",
)?
.expect("a non-null value compiles or fails"),
};
Ok(KeyFromHeadingRule {
label: field(&spec, CTX, "label", "ApiSymbol")?,
when_matches,
property: field(&spec, CTX, "property", "qualified_name")?,
under_label,
})
}
fn optional_field(map: &PropMap, ctx: &str, key: &str) -> Result<Option<String>, String> {
match map.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) if !s.is_empty() => Ok(Some(s.clone())),
Some(Value::String(_)) => Err(format!("`{ctx}.{key}` must not be empty")),
Some(other) => Err(format!(
"`{ctx}.{key}` must be a string, not {}",
kind_of(other)
)),
}
}
fn rule_map<'a>(v: &'a Value, ctx: &str) -> Result<std::borrow::Cow<'a, PropMap>, String> {
match v {
Value::Map(map) => Ok(std::borrow::Cow::Borrowed(map)),
Value::Null => Ok(std::borrow::Cow::Owned(PropMap::new())),
other => Err(format!("`{ctx}` must be a mapping, not {}", kind_of(other))),
}
}
fn check_keys(map: &PropMap, ctx: &str, allowed: &[&str]) -> Result<(), String> {
for (key, _) in map.iter() {
if !allowed.contains(&key) {
return Err(format!(
"unknown key `{ctx}.{key}`; accepts {}",
allowed.join(", ")
));
}
}
Ok(())
}
fn field(map: &PropMap, ctx: &str, key: &str, default: &str) -> Result<String, String> {
match map.get(key) {
None | Some(Value::Null) => Ok(default.to_string()),
Some(Value::String(s)) if !s.is_empty() => Ok(s.clone()),
Some(Value::String(_)) => Err(format!("`{ctx}.{key}` must not be empty")),
Some(other) => Err(format!(
"`{ctx}.{key}` must be a string, not {}",
kind_of(other)
)),
}
}
fn limit(map: &PropMap, ctx: &str, key: &str, default: usize) -> Result<usize, String> {
match map.get(key) {
None | Some(Value::Null) => Ok(default),
Some(Value::Int64(n)) if *n > 0 => Ok(*n as usize),
Some(Value::Int64(n)) => Err(format!(
"`{ctx}.{key}: {n}` must be a positive integer; a limit of zero would \
make every block its own chunk"
)),
Some(other) => Err(format!(
"`{ctx}.{key}` must be an integer, not {}",
kind_of(other)
)),
}
}
fn inherit_list(v: &Value) -> Result<Vec<String>, String> {
let Value::List(items) = v else {
return Err(format!(
"`structure.inherit` must be a list of strings, not {}",
kind_of(v)
));
};
let mut out = Vec::with_capacity(items.len());
for item in items.iter() {
let Value::String(name) = item else {
return Err(format!(
"`structure.inherit` must be a list of strings, not {}",
kind_of(item)
));
};
if DERIVED_PROPERTIES.contains(&name.as_str()) {
return Err(format!(
"`structure.inherit: [{name}]` names a property a derived node defines \
itself; inheriting it would overwrite the structure the note was read from"
));
}
if RESERVED_FRONTMATTER.contains(&name.as_str()) {
return Err(format!(
"`structure.inherit: [{name}]` names a reserved frontmatter key (VAULT.md §4.1)"
));
}
out.push(name.clone());
}
Ok(out)
}
fn lang_list(v: &Value) -> Result<Vec<String>, String> {
let Value::List(items) = v else {
return Err(format!(
"`structure.code_fences.langs` must be a list of strings, not {}",
kind_of(v)
));
};
items
.iter()
.map(|item| match item {
Value::String(s) => Ok(s.to_lowercase()),
other => Err(format!(
"`structure.code_fences.langs` must be a list of strings, not {}",
kind_of(other)
)),
})
.collect()
}
fn placeholder_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap())
}
fn check_placeholders(template: &str) -> Result<(), String> {
for caps in placeholder_re().captures_iter(template) {
let name = caps.get(1).map(|m| m.as_str()).unwrap_or_default();
if !PLACEHOLDERS.contains(&name) {
return Err(format!(
"`structure.embed_text` uses `{{{name}}}`, which is not a placeholder \
this format names; use {}",
PLACEHOLDERS
.iter()
.map(|p| format!("{{{p}}}"))
.collect::<Vec<_>>()
.join(", ")
));
}
}
Ok(())
}
pub(crate) fn render_embed_text(
template: &str,
note_title: &str,
section_title: &str,
heading_path: &[String],
text: &str,
id: &str,
) -> String {
placeholder_re()
.replace_all(template, |caps: ®ex::Captures| {
match caps.get(1).map(|m| m.as_str()).unwrap_or_default() {
"title" => note_title.to_string(),
"section_title" => section_title.to_string(),
"heading_path" => heading_path.join(" > "),
"text" => text.to_string(),
"id" => id.to_string(),
_ => caps[0].to_string(),
}
})
.into_owned()
}
#[cfg(test)]
#[path = "profile_tests.rs"]
mod profile_tests;