Skip to content

Commit ab4f825

Browse files
Wave3 TestRioPlay
authored andcommitted
feat(parse): emit Requires edges for AsciiDoc include:: directives
The polyglot gen path (aden-parse) dropped include:: entirely — only the legacy hand-rolled aden-graph parser handled it, and only as a no-store fallback. Port it into the canonical pipeline: the AsciiDoc extractor records include targets in a doc_includes attribute on the document's representative node, and a new additive link_include_edges pass resolves each target file (by stem) to its representative doc anchor and emits a directional Requires edge. First step of converging the three parsing surfaces onto the polyglot aden-parse engine. Verified end-to-end: a master.adoc that include::s chapter.adoc now yields master --Requires--> chapter through real gen (edge confirmed under --edge-type Requires, absent under RelatesTo).
1 parent 3fa6dbc commit ab4f825

2 files changed

Lines changed: 233 additions & 0 deletions

File tree

crates/aden-cli/src/commands/generate.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ struct EmittedSymbol {
3333
/// `[text](#frag)`). Linked as bidirectional `RelatesTo` edges against doc
3434
/// anchor fragments only.
3535
refs: Vec<String>,
36+
/// `include::` document-composition targets (raw file paths from the
37+
/// `doc_includes` attribute). Linked as directional `Requires` edges.
38+
includes: Vec<String>,
3639
/// `edge::implements[Trait::method]` references (trait impls) — linked as
3740
/// `Implements` edges so blast radius reaches implementors (Wave 1).
3841
implements: Vec<String>,
@@ -220,6 +223,76 @@ fn extract_doc_refs(doc: &aden_core::Document) -> Vec<String> {
220223
extract_joined_attribute(doc, "doc_refs")
221224
}
222225

226+
/// Document-composition targets, read from the `doc_includes` attribute the
227+
/// AsciiDoc parser fills for `include::` directives. Resolved file-wise (by
228+
/// stem) to directional `Requires` edges in [`link_include_edges`].
229+
fn extract_doc_includes(doc: &aden_core::Document) -> Vec<String> {
230+
extract_joined_attribute(doc, "doc_includes")
231+
}
232+
233+
/// Resolve `include::` directives (the `doc_includes` channel) into directional
234+
/// `Requires` edges: an including document depends on each file it pulls in.
235+
/// Resolution is file-wise by stem (an include names a file, not a fragment),
236+
/// pointing at that file's representative (first) doc node. Runs as a separate
237+
/// additive pass after `link_store_edges`; `put_edges_bulk` appends, so edges
238+
/// already written are kept.
239+
fn link_include_edges<S: GraphStorage>(
240+
storage: &S,
241+
include_records: &[(String, Vec<String>)],
242+
) -> Result<(), Box<dyn std::error::Error>> {
243+
use aden_core::EdgeType;
244+
if include_records.is_empty() {
245+
return Ok(());
246+
}
247+
let anchors = storage.get_all_anchors()?;
248+
// File stem -> declaring doc anchors (sorted/deduped for deterministic picks).
249+
let mut stem_index: HashMap<&str, Vec<&str>> = HashMap::new();
250+
for anchor in &anchors {
251+
if let Some(file_key) = doc_anchor_file(anchor)
252+
&& let Some(stem) = std::path::Path::new(file_key)
253+
.file_stem()
254+
.and_then(|s| s.to_str())
255+
{
256+
stem_index.entry(stem).or_default().push(anchor.as_str());
257+
}
258+
}
259+
for cands in stem_index.values_mut() {
260+
cands.sort_unstable();
261+
cands.dedup();
262+
}
263+
let mut edges: Vec<(String, String, EdgeType)> = Vec::new();
264+
for (referrer, targets) in include_records {
265+
let ref_file = doc_anchor_file(referrer);
266+
for target in targets {
267+
let Some(stem) = std::path::Path::new(target)
268+
.file_stem()
269+
.and_then(|s| s.to_str())
270+
else {
271+
continue;
272+
};
273+
let Some(cands) = stem_index.get(stem) else {
274+
continue;
275+
};
276+
// An include points at ANOTHER document: prefer a candidate in a
277+
// different file than the referrer; else the first (deterministic).
278+
let target_anchor = cands
279+
.iter()
280+
.copied()
281+
.find(|a| doc_anchor_file(a) != ref_file)
282+
.or_else(|| cands.first().copied());
283+
if let Some(ta) = target_anchor
284+
&& ta != referrer.as_str()
285+
{
286+
edges.push((referrer.clone(), ta.to_string(), EdgeType::Requires));
287+
}
288+
}
289+
}
290+
if !edges.is_empty() {
291+
storage.put_edges_bulk(&edges)?;
292+
}
293+
Ok(())
294+
}
295+
223296
/// Backtick prose mentions, read from the `doc_mentions` attribute the format
224297
/// parsers fill (Wave 2). Same division of labor as `doc_refs`: the parser
225298
/// knows fence/backtick state; resolution in [`link_store_edges`] is
@@ -1500,6 +1573,7 @@ fn cmd_gen_inner(
15001573
let callees = extract_callees(&doc_clone);
15011574
let uses = extract_uses(&doc_clone);
15021575
let refs = extract_doc_refs(&doc_clone);
1576+
let includes = extract_doc_includes(&doc_clone);
15031577
let implements = extract_edge_macro(&doc_clone, "implements");
15041578
let mutates = extract_edge_macro(&doc_clone, "mutates");
15051579
let mentions = extract_doc_mentions(&doc_clone);
@@ -1549,6 +1623,7 @@ fn cmd_gen_inner(
15491623
callees,
15501624
uses,
15511625
refs,
1626+
includes,
15521627
implements,
15531628
mutates,
15541629
mentions,
@@ -1581,6 +1656,7 @@ fn cmd_gen_inner(
15811656
let mut link_records: Vec<(String, Vec<String>)> = Vec::new();
15821657
let mut use_records: Vec<(String, Vec<String>)> = Vec::new();
15831658
let mut ref_records: Vec<(String, Vec<String>)> = Vec::new();
1659+
let mut include_records: Vec<(String, Vec<String>)> = Vec::new();
15841660
let mut impl_records: Vec<(String, Vec<String>)> = Vec::new();
15851661
let mut mutates_records: Vec<(String, Vec<String>)> = Vec::new();
15861662
let mut mention_records: Vec<(String, Vec<String>)> = Vec::new();
@@ -1672,6 +1748,9 @@ fn cmd_gen_inner(
16721748
if !sym.refs.is_empty() {
16731749
ref_records.push((sym.anchor.clone(), sym.refs));
16741750
}
1751+
if !sym.includes.is_empty() {
1752+
include_records.push((sym.anchor.clone(), sym.includes));
1753+
}
16751754
if !sym.uses.is_empty() {
16761755
use_records.push((sym.anchor.clone(), sym.uses));
16771756
}
@@ -1792,6 +1871,13 @@ fn cmd_gen_inner(
17921871
}
17931872
};
17941873

1874+
// Additive second pass: include:: directives -> Requires edges. Kept
1875+
// separate from link_store_edges (which works from anchors only); this
1876+
// resolves file-wise and put_edges_bulk appends, so prior edges persist.
1877+
if let Err(e) = link_include_edges(&storage, &include_records) {
1878+
eprintln!("WARN: Failed to link include edges: {}", e);
1879+
}
1880+
17951881
save_gen_cache(&cache_path, &cache)?;
17961882

17971883
// The summary is "summary only" output: shown under --quiet/regen, but
@@ -2292,6 +2378,44 @@ mod link_tests {
22922378
/// Prose `ref:` records resolve format-neutrally against DOC anchor
22932379
/// fragments only — bidirectional `RelatesTo` — and must NEVER attach to a
22942380
/// same-named code symbol (the fuzzy code path is off-limits to prose).
2381+
#[test]
2382+
fn include_records_link_requires_edges() {
2383+
use aden_core::{Block, Document, EdgeType, NodeType};
2384+
let dir = tempfile::tempdir().unwrap();
2385+
let storage = Storage::new(dir.path().to_str().unwrap()).unwrap();
2386+
let mk = |anchor: &str| Document {
2387+
anchor: anchor.into(),
2388+
node_type: NodeType::Module,
2389+
attributes: Default::default(),
2390+
blocks: vec![Block::Paragraph("body".into())],
2391+
source_span: None,
2392+
metadata: None,
2393+
confidence: 1.0,
2394+
};
2395+
// A master doc and a chapter it includes, each a heading-section node.
2396+
let master = "aden://doc/p/master.adoc/h1master";
2397+
let chapter = "aden://doc/p/chapter-one.adoc/h1chapter";
2398+
for a in [master, chapter] {
2399+
storage.put_document(&mk(a)).unwrap();
2400+
}
2401+
storage.flush().unwrap();
2402+
2403+
let include_records = vec![(master.to_string(), vec!["chapter-one.adoc".to_string()])];
2404+
link_include_edges(&storage, &include_records).unwrap();
2405+
2406+
let out = storage.get_outgoing_edges(master).unwrap();
2407+
assert!(
2408+
out.contains(&(chapter.to_string(), EdgeType::Requires)),
2409+
"master must Requires the included chapter doc node; got {out:?}"
2410+
);
2411+
// Directional: the chapter does not Requires the master.
2412+
let back = storage.get_outgoing_edges(chapter).unwrap();
2413+
assert!(
2414+
!back.iter().any(|(t, _)| t == master),
2415+
"include edge must be directional (master->chapter only); got {back:?}"
2416+
);
2417+
}
2418+
22952419
#[test]
22962420
fn prose_ref_records_link_doc_anchors_only() {
22972421
use aden_core::{Block, Document, EdgeType, NodeType};
@@ -2509,6 +2633,33 @@ mod link_tests {
25092633
);
25102634
}
25112635

2636+
#[test]
2637+
fn extract_doc_includes_reads_doc_includes_attribute() {
2638+
use aden_core::{Block, Document, NodeType};
2639+
let mut attrs = std::collections::HashMap::new();
2640+
attrs.insert(
2641+
"doc_includes".to_string(),
2642+
"chapter-two.adoc,chapter-one.adoc,chapter-two.adoc".to_string(),
2643+
);
2644+
let doc = Document {
2645+
anchor: "aden://doc/p/master.adoc/h2overview".into(),
2646+
node_type: NodeType::Module,
2647+
attributes: attrs,
2648+
blocks: vec![Block::Paragraph("body".into())],
2649+
source_span: None,
2650+
metadata: None,
2651+
confidence: 1.0,
2652+
};
2653+
assert_eq!(
2654+
extract_doc_includes(&doc),
2655+
vec![
2656+
"chapter-one.adoc".to_string(),
2657+
"chapter-two.adoc".to_string()
2658+
],
2659+
"sorted, deduped, attribute-sourced"
2660+
);
2661+
}
2662+
25122663
#[test]
25132664
fn extract_uses_reads_edge_uses_listings() {
25142665
use aden_core::{Block, Document, NodeType};

crates/aden-parse/src/asciidoc.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ impl crate::extractor::LanguageExtractor for AsciiDocExtractor {
6969
// def)` — Term-node candidates; only those inside glossary-gated
7070
// sections are promoted below.
7171
let mut term_lines: Vec<(usize, Option<String>, String, String)> = Vec::new();
72+
// `include::target[]` directives (document composition). File-level, so
73+
// collected once and attached to the document's representative node;
74+
// resolved to `Requires` edges in the link phase.
75+
let mut includes: Vec<String> = Vec::new();
7276

7377
for (line_num, line) in body.lines().enumerate() {
7478
let line_num = line_num + 1;
@@ -113,6 +117,17 @@ impl crate::extractor::LanguageExtractor for AsciiDocExtractor {
113117
if let Some((explicit, name, def)) = parse_dlist_term(line) {
114118
term_lines.push((line_num - 1, explicit, name, def));
115119
}
120+
// `include::target[opts]` — document composition. Fence-guarded by the
121+
// listing/literal skips above, so an include inside a delimited block
122+
// (a code example) is not mistaken for a real directive.
123+
if let Some(rest) = line.trim_start().strip_prefix("include::")
124+
&& let Some(br) = rest.find('[')
125+
{
126+
let target = rest[..br].trim();
127+
if !target.is_empty() {
128+
includes.push(target.to_string());
129+
}
130+
}
116131

117132
if let Some(rest) = line.strip_prefix("= ") {
118133
let title = rest.trim().to_string();
@@ -226,6 +241,15 @@ impl crate::extractor::LanguageExtractor for AsciiDocExtractor {
226241
if !section_refs.is_empty() {
227242
attrs.insert("doc_refs".to_string(), section_refs.join(","));
228243
}
244+
// Includes are file-level; attach the whole file's targets to the
245+
// document's representative (first) node so the link phase emits
246+
// one Requires edge per included file from the document.
247+
if hi == 0 && !includes.is_empty() {
248+
let mut inc = includes.clone();
249+
inc.sort();
250+
inc.dedup();
251+
attrs.insert("doc_includes".to_string(), inc.join(","));
252+
}
229253
// Same attribution rule for prose mentions (Wave-2 Mentions).
230254
let mut section_mentions: Vec<String> = line_mentions
231255
.iter()
@@ -303,6 +327,12 @@ impl crate::extractor::LanguageExtractor for AsciiDocExtractor {
303327
if !all_refs.is_empty() {
304328
attrs.insert("doc_refs".to_string(), all_refs.join(","));
305329
}
330+
if !includes.is_empty() {
331+
let mut inc = includes.clone();
332+
inc.sort();
333+
inc.dedup();
334+
attrs.insert("doc_includes".to_string(), inc.join(","));
335+
}
306336
let mut all_mentions: Vec<String> =
307337
line_mentions.iter().map(|(_, m)| m.clone()).collect();
308338
all_mentions.sort();
@@ -1025,6 +1055,58 @@ References <<_other>> here.
10251055
);
10261056
}
10271057

1058+
/// `include::target[]` directives must surface as a `doc_includes` attribute
1059+
/// on the document's representative node, so the link phase can emit a
1060+
/// `Requires` edge. The canonical gen path previously dropped includes.
1061+
#[test]
1062+
fn include_directive_emits_doc_includes_attribute() {
1063+
let ext = AsciiDocExtractor::new();
1064+
let src = "\
1065+
= Master
1066+
1067+
include::chapter-one.adoc[]
1068+
1069+
== Overview
1070+
1071+
Body.
1072+
";
1073+
let docs = ext
1074+
.extract_documents(src, Path::new("master.adoc"))
1075+
.expect("extraction must succeed");
1076+
let with_inc = docs
1077+
.iter()
1078+
.find(|d| d.attributes.contains_key("doc_includes"))
1079+
.expect("a node must carry doc_includes");
1080+
assert_eq!(
1081+
with_inc.attributes.get("doc_includes").map(String::as_str),
1082+
Some("chapter-one.adoc"),
1083+
);
1084+
}
1085+
1086+
/// `include::` inside a delimited listing block is a code example, not a real
1087+
/// directive — it must NOT produce a doc_includes entry.
1088+
#[test]
1089+
fn include_inside_listing_block_is_ignored() {
1090+
let ext = AsciiDocExtractor::new();
1091+
let src = "\
1092+
= Master
1093+
1094+
----
1095+
include::not-real.adoc[]
1096+
----
1097+
1098+
Body.
1099+
";
1100+
let docs = ext
1101+
.extract_documents(src, Path::new("master.adoc"))
1102+
.expect("extraction must succeed");
1103+
assert!(
1104+
docs.iter()
1105+
.all(|d| !d.attributes.contains_key("doc_includes")),
1106+
"include inside a listing block must be ignored"
1107+
);
1108+
}
1109+
10281110
/// Regression: multibyte characters (em-dashes, typographic quotes) before
10291111
/// an `xref:` must not panic the byte-indexed scanner on a non-boundary
10301112
/// slice (`line[i..]` with i inside a UTF-8 sequence).

0 commit comments

Comments
 (0)