use std::collections::BTreeMap;
use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind};
use crate::text::{scan_wiki_links, slugify};
pub const LAT_REF: &str = "import:lat";
#[derive(Debug, Clone)]
pub struct LatImport {
pub facts: FactSet,
pub report: LatReport,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct LatReport {
pub files: usize,
pub sections: usize,
pub links_total: usize,
pub links_to_sections: usize,
pub links_to_code: usize,
}
#[must_use]
pub fn import_lat(files: &[(String, String)]) -> LatImport {
let index = LatIndex::build(files);
let mut facts = FactSet::new();
let mut report = LatReport::default();
for (path, content) in files {
report.files += 1;
import_file(path, content, &index, &mut facts, &mut report);
}
LatImport { facts, report }
}
fn doc_key(path: &str) -> String {
format!("lat:{path}")
}
fn section_key(path: &str, slug: &str) -> String {
format!("lat:{path}#{slug}")
}
#[must_use]
pub fn resolve_lat_ref(files: &[(String, String)], raw: &str) -> Option<String> {
LatIndex::build(files).resolve_section(raw)
}
struct LatIndex {
by_stem: BTreeMap<String, String>,
}
impl LatIndex {
fn build(files: &[(String, String)]) -> Self {
let mut by_stem = BTreeMap::new();
for (path, _) in files {
by_stem.entry(stem_of(path)).or_insert_with(|| path.clone());
}
Self { by_stem }
}
fn is_lat_file(&self, head: &str) -> bool {
let bare = !head.contains('/')
&& head
.rsplit_once('.')
.is_none_or(|(_, ext)| ext.eq_ignore_ascii_case("md"));
bare && self.by_stem.contains_key(&stem_of(head))
}
fn resolve_section(&self, raw: &str) -> Option<String> {
let (head, rest) = split_head(raw);
if !self.is_lat_file(head) {
return None;
}
let path = self.by_stem.get(&stem_of(head))?;
match rest {
Some(section) => {
let leaf = section.rsplit('#').next().unwrap_or(section).trim();
Some(section_key(path, &slugify(leaf)))
}
None => Some(doc_key(path)),
}
}
}
fn lat_edge(src: String, dst: String, kind: EdgeKind) -> Edge {
let mut edge = Edge::authored(src, dst, kind);
edge.src_ref = Some(LAT_REF.to_owned());
edge
}
fn split_head(raw: &str) -> (&str, Option<&str>) {
match raw.split_once('#') {
Some((h, r)) => (h.trim(), Some(r.trim())),
None => (raw.trim(), None),
}
}
fn stem_of(path: &str) -> String {
let name = path.rsplit('/').next().unwrap_or(path);
name.rsplit_once('.')
.map_or(name, |(stem, _)| stem)
.to_ascii_lowercase()
}
fn import_file(
path: &str,
content: &str,
index: &LatIndex,
facts: &mut FactSet,
report: &mut LatReport,
) {
let doc = doc_key(path);
let mut stack: Vec<(usize, String)> = Vec::new();
let mut title: Option<String> = None;
let mut in_fence = false;
for line in content.lines() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
if let Some((level, heading)) = heading(line) {
title.get_or_insert_with(|| heading.to_owned());
let key = section_key(path, &slugify(heading));
let mut node = Node::new(key.clone(), NodeKind::Other("lat_section".into()), heading);
node.path = Some(path.to_owned());
facts.nodes.push(node);
report.sections += 1;
while stack.last().is_some_and(|(l, _)| *l >= level) {
stack.pop();
}
let parent = stack.last().map_or(doc.clone(), |(_, k)| k.clone());
facts
.edges
.push(lat_edge(parent, key.clone(), EdgeKind::Contains));
stack.push((level, key));
continue;
}
let from = stack.last().map_or(doc.clone(), |(_, k)| k.clone());
for raw in scan_wiki_links(line) {
report.links_total += 1;
if let Some((target, to_code)) = resolve_link(index, &raw) {
if to_code {
report.links_to_code += 1;
} else {
report.links_to_sections += 1;
}
facts
.edges
.push(lat_edge(from.clone(), target, EdgeKind::References));
}
}
}
let name = title.unwrap_or_else(|| stem_of(path));
let mut node = Node::new(doc.clone(), NodeKind::Doc, name);
node.path = Some(path.to_owned());
facts.nodes.push(node);
}
fn resolve_link(index: &LatIndex, raw: &str) -> Option<(String, bool)> {
if let Some(section) = index.resolve_section(raw) {
return Some((section, false));
}
let (head, rest) = split_head(raw);
if head.is_empty() {
return None;
}
let key = match rest.filter(|s| !s.is_empty()) {
Some(symbol) => format!("sym:{}:{head}#{symbol}", lang_for(head)),
None => format!("file:{head}"),
};
Some((key, true))
}
fn heading(line: &str) -> Option<(usize, &str)> {
let hashes = line.len() - line.trim_start_matches('#').len();
if (1..=6).contains(&hashes) && line.as_bytes().get(hashes) == Some(&b' ') {
Some((hashes, line[hashes + 1..].trim()))
} else {
None
}
}
fn lang_for(path: &str) -> &str {
match path.rsplit_once('.').map(|(_, ext)| ext) {
Some("rs") => "rust",
Some(other) => other,
None => "text",
}
}
#[cfg(test)]
mod tests {
use super::{LAT_REF, import_lat, resolve_lat_ref};
use rto_graph::{EdgeKind, NodeKind};
fn files() -> Vec<(String, String)> {
vec![
(
"lat.md/architecture.md".to_owned(),
"# Architecture\n\nThe system. See [[auth#OAuth Flow]].\n\n\
## Request Pipeline\n\nHandled in [[src/server.rs#run]].\n"
.to_owned(),
),
(
"lat.md/auth.md".to_owned(),
"# Auth\n\n## OAuth Flow\n\nTokens via [[src/auth.rs#validate]].\n".to_owned(),
),
]
}
#[test]
fn imports_docs_sections_and_contains() {
let imp = import_lat(&files());
let keys: Vec<_> = imp.facts.nodes.iter().map(|n| n.key.as_str()).collect();
assert!(keys.contains(&"lat:lat.md/architecture.md"));
assert!(keys.contains(&"lat:lat.md/architecture.md#architecture"));
assert!(keys.contains(&"lat:lat.md/architecture.md#request-pipeline"));
assert!(keys.contains(&"lat:lat.md/auth.md#oauth-flow"));
let sec = imp
.facts
.nodes
.iter()
.find(|n| n.key == "lat:lat.md/auth.md#oauth-flow")
.unwrap();
assert_eq!(sec.kind, NodeKind::Other("lat_section".into()));
assert!(imp.facts.edges.iter().any(|e| e.kind == EdgeKind::Contains
&& e.src == "lat:lat.md/auth.md"
&& e.dst == "lat:lat.md/auth.md#auth"));
assert_eq!(imp.report.files, 2);
}
#[test]
fn resolves_lat_and_code_links() {
let imp = import_lat(&files());
assert!(
imp.facts
.edges
.iter()
.any(|e| e.kind == EdgeKind::References
&& e.src == "lat:lat.md/architecture.md#architecture"
&& e.dst == "lat:lat.md/auth.md#oauth-flow")
);
assert!(
imp.facts
.edges
.iter()
.any(|e| e.kind == EdgeKind::References
&& e.src == "lat:lat.md/architecture.md#request-pipeline"
&& e.dst == "sym:rust:src/server.rs#run")
);
assert_eq!(imp.report.links_to_sections, 1);
assert_eq!(imp.report.links_to_code, 2);
assert!(imp.facts.edges.iter().all(|e| {
e.provenance.as_str() == "authored" && e.src_ref.as_deref() == Some(LAT_REF)
}));
}
#[test]
fn resolve_ref_distinguishes_lat_from_code() {
let f = files();
assert_eq!(
resolve_lat_ref(&f, "auth#OAuth Flow").as_deref(),
Some("lat:lat.md/auth.md#oauth-flow")
);
assert_eq!(resolve_lat_ref(&f, "src/auth.rs#validate"), None);
assert_eq!(
resolve_lat_ref(&f, "architecture").as_deref(),
Some("lat:lat.md/architecture.md")
);
}
#[test]
fn ref_marker_is_stable() {
assert_eq!(LAT_REF, "import:lat");
}
}