use std::collections::BTreeMap;
use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
use crate::annotate::is_comment_line;
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,
pub backlinks_resolved: usize,
pub backlinks_unresolved: 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)
}
const LAT_MARKER: &str = "@lat:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LatAnnotation {
pub path: String,
pub reference: String,
pub line: usize,
}
#[must_use]
pub fn scan_lat_annotations(rel_path: &str, text: &str) -> Vec<LatAnnotation> {
let mut out = Vec::new();
for (i, line) in text.lines().enumerate() {
if !is_comment_line(line) {
continue;
}
let stripped = crate::text::strip_code_spans(line);
let Some(pos) = stripped.find(LAT_MARKER) else {
continue;
};
let after = &stripped[pos + LAT_MARKER.len()..];
for reference in scan_wiki_links(after) {
out.push(LatAnnotation {
path: rel_path.to_owned(),
reference,
line: i + 1,
});
}
}
out
}
#[must_use]
pub fn import_lat_backlinks(
files: &[(String, String)],
annotations: &[LatAnnotation],
) -> (Vec<Edge>, usize) {
let index = LatIndex::build(files);
let mut edges = Vec::new();
let mut unresolved = 0;
let mut seen = std::collections::BTreeSet::new();
for ann in annotations {
if let Some(target) = index.resolve_section(&ann.reference) {
let src = format!("file:{}", ann.path);
if seen.insert((src.clone(), target.clone())) {
edges.push(lat_edge(src, target, EdgeKind::References));
}
} else {
unresolved += 1;
}
}
(edges, unresolved)
}
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)
.with_provenance(Provenance::Authored);
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).with_provenance(Provenance::Authored);
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}", crate::text::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
}
}
#[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");
}
#[test]
fn scans_lat_backlinks_only_on_comment_lines() {
use super::scan_lat_annotations;
let src = "// @lat: [[auth#OAuth Flow]]\n\
fn f() {}\n\
let s = \"@lat: [[architecture]]\";\n\
/* see @lat: [[architecture#Request Pipeline]] and [[auth]] */\n";
let anns = scan_lat_annotations("src/auth.rs", src);
assert_eq!(anns.len(), 3);
assert_eq!(anns[0].reference, "auth#OAuth Flow");
assert_eq!(anns[0].line, 1);
assert_eq!(anns[1].reference, "architecture#Request Pipeline");
assert_eq!(anns[1].line, 4);
assert_eq!(anns[2].reference, "auth");
}
#[test]
fn imports_backlinks_as_authored_file_to_section_edges() {
use super::{import_lat_backlinks, scan_lat_annotations};
let f = files();
let anns = scan_lat_annotations("src/auth.rs", "// @lat: [[auth#OAuth Flow]]\n");
let (edges, unresolved) = import_lat_backlinks(&f, &anns);
assert_eq!(unresolved, 0);
assert_eq!(edges.len(), 1);
let e = &edges[0];
assert_eq!(e.src, "file:src/auth.rs");
assert_eq!(e.dst, "lat:lat.md/auth.md#oauth-flow");
assert_eq!(e.kind, EdgeKind::References);
assert_eq!(e.provenance.as_str(), "authored");
assert_eq!(e.src_ref.as_deref(), Some(LAT_REF));
}
#[test]
fn repeated_backlinks_in_a_file_collapse_to_one_edge() {
use super::{import_lat_backlinks, scan_lat_annotations};
let f = files();
let anns = scan_lat_annotations(
"src/auth.rs",
"// @lat: [[auth#OAuth Flow]]\n// @lat: [[auth#OAuth Flow]]\n",
);
assert_eq!(anns.len(), 2, "both annotations are scanned");
let (edges, unresolved) = import_lat_backlinks(&f, &anns);
assert_eq!(unresolved, 0);
assert_eq!(edges.len(), 1, "duplicate (file, section) edge collapsed");
let same_line = scan_lat_annotations(
"src/auth.rs",
"// @lat: [[auth#OAuth Flow]] [[auth#OAuth Flow]]\n",
);
assert_eq!(same_line.len(), 2, "both refs on the line are scanned");
let (edges, _) = import_lat_backlinks(&f, &same_line);
assert_eq!(edges.len(), 1, "same-line duplicate collapsed");
}
#[test]
fn backlink_to_unknown_lat_file_is_unresolved() {
use super::{import_lat_backlinks, scan_lat_annotations};
let f = files();
let anns = scan_lat_annotations(
"src/x.rs",
"// @lat: [[nope#Section]]\n// @lat: [[src/auth.rs#validate]]\n",
);
let (edges, unresolved) = import_lat_backlinks(&f, &anns);
assert!(edges.is_empty());
assert_eq!(unresolved, 2);
}
}