use super::block::BlockTree;
use super::constructs::{derive_callouts, derive_fences, derive_lists, Ctx};
use super::profile::{ChunkRule, KeyFromHeadingRule, SectionRule, StructureProfile};
use super::tables::derive_tables;
use crate::datatypes::values::Value;
use crate::okf::model::Link;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct DerivedNode {
pub suffix: String,
pub label: String,
pub section: Option<String>,
pub heading_path: Vec<String>,
pub section_title: Option<String>,
pub text: Option<String>,
pub props: Vec<(String, Value)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DerivedEdge {
pub conn_type: String,
pub source: Option<String>,
pub target: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub(crate) struct Derived {
pub nodes: Vec<DerivedNode>,
pub edges: Vec<DerivedEdge>,
pub links: Vec<Link>,
pub edge_tables_hit: BTreeSet<String>,
pub warnings: Vec<String>,
}
pub(crate) fn derive(
body: &str,
tree: &BlockTree,
note_title: &str,
note_label: &str,
profile: &StructureProfile,
) -> Derived {
let mut out = Derived::default();
let mut ids = IdSpace::default();
let sections = profile
.sections
.as_ref()
.map(|rule| derive_sections(body, tree, rule, &mut ids, &mut out));
if let Some(rule) = &profile.chunks {
derive_chunks(body, tree, rule, sections.as_deref(), &mut ids, &mut out);
}
let ctx = Ctx {
body,
tree,
sections: sections.as_deref(),
note_title,
};
if let Some(rule) = &profile.callouts {
derive_callouts(&ctx, rule, &mut ids, &mut out);
}
if let Some(rule) = &profile.code_fences {
derive_fences(&ctx, rule, &mut ids, &mut out);
}
if let Some(rule) = &profile.ordered_lists {
derive_lists(&ctx, rule, &mut ids, &mut out);
}
if !profile.tables.is_empty() {
derive_tables(&ctx, &profile.tables, &mut ids, &mut out);
}
if let Some(rule) = &profile.key_from_heading {
let section_label = profile.sections.as_ref().map(|r| r.label.as_str());
relabel_symbols(&mut out, rule, note_label, section_label);
}
out
}
fn relabel_symbols(
out: &mut Derived,
rule: &KeyFromHeadingRule,
note_label: &str,
section_label: Option<&str>,
) {
if note_label != rule.under_label {
return;
}
let Some(section_label) = section_label else {
return;
};
for node in &mut out.nodes {
if node.label != section_label {
continue;
}
let Some(Value::String(title)) = node
.props
.iter()
.find(|(k, _)| k == "title")
.map(|(_, v)| v)
.cloned()
else {
continue;
};
if !(title.contains('.') || title.contains('(')) || !rule.when_matches.is_match(&title) {
continue;
}
node.label = rule.label.clone();
let (name, signature) = split_signature(&title);
node.props
.push((rule.property.clone(), Value::String(name.to_string())));
if !signature.is_empty() {
node.props.push((
"signature".to_string(),
Value::String(signature.to_string()),
));
}
}
}
fn split_signature(title: &str) -> (&str, &str) {
let cut = [title.find('('), title.find('→')]
.into_iter()
.flatten()
.min()
.unwrap_or(title.len());
(title[..cut].trim_end(), title[cut..].trim())
}
#[derive(Default)]
pub(super) struct IdSpace(BTreeMap<String, usize>);
impl IdSpace {
pub(super) fn claim(&mut self, wanted: &str) -> (String, bool) {
let count = self.0.entry(wanted.to_string()).or_insert(0);
*count += 1;
match *count {
1 => (wanted.to_string(), false),
n => (format!("{wanted}~{n}"), true),
}
}
}
fn derive_sections(
body: &str,
tree: &BlockTree,
rule: &SectionRule,
ids: &mut IdSpace,
out: &mut Derived,
) -> Vec<String> {
let mut suffixes: Vec<String> = Vec::with_capacity(tree.headings.len());
let mut siblings: BTreeMap<Option<usize>, (usize, String)> = BTreeMap::new();
for (index, heading) in tree.headings.iter().enumerate() {
let parent = parent_of(tree, index);
let (suffix, duplicate) = ids.claim(&format!("#{}", heading.path.join("#")));
if duplicate {
out.warnings.push(format!(
"duplicate heading path `{}`: a link cannot reach the second one, which \
takes the id `{suffix}` — give it a `^block-id` (VAULT.md §5.7)",
heading.path.join("#")
));
}
let entry = siblings.entry(parent).or_insert((0, String::new()));
let ordinal = entry.0;
let previous = (ordinal > 0).then(|| entry.1.clone());
*entry = (ordinal + 1, suffix.clone());
let parent_suffix = parent.map(|p| suffixes[p].clone());
out.edges.push(DerivedEdge {
conn_type: rule.edge.clone(),
source: parent_suffix.clone(),
target: suffix.clone(),
});
if let Some(parent_suffix) = &parent_suffix {
out.edges.push(DerivedEdge {
conn_type: rule.parent.clone(),
source: Some(suffix.clone()),
target: parent_suffix.clone(),
});
}
if let Some(previous) = previous {
out.edges.push(DerivedEdge {
conn_type: rule.next.clone(),
source: Some(previous),
target: suffix.clone(),
});
}
out.nodes.push(DerivedNode {
suffix: suffix.clone(),
label: rule.label.clone(),
section: parent_suffix,
heading_path: heading.path.clone(),
section_title: Some(heading.text.clone()),
text: Some(trimmed(body, heading.body_range.clone())),
props: vec![
("title".to_string(), Value::String(heading.text.clone())),
("level".to_string(), Value::Int64(heading.level as i64)),
("ordinal".to_string(), Value::Int64(ordinal as i64)),
(
"path".to_string(),
Value::List(
heading
.path
.iter()
.map(|p| Value::String(p.clone()))
.collect(),
),
),
],
});
suffixes.push(suffix);
}
suffixes
}
fn parent_of(tree: &BlockTree, index: usize) -> Option<usize> {
let level = tree.headings[index].level;
tree.headings[..index].iter().rposition(|h| h.level < level)
}
fn derive_chunks(
body: &str,
tree: &BlockTree,
rule: &ChunkRule,
sections: Option<&[String]>,
ids: &mut IdSpace,
out: &mut Derived,
) {
let mut counters: BTreeMap<Option<String>, usize> = BTreeMap::new();
for group in chunkable_groups(tree) {
let container = sections.and_then(|s| group.heading.map(|h| s[h].clone()));
let heading_path = group
.heading
.map(|h| tree.headings[h].path.clone())
.unwrap_or_default();
let section_title = group.heading.map(|h| tree.headings[h].text.clone());
let mut previous: Option<String> = None;
for packed in pack(body, tree, &group.blocks, rule) {
let counter = counters.entry(container.clone()).or_insert(0);
let ordinal = *counter;
*counter += 1;
let wanted = match &packed.block_id {
Some(id) => format!("#^{id}"),
None => format!(
"{}~chunk{}",
container.clone().unwrap_or_default(),
ordinal + 1
),
};
let (suffix, duplicate) = ids.claim(&wanted);
if duplicate {
out.warnings.push(format!(
"duplicate derived id `{wanted}`: the second one takes `{suffix}`"
));
}
let text = trimmed(body, packed.range.clone());
out.edges.push(DerivedEdge {
conn_type: rule.edge.clone(),
source: container.clone(),
target: suffix.clone(),
});
if let Some(previous) = previous.replace(suffix.clone()) {
out.edges.push(DerivedEdge {
conn_type: rule.next.clone(),
source: Some(previous),
target: suffix.clone(),
});
}
out.nodes.push(DerivedNode {
suffix,
label: rule.label.clone(),
section: container.clone(),
heading_path: heading_path.clone(),
section_title: section_title.clone(),
props: vec![
("ordinal".to_string(), Value::Int64(ordinal as i64)),
("chunk_hash".to_string(), Value::String(text_hash(&text))),
],
text: Some(text),
});
}
}
}
struct Group {
heading: Option<usize>,
blocks: Vec<usize>,
}
fn chunkable_groups(tree: &BlockTree) -> Vec<Group> {
let mut groups: Vec<Group> = Vec::new();
for (index, block) in tree.blocks.iter().enumerate() {
if block.inside.is_some() {
continue;
}
if is_own_line_block_id(tree, index) {
continue;
}
match groups.last_mut() {
Some(last) if last.heading == block.heading => last.blocks.push(index),
_ => groups.push(Group {
heading: block.heading,
blocks: vec![index],
}),
}
}
groups
}
fn is_own_line_block_id(tree: &BlockTree, block: usize) -> bool {
tree.block_ids.iter().any(|id| {
id.own_line
&& id.range.start >= tree.blocks[block].range.start
&& id.range.end <= tree.blocks[block].range.end
})
}
struct Packed {
range: std::ops::Range<usize>,
block_id: Option<String>,
}
fn pack(body: &str, tree: &BlockTree, blocks: &[usize], rule: &ChunkRule) -> Vec<Packed> {
let mut out: Vec<Packed> = Vec::new();
let mut open: Option<std::ops::Range<usize>> = None;
let mut words = 0usize;
for &index in blocks {
let range = tree.blocks[index].range.clone();
let text = &body[range.clone()];
let block_words = text.split_whitespace().count();
if let Some(id) = block_id_of(tree, index) {
if let Some(range) = open.take() {
out.push(Packed {
range,
block_id: None,
});
}
out.push(Packed {
range,
block_id: Some(id),
});
words = 0;
continue;
}
match open.take() {
Some(current)
if words + block_words <= rule.max_words
&& range.end - current.start <= rule.max_chars =>
{
open = Some(current.start..range.end);
words += block_words;
}
Some(current) => {
out.push(Packed {
range: current,
block_id: None,
});
open = Some(range);
words = block_words;
}
None => {
open = Some(range);
words = block_words;
}
}
}
if let Some(range) = open {
out.push(Packed {
range,
block_id: None,
});
}
out
}
fn block_id_of(tree: &BlockTree, block: usize) -> Option<String> {
tree.block_ids
.iter()
.find(|id| id.attaches_to == Some(block))
.map(|id| id.id.clone())
}
fn trimmed(body: &str, range: std::ops::Range<usize>) -> String {
body[range].trim_end().to_string()
}
fn text_hash(text: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[cfg(test)]
#[path = "derive_tests.rs"]
mod derive_tests;