1use std::collections::BTreeMap;
20
21use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
22
23use crate::annotate::is_comment_line;
24use crate::text::{scan_wiki_links, slugify};
25
26pub const LAT_REF: &str = "import:lat";
29
30#[derive(Debug, Clone)]
32pub struct LatImport {
33 pub facts: FactSet,
35 pub report: LatReport,
37}
38
39#[derive(Debug, Clone, Default, serde::Serialize)]
41pub struct LatReport {
42 pub files: usize,
44 pub sections: usize,
46 pub links_total: usize,
48 pub links_to_sections: usize,
50 pub links_to_code: usize,
52 pub backlinks_resolved: usize,
54 pub backlinks_unresolved: usize,
56}
57
58#[must_use]
62pub fn import_lat(files: &[(String, String)]) -> LatImport {
63 let index = LatIndex::build(files);
64 let mut facts = FactSet::new();
65 let mut report = LatReport::default();
66 for (path, content) in files {
67 report.files += 1;
68 import_file(path, content, &index, &mut facts, &mut report);
69 }
70 LatImport { facts, report }
71}
72
73fn doc_key(path: &str) -> String {
75 format!("lat:{path}")
76}
77
78fn section_key(path: &str, slug: &str) -> String {
80 format!("lat:{path}#{slug}")
81}
82
83#[must_use]
91pub fn resolve_lat_ref(files: &[(String, String)], raw: &str) -> Option<String> {
92 LatIndex::build(files).resolve_section(raw)
93}
94
95const LAT_MARKER: &str = "@lat:";
97
98#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct LatAnnotation {
102 pub path: String,
104 pub reference: String,
106 pub line: usize,
108}
109
110#[must_use]
116pub fn scan_lat_annotations(rel_path: &str, text: &str) -> Vec<LatAnnotation> {
117 let mut out = Vec::new();
118 for (i, line) in text.lines().enumerate() {
119 if !is_comment_line(line) {
120 continue;
121 }
122 let stripped = crate::text::strip_code_spans(line);
125 let Some(pos) = stripped.find(LAT_MARKER) else {
126 continue;
127 };
128 let after = &stripped[pos + LAT_MARKER.len()..];
129 for reference in scan_wiki_links(after) {
130 out.push(LatAnnotation {
131 path: rel_path.to_owned(),
132 reference,
133 line: i + 1,
134 });
135 }
136 }
137 out
138}
139
140#[must_use]
145pub fn import_lat_backlinks(
146 files: &[(String, String)],
147 annotations: &[LatAnnotation],
148) -> (Vec<Edge>, usize) {
149 let index = LatIndex::build(files);
150 let mut edges = Vec::new();
151 let mut unresolved = 0;
152 let mut seen = std::collections::BTreeSet::new();
156 for ann in annotations {
157 if let Some(target) = index.resolve_section(&ann.reference) {
158 let src = format!("file:{}", ann.path);
159 if seen.insert((src.clone(), target.clone())) {
160 edges.push(lat_edge(src, target, EdgeKind::References));
161 }
162 } else {
163 unresolved += 1;
164 }
165 }
166 (edges, unresolved)
167}
168
169struct LatIndex {
172 by_stem: BTreeMap<String, String>,
173}
174
175impl LatIndex {
176 fn build(files: &[(String, String)]) -> Self {
177 let mut by_stem = BTreeMap::new();
178 for (path, _) in files {
179 by_stem.entry(stem_of(path)).or_insert_with(|| path.clone());
180 }
181 Self { by_stem }
182 }
183
184 fn is_lat_file(&self, head: &str) -> bool {
187 let bare = !head.contains('/')
188 && head
189 .rsplit_once('.')
190 .is_none_or(|(_, ext)| ext.eq_ignore_ascii_case("md"));
191 bare && self.by_stem.contains_key(&stem_of(head))
192 }
193
194 fn resolve_section(&self, raw: &str) -> Option<String> {
197 let (head, rest) = split_head(raw);
198 if !self.is_lat_file(head) {
199 return None;
200 }
201 let path = self.by_stem.get(&stem_of(head))?;
202 match rest {
203 Some(section) => {
205 let leaf = section.rsplit('#').next().unwrap_or(section).trim();
206 Some(section_key(path, &slugify(leaf)))
207 }
208 None => Some(doc_key(path)),
209 }
210 }
211}
212
213fn lat_edge(src: String, dst: String, kind: EdgeKind) -> Edge {
217 let mut edge = Edge::authored(src, dst, kind);
218 edge.src_ref = Some(LAT_REF.to_owned());
219 edge
220}
221
222fn split_head(raw: &str) -> (&str, Option<&str>) {
224 match raw.split_once('#') {
225 Some((h, r)) => (h.trim(), Some(r.trim())),
226 None => (raw.trim(), None),
227 }
228}
229
230fn stem_of(path: &str) -> String {
232 let name = path.rsplit('/').next().unwrap_or(path);
233 name.rsplit_once('.')
234 .map_or(name, |(stem, _)| stem)
235 .to_ascii_lowercase()
236}
237
238fn import_file(
241 path: &str,
242 content: &str,
243 index: &LatIndex,
244 facts: &mut FactSet,
245 report: &mut LatReport,
246) {
247 let doc = doc_key(path);
248 let mut stack: Vec<(usize, String)> = Vec::new();
250 let mut title: Option<String> = None;
251 let mut in_fence = false;
252
253 for line in content.lines() {
254 if line.trim_start().starts_with("```") {
255 in_fence = !in_fence;
256 continue;
257 }
258 if in_fence {
259 continue;
260 }
261 if let Some((level, heading)) = heading(line) {
262 title.get_or_insert_with(|| heading.to_owned());
263 let key = section_key(path, &slugify(heading));
264 let mut node = Node::new(key.clone(), NodeKind::Other("lat_section".into()), heading)
265 .with_provenance(Provenance::Authored);
266 node.path = Some(path.to_owned());
267 facts.nodes.push(node);
268 report.sections += 1;
269
270 while stack.last().is_some_and(|(l, _)| *l >= level) {
272 stack.pop();
273 }
274 let parent = stack.last().map_or(doc.clone(), |(_, k)| k.clone());
275 facts
276 .edges
277 .push(lat_edge(parent, key.clone(), EdgeKind::Contains));
278 stack.push((level, key));
279 continue;
280 }
281 let from = stack.last().map_or(doc.clone(), |(_, k)| k.clone());
283 for raw in scan_wiki_links(line) {
284 report.links_total += 1;
285 if let Some((target, to_code)) = resolve_link(index, &raw) {
286 if to_code {
287 report.links_to_code += 1;
288 } else {
289 report.links_to_sections += 1;
290 }
291 facts
292 .edges
293 .push(lat_edge(from.clone(), target, EdgeKind::References));
294 }
295 }
296 }
297
298 let name = title.unwrap_or_else(|| stem_of(path));
299 let mut node =
300 Node::new(doc.clone(), NodeKind::Doc, name).with_provenance(Provenance::Authored);
301 node.path = Some(path.to_owned());
302 facts.nodes.push(node);
304}
305
306fn resolve_link(index: &LatIndex, raw: &str) -> Option<(String, bool)> {
310 if let Some(section) = index.resolve_section(raw) {
311 return Some((section, false));
312 }
313 let (head, rest) = split_head(raw);
314 if head.is_empty() {
315 return None;
316 }
317 let key = match rest.filter(|s| !s.is_empty()) {
318 Some(symbol) => format!("sym:{}:{head}#{symbol}", crate::text::lang_for(head)),
319 None => format!("file:{head}"),
320 };
321 Some((key, true))
322}
323
324fn heading(line: &str) -> Option<(usize, &str)> {
326 let hashes = line.len() - line.trim_start_matches('#').len();
327 if (1..=6).contains(&hashes) && line.as_bytes().get(hashes) == Some(&b' ') {
328 Some((hashes, line[hashes + 1..].trim()))
329 } else {
330 None
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::{LAT_REF, import_lat, resolve_lat_ref};
337 use rto_graph::{EdgeKind, NodeKind};
338
339 fn files() -> Vec<(String, String)> {
340 vec![
341 (
342 "lat.md/architecture.md".to_owned(),
343 "# Architecture\n\nThe system. See [[auth#OAuth Flow]].\n\n\
344 ## Request Pipeline\n\nHandled in [[src/server.rs#run]].\n"
345 .to_owned(),
346 ),
347 (
348 "lat.md/auth.md".to_owned(),
349 "# Auth\n\n## OAuth Flow\n\nTokens via [[src/auth.rs#validate]].\n".to_owned(),
350 ),
351 ]
352 }
353
354 #[test]
355 fn imports_docs_sections_and_contains() {
356 let imp = import_lat(&files());
357 let keys: Vec<_> = imp.facts.nodes.iter().map(|n| n.key.as_str()).collect();
358 assert!(keys.contains(&"lat:lat.md/architecture.md"));
359 assert!(keys.contains(&"lat:lat.md/architecture.md#architecture"));
360 assert!(keys.contains(&"lat:lat.md/architecture.md#request-pipeline"));
361 assert!(keys.contains(&"lat:lat.md/auth.md#oauth-flow"));
362 let sec = imp
364 .facts
365 .nodes
366 .iter()
367 .find(|n| n.key == "lat:lat.md/auth.md#oauth-flow")
368 .unwrap();
369 assert_eq!(sec.kind, NodeKind::Other("lat_section".into()));
370 assert!(imp.facts.edges.iter().any(|e| e.kind == EdgeKind::Contains
372 && e.src == "lat:lat.md/auth.md"
373 && e.dst == "lat:lat.md/auth.md#auth"));
374 assert_eq!(imp.report.files, 2);
375 }
376
377 #[test]
378 fn resolves_lat_and_code_links() {
379 let imp = import_lat(&files());
380 assert!(
382 imp.facts
383 .edges
384 .iter()
385 .any(|e| e.kind == EdgeKind::References
386 && e.src == "lat:lat.md/architecture.md#architecture"
387 && e.dst == "lat:lat.md/auth.md#oauth-flow")
388 );
389 assert!(
391 imp.facts
392 .edges
393 .iter()
394 .any(|e| e.kind == EdgeKind::References
395 && e.src == "lat:lat.md/architecture.md#request-pipeline"
396 && e.dst == "sym:rust:src/server.rs#run")
397 );
398 assert_eq!(imp.report.links_to_sections, 1);
399 assert_eq!(imp.report.links_to_code, 2);
400 assert!(imp.facts.edges.iter().all(|e| {
403 e.provenance.as_str() == "authored" && e.src_ref.as_deref() == Some(LAT_REF)
404 }));
405 }
406
407 #[test]
408 fn resolve_ref_distinguishes_lat_from_code() {
409 let f = files();
410 assert_eq!(
412 resolve_lat_ref(&f, "auth#OAuth Flow").as_deref(),
413 Some("lat:lat.md/auth.md#oauth-flow")
414 );
415 assert_eq!(resolve_lat_ref(&f, "src/auth.rs#validate"), None);
417 assert_eq!(
419 resolve_lat_ref(&f, "architecture").as_deref(),
420 Some("lat:lat.md/architecture.md")
421 );
422 }
423
424 #[test]
425 fn ref_marker_is_stable() {
426 assert_eq!(LAT_REF, "import:lat");
427 }
428
429 #[test]
430 fn scans_lat_backlinks_only_on_comment_lines() {
431 use super::scan_lat_annotations;
432 let src = "// @lat: [[auth#OAuth Flow]]\n\
433 fn f() {}\n\
434 let s = \"@lat: [[architecture]]\";\n\
435 /* see @lat: [[architecture#Request Pipeline]] and [[auth]] */\n";
436 let anns = scan_lat_annotations("src/auth.rs", src);
437 assert_eq!(anns.len(), 3);
439 assert_eq!(anns[0].reference, "auth#OAuth Flow");
440 assert_eq!(anns[0].line, 1);
441 assert_eq!(anns[1].reference, "architecture#Request Pipeline");
442 assert_eq!(anns[1].line, 4);
443 assert_eq!(anns[2].reference, "auth");
444 }
445
446 #[test]
447 fn imports_backlinks_as_authored_file_to_section_edges() {
448 use super::{import_lat_backlinks, scan_lat_annotations};
449 let f = files();
450 let anns = scan_lat_annotations("src/auth.rs", "// @lat: [[auth#OAuth Flow]]\n");
451 let (edges, unresolved) = import_lat_backlinks(&f, &anns);
452 assert_eq!(unresolved, 0);
453 assert_eq!(edges.len(), 1);
454 let e = &edges[0];
455 assert_eq!(e.src, "file:src/auth.rs");
456 assert_eq!(e.dst, "lat:lat.md/auth.md#oauth-flow");
457 assert_eq!(e.kind, EdgeKind::References);
458 assert_eq!(e.provenance.as_str(), "authored");
459 assert_eq!(e.src_ref.as_deref(), Some(LAT_REF));
460 }
461
462 #[test]
463 fn repeated_backlinks_in_a_file_collapse_to_one_edge() {
464 use super::{import_lat_backlinks, scan_lat_annotations};
465 let f = files();
466 let anns = scan_lat_annotations(
468 "src/auth.rs",
469 "// @lat: [[auth#OAuth Flow]]\n// @lat: [[auth#OAuth Flow]]\n",
470 );
471 assert_eq!(anns.len(), 2, "both annotations are scanned");
472 let (edges, unresolved) = import_lat_backlinks(&f, &anns);
473 assert_eq!(unresolved, 0);
474 assert_eq!(edges.len(), 1, "duplicate (file, section) edge collapsed");
475
476 let same_line = scan_lat_annotations(
478 "src/auth.rs",
479 "// @lat: [[auth#OAuth Flow]] [[auth#OAuth Flow]]\n",
480 );
481 assert_eq!(same_line.len(), 2, "both refs on the line are scanned");
482 let (edges, _) = import_lat_backlinks(&f, &same_line);
483 assert_eq!(edges.len(), 1, "same-line duplicate collapsed");
484 }
485
486 #[test]
487 fn backlink_to_unknown_lat_file_is_unresolved() {
488 use super::{import_lat_backlinks, scan_lat_annotations};
489 let f = files();
490 let anns = scan_lat_annotations(
493 "src/x.rs",
494 "// @lat: [[nope#Section]]\n// @lat: [[src/auth.rs#validate]]\n",
495 );
496 let (edges, unresolved) = import_lat_backlinks(&f, &anns);
497 assert!(edges.is_empty());
498 assert_eq!(unresolved, 2);
499 }
500}