use super::block::{BlockKind, Cell};
use super::constructs::{bump, claim, Ctx, Place};
use super::derive::{Derived, DerivedEdge, DerivedNode, IdSpace};
use super::profile::TableRule;
use crate::datatypes::values::Value;
use crate::okf::links::{self, WikiRef};
use crate::okf::model::Link;
use std::collections::{BTreeMap, BTreeSet};
pub(super) fn derive_tables(
ctx: &Ctx<'_>,
rules: &[TableRule],
ids: &mut IdSpace,
out: &mut Derived,
) {
let mut counters: BTreeMap<String, usize> = BTreeMap::new();
for block in &ctx.tree.blocks {
let BlockKind::Table(table) = &block.kind else {
continue;
};
let place = ctx.place(block);
let Some(title) = place.section_title.as_deref() else {
continue;
};
let Some(rule) = rules.iter().find(|r| r.under_heading.is_match(title)) else {
continue;
};
let columns = Columns::read(&table.header);
if rule.edges {
edge_rows(rule, &place, &columns, &table.rows, out);
} else {
node_rows(rule, &place, &columns, &table.rows, ids, &mut counters, out);
}
}
}
struct Columns {
names: Vec<Option<String>>,
}
impl Columns {
fn read(header: &[Cell]) -> Self {
Columns {
names: header
.iter()
.map(|cell| {
let name = unescape_pipes(&cell.text);
(!name.is_empty()).then_some(name)
})
.collect(),
}
}
fn index_of(&self, name: &str) -> Option<usize> {
self.names
.iter()
.position(|column| column.as_deref() == Some(name))
}
fn name(&self, index: usize) -> Option<&str> {
self.names.get(index).and_then(|n| n.as_deref())
}
fn pairs<'a>(
&'a self,
row: &'a [Cell],
except: Option<usize>,
) -> impl Iterator<Item = (&'a str, String)> {
row.iter().enumerate().filter_map(move |(index, cell)| {
if except == Some(index) {
return None;
}
let name = self.name(index)?;
let value = unescape_pipes(&cell.text);
(!value.is_empty()).then_some((name, value))
})
}
}
fn node_rows(
rule: &TableRule,
place: &Place,
columns: &Columns,
rows: &[Vec<Cell>],
ids: &mut IdSpace,
counters: &mut BTreeMap<String, usize>,
out: &mut Derived,
) {
let label = rule.label.clone().expect("a node rule declares its label");
let key = match &rule.key_column {
Some(name) => match columns.index_of(name) {
Some(index) => index,
None => {
out.warnings.push(format!(
"table under `{}` has no column `{name}`; its rows key on the first column",
place.section_title.as_deref().unwrap_or_default()
));
0
}
},
None => 0,
};
let prefix = place.section.clone().unwrap_or_default();
let mut used: BTreeSet<String> = BTreeSet::new();
for (index, row) in rows.iter().enumerate() {
let number = bump(counters, &prefix);
let value = row.get(key).map(|c| unescape_pipes(&c.text));
let wanted = match value.filter(|v| !v.is_empty()) {
Some(value) if used.insert(value.clone()) => format!("{prefix}~{value}"),
other => {
out.warnings.push(match other {
Some(value) => format!(
"table under `{}` repeats the key `{value}`; row {} keys on its \
position instead (`~row{number}`)",
place.section_title.as_deref().unwrap_or_default(),
index + 1
),
None => format!(
"table under `{}` has an empty key in row {}; it keys on its \
position instead (`~row{number}`)",
place.section_title.as_deref().unwrap_or_default(),
index + 1
),
});
format!("{prefix}~row{number}")
}
};
let suffix = claim(ids, wanted, out);
out.edges.push(DerivedEdge {
conn_type: rule.edge.clone(),
source: place.section.clone(),
target: suffix.clone(),
});
let props: Vec<(String, Value)> = columns
.pairs(row, None)
.map(|(name, value)| (name.to_string(), Value::String(value)))
.collect();
out.nodes.push(DerivedNode {
suffix,
label: label.clone(),
section: place.section.clone(),
heading_path: place.heading_path.clone(),
section_title: place.section_title.clone(),
range: row_range(row),
text: None,
props,
});
}
}
fn row_range(row: &[Cell]) -> std::ops::Range<usize> {
match (row.first(), row.last()) {
(Some(first), Some(last)) => first.range.start..last.range.end.max(first.range.start),
_ => 0..0,
}
}
fn edge_rows(
rule: &TableRule,
place: &Place,
columns: &Columns,
rows: &[Vec<Cell>],
out: &mut Derived,
) {
let target = match &rule.key_column {
Some(name) => columns.index_of(name),
None => target_column(rows),
};
let Some(target) = target else {
out.warnings.push(format!(
"edge table under `{}` names no target column: declare `key_column:`, or \
write the targets as `[[wikilinks]]`",
place.section_title.as_deref().unwrap_or_default()
));
return;
};
for (index, row) in rows.iter().enumerate() {
let Some(cell) = row.get(target) else {
continue;
};
let raw = unescape_pipes(&cell.text);
let link = links::first_wikilink(&cell.text);
let (name, anchor, label) = match &link {
Some(WikiRef {
name,
anchor,
alias,
}) => (name.to_string(), *anchor, *alias),
None => (raw.clone(), None, None),
};
if name.is_empty() {
continue;
}
let mut props: Vec<(String, Value)> = Vec::new();
if let Some(section) = &place.section_title {
props.push(("section".to_string(), Value::String(section.clone())));
}
if let Some(anchor) = anchor.filter(|a| !a.is_empty()) {
props.push(("anchor".to_string(), Value::String(anchor.to_string())));
}
if let Some(label) = label {
props.push(("label".to_string(), Value::String(label.to_string())));
}
props.push(("row".to_string(), Value::Int64(index as i64 + 1)));
for (name, value) in columns.pairs(row, Some(target)) {
if props.iter().any(|(k, _)| k == name) {
out.warnings.push(format!(
"edge table under `{}` has a column `{name}`, which is the edge \
property the link itself carries; the column is dropped",
place.section_title.as_deref().unwrap_or_default()
));
continue;
}
props.push((name.to_string(), Value::String(value)));
}
out.links.push(Link {
target: name.trim_end_matches(".md").to_string(),
conn_type: rule.edge.clone(),
is_external: false,
props,
reverse: false,
});
out.edge_tables_hit.insert(rule.edge.clone());
}
}
fn target_column(rows: &[Vec<Cell>]) -> Option<usize> {
let width = rows.iter().map(Vec::len).max().unwrap_or(0);
(0..width).find(|&index| {
rows.iter().any(|row| {
row.get(index)
.is_some_and(|c| links::first_wikilink(&c.text).is_some())
})
})
}
fn unescape_pipes(text: &str) -> String {
if text.contains("\\|") {
text.replace("\\|", "|")
} else {
text.to_string()
}
}
#[cfg(test)]
#[path = "tables_tests.rs"]
mod tables_tests;