use super::block::{BlockTree, List};
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};
use std::ops::Range;
#[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 range: Range<usize>,
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 forced_splits: usize,
pub section_suffixes: Vec<String>,
pub links_from: Vec<(String, Link)>,
}
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();
for _ in tree.directives.iter().filter(|d| d.key.is_empty()) {
out.warnings.push(
"`<!-- kglite -->` names no key; nothing was recorded (VAULT.md §5.8)".to_string(),
);
}
let sections = profile
.sections
.as_ref()
.map(|rule| derive_sections(body, tree, rule, &mut ids, &mut out));
out.section_suffixes = sections.clone().unwrap_or_default();
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()),
range: heading.range.start..heading.body_range.end,
text: Some(trimmed(body, tree, 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,
) {
warn_nested_chunk_markers(tree, out);
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;
let (packed_chunks, forced) = pack(body, tree, &group.blocks, rule);
out.forced_splits += forced;
for packed in packed_chunks {
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, tree, 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))),
],
range: packed.range.clone(),
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;
}
if tree.directives.iter().any(|d| d.block == index) {
continue;
}
if block.range.is_empty() {
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
}
const CHUNK_MARKER: &str = "chunk";
fn chunk_marker_between(tree: &BlockTree, from: usize, to: usize) -> bool {
tree.directives.iter().any(|d| {
d.key == CHUNK_MARKER
&& tree.blocks[d.block].inside.is_none()
&& d.range.start >= from
&& d.range.end <= to
})
}
fn warn_nested_chunk_markers(tree: &BlockTree, out: &mut Derived) {
for directive in &tree.directives {
if directive.key != CHUNK_MARKER {
continue;
}
let Some(container) = tree.blocks[directive.block].inside else {
continue;
};
out.warnings.push(format!(
"`<!-- kglite chunk -->` inside a {} has no chunk to split (VAULT.md §7.1)",
container_word(&tree.blocks[container].kind)
));
}
}
fn container_word(kind: &super::block::BlockKind) -> &'static str {
use super::block::BlockKind;
match kind {
BlockKind::List(_) => "list",
BlockKind::Table(_) => "table",
BlockKind::BlockQuote(quote) if quote.callout.is_some() => "callout",
BlockKind::BlockQuote(_) => "quotation",
_ => "block",
}
}
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>, usize) {
let mut out: Vec<Packed> = Vec::new();
let mut forced = 0usize;
let mut open: Option<std::ops::Range<usize>> = None;
let mut words = 0usize;
for &index in blocks {
let range = tree.blocks[index].range.clone();
if let Some(current) = open.clone() {
if chunk_marker_between(tree, current.end, range.start) {
out.push(Packed {
range: current,
block_id: None,
});
open = None;
words = 0;
}
}
let text = &body[range.clone()];
let block_words = text.split_whitespace().count();
let id = block_id_of(tree, index);
let oversize = block_words > rule.max_words || range.len() > rule.max_chars;
if id.is_some() || oversize {
if let Some(range) = open.take() {
out.push(Packed {
range,
block_id: None,
});
}
words = 0;
let pieces = if oversize {
split_block(body, tree, index, rule)
} else {
vec![range]
};
forced += pieces.len() - 1;
for (nth, piece) in pieces.into_iter().enumerate() {
out.push(Packed {
range: piece,
block_id: if nth == 0 { id.clone() } else { None },
});
}
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, forced)
}
fn split_block(
body: &str,
tree: &BlockTree,
index: usize,
rule: &ChunkRule,
) -> Vec<std::ops::Range<usize>> {
let range = tree.blocks[index].range.clone();
let atoms = match &tree.blocks[index].kind {
super::block::BlockKind::List(list) => item_atoms(list, &range),
_ => line_atoms(body, &range),
};
let pieces = pack_atoms(body, atoms, rule);
merge_blank(body, pieces)
}
fn item_atoms(list: &List, range: &std::ops::Range<usize>) -> Vec<std::ops::Range<usize>> {
let mut bounds = vec![range.start];
for item in &list.items {
if item.range.start > *bounds.last().expect("seeded") && item.range.start < range.end {
bounds.push(item.range.start);
}
}
bounds.push(range.end);
bounds.windows(2).map(|w| w[0]..w[1]).collect()
}
fn line_atoms(body: &str, range: &std::ops::Range<usize>) -> Vec<std::ops::Range<usize>> {
let mut out = Vec::new();
let mut start = range.start;
for (offset, byte) in body.as_bytes()[range.clone()].iter().enumerate() {
if *byte == b'\n' {
let end = range.start + offset + 1;
out.push(start..end);
start = end;
}
}
if start < range.end || out.is_empty() {
out.push(start..range.end);
}
out
}
fn pack_atoms(
body: &str,
atoms: Vec<std::ops::Range<usize>>,
rule: &ChunkRule,
) -> Vec<std::ops::Range<usize>> {
let mut out: Vec<std::ops::Range<usize>> = Vec::new();
let mut open: Option<std::ops::Range<usize>> = None;
let mut words = 0usize;
for atom in atoms {
if atom.len() > rule.max_chars {
if let Some(current) = open.take() {
out.push(current);
words = 0;
}
let lines = line_atoms(body, &atom);
if lines.len() > 1 {
out.extend(pack_atoms(body, lines, rule));
} else {
out.extend(hard_split(body, atom, rule.max_chars));
}
continue;
}
let atom_words = body[atom.clone()].split_whitespace().count();
match open.take() {
Some(current)
if words + atom_words <= rule.max_words
&& atom.end - current.start <= rule.max_chars =>
{
open = Some(current.start..atom.end);
words += atom_words;
}
Some(current) => {
out.push(current);
open = Some(atom);
words = atom_words;
}
None => {
open = Some(atom);
words = atom_words;
}
}
}
if let Some(current) = open {
out.push(current);
}
out
}
fn hard_split(
body: &str,
atom: std::ops::Range<usize>,
max_chars: usize,
) -> Vec<std::ops::Range<usize>> {
let max = max_chars.max(1);
let mut out = Vec::new();
let mut start = atom.start;
while atom.end - start > max {
let mut cut = start + max;
while cut > start && !body.is_char_boundary(cut) {
cut -= 1;
}
if cut == start {
cut = start + 1;
while cut < atom.end && !body.is_char_boundary(cut) {
cut += 1;
}
}
out.push(start..cut);
start = cut;
}
out.push(start..atom.end);
out
}
fn merge_blank(body: &str, pieces: Vec<std::ops::Range<usize>>) -> Vec<std::ops::Range<usize>> {
let mut out: Vec<std::ops::Range<usize>> = Vec::new();
for piece in pieces {
match out.last_mut() {
Some(previous) if body[piece.clone()].trim().is_empty() => previous.end = piece.end,
_ => out.push(piece),
}
}
if out.len() > 1 && body[out[0].clone()].trim().is_empty() {
let head = out.remove(0);
out[0].start = head.start;
}
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, tree: &BlockTree, range: std::ops::Range<usize>) -> String {
let cuts: Vec<&std::ops::Range<usize>> = tree
.directives
.iter()
.map(|directive| &directive.range)
.filter(|cut| cut.start >= range.start && cut.end <= range.end)
.collect();
if cuts.is_empty() {
return body[range].trim_end().to_string();
}
let mut out = String::with_capacity(range.len());
let mut at = range.start;
for cut in cuts {
out.push_str(&body[at..cut.start]);
at = cut.end;
}
out.push_str(&body[at..range.end]);
out.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;