use super::*;
use crate::lex::ast::elements::content_item::ContentItem;
use crate::lex::ast::Document;
use std::collections::BTreeSet;
use std::path::PathBuf;
const TEST_ROOT: &str = "/repo";
const DEFAULT_MAIN_PATH: &str = "/repo/main.lex";
fn fixture(main_source: &str, files: &[(&str, &str)]) -> Result<Tree, IncludeError> {
fixture_at(DEFAULT_MAIN_PATH, main_source, files)
}
fn fixture_at(
main_path: &str,
main_source: &str,
files: &[(&str, &str)],
) -> Result<Tree, IncludeError> {
let mut loader = MemoryLoader::new();
loader.insert(main_path, main_source);
for (p, s) in files {
loader.insert(*p, *s);
}
let config = ResolveConfig::with_root(PathBuf::from(TEST_ROOT));
let doc = resolve_from_source(
main_source,
Some(PathBuf::from(main_path)),
&config,
&loader,
)?;
Ok(Tree { doc })
}
struct Tree {
doc: Document,
}
impl Tree {
fn root_children(&self) -> &[ContentItem] {
&self.doc.root.children
}
fn root_session_titles(&self) -> Vec<String> {
self.root_children()
.iter()
.filter_map(|i| match i {
ContentItem::Session(s) => Some(s.title.as_string().to_string()),
_ => None,
})
.collect()
}
fn root_paragraph_texts(&self) -> Vec<String> {
self.root_children()
.iter()
.filter_map(|i| match i {
ContentItem::Paragraph(p) => Some(p.text()),
_ => None,
})
.collect()
}
fn all_attached_annotation_labels(&self) -> Vec<String> {
let mut out = Vec::new();
for ann in &self.doc.annotations {
out.push(ann.data.label.value.clone());
collect_attached_labels(&ann.children, &mut out);
}
collect_attached_labels(self.root_children(), &mut out);
out
}
fn distinct_origin_paths(&self) -> BTreeSet<Option<PathBuf>> {
let mut set = BTreeSet::new();
set.insert(
self.doc
.root
.location
.origin_path
.as_ref()
.map(|p| (**p).clone()),
);
for item in self.root_children() {
collect_origins_from_item(item, &mut set);
}
set
}
fn find_session(&self, title: &str) -> Option<&Session> {
find_session_in(self.root_children(), title)
}
#[allow(dead_code)]
fn dump(&self) -> String {
let mut out = String::new();
out.push_str(&format!(
"Document(annotations=[{}], title={:?})\n",
self.doc
.annotations
.iter()
.map(|a| a.data.label.value.clone())
.collect::<Vec<_>>()
.join(","),
self.doc.title.as_ref().map(|t| t.as_str()),
));
dump_items(&self.doc.root.children, 1, &mut out);
out
}
}
#[allow(dead_code)]
fn dump_items(items: &[ContentItem], depth: usize, out: &mut String) {
let pad = " ".repeat(depth);
for item in items {
match item {
ContentItem::Session(s) => {
out.push_str(&format!(
"{pad}Session({:?}) attached=[{}]\n",
s.title.as_string(),
s.annotations
.iter()
.map(|a| format!("{}({:?})", a.data.label.value, a.include_src()))
.collect::<Vec<_>>()
.join(",")
));
dump_items(&s.children, depth + 1, out);
}
ContentItem::Definition(d) => {
out.push_str(&format!(
"{pad}Definition({:?}) attached=[{}]\n",
d.subject.as_string(),
d.annotations
.iter()
.map(|a| a.data.label.value.clone())
.collect::<Vec<_>>()
.join(",")
));
dump_items(&d.children, depth + 1, out);
}
ContentItem::Paragraph(p) => {
out.push_str(&format!(
"{pad}Paragraph({:?}) attached=[{}]\n",
p.text(),
p.annotations
.iter()
.map(|a| format!("{}({:?})", a.data.label.value, a.include_src()))
.collect::<Vec<_>>()
.join(",")
));
}
ContentItem::Annotation(a) => {
out.push_str(&format!(
"{pad}Annotation({}, src={:?}) children:\n",
a.data.label.value,
a.include_src()
));
dump_items(&a.children, depth + 1, out);
}
ContentItem::List(l) => {
out.push_str(&format!("{pad}List({} items)\n", l.items.len()));
dump_items(&l.items, depth + 1, out);
}
ContentItem::ListItem(li) => {
out.push_str(&format!(
"{pad}ListItem({:?}) attached=[{}]\n",
li.text
.iter()
.map(|t| t.as_string().to_string())
.collect::<Vec<_>>()
.join(""),
li.annotations
.iter()
.map(|a| a.data.label.value.clone())
.collect::<Vec<_>>()
.join(",")
));
dump_items(&li.children, depth + 1, out);
}
other => {
out.push_str(&format!("{pad}{}\n", other.node_type()));
}
}
}
}
fn collect_attached_labels(items: &[ContentItem], out: &mut Vec<String>) {
for item in items {
match item {
ContentItem::Session(s) => {
for ann in &s.annotations {
out.push(ann.data.label.value.clone());
collect_attached_labels(&ann.children, out);
}
collect_attached_labels(&s.children, out);
}
ContentItem::Definition(d) => {
for ann in &d.annotations {
out.push(ann.data.label.value.clone());
collect_attached_labels(&ann.children, out);
}
collect_attached_labels(&d.children, out);
}
ContentItem::ListItem(li) => {
for ann in &li.annotations {
out.push(ann.data.label.value.clone());
collect_attached_labels(&ann.children, out);
}
collect_attached_labels(&li.children, out);
}
ContentItem::Paragraph(p) => {
for ann in &p.annotations {
out.push(ann.data.label.value.clone());
collect_attached_labels(&ann.children, out);
}
}
ContentItem::List(l) => {
collect_attached_labels(&l.items, out);
}
ContentItem::Annotation(a) => {
out.push(a.data.label.value.clone());
collect_attached_labels(&a.children, out);
}
_ => {}
}
}
}
fn collect_origins_from_item(item: &ContentItem, set: &mut BTreeSet<Option<PathBuf>>) {
let origin = item.range().origin_path.as_ref().map(|p| (**p).clone());
set.insert(origin);
match item {
ContentItem::Session(s) => {
for child in &s.children {
collect_origins_from_item(child, set);
}
}
ContentItem::Definition(d) => {
for child in &d.children {
collect_origins_from_item(child, set);
}
}
ContentItem::ListItem(li) => {
for child in &li.children {
collect_origins_from_item(child, set);
}
}
ContentItem::List(l) => {
for li in &l.items {
collect_origins_from_item(li, set);
}
}
_ => {}
}
}
fn find_session_in<'a>(items: &'a [ContentItem], title: &str) -> Option<&'a Session> {
for item in items {
if let ContentItem::Session(s) = item {
if s.title.as_string() == title {
return Some(s);
}
if let Some(found) = find_session_in(&s.children, title) {
return Some(found);
}
}
}
None
}
use crate::lex::ast::traits::AstNode;
fn assert_no_unresolved_includes(tree: &Tree) {
let mut found = Vec::new();
walk_for_unresolved_includes(tree.root_children(), &mut found);
assert!(
found.is_empty(),
"unresolved lex.include annotations remain at: {found:?}"
);
}
fn walk_for_unresolved_includes(items: &[ContentItem], found: &mut Vec<String>) {
for item in items {
match item {
ContentItem::Annotation(a) if a.is_include() => {
found.push(format!("{}", a.location));
}
ContentItem::Session(s) => walk_for_unresolved_includes(&s.children, found),
ContentItem::Definition(d) => walk_for_unresolved_includes(&d.children, found),
ContentItem::ListItem(li) => walk_for_unresolved_includes(&li.children, found),
ContentItem::List(l) => walk_for_unresolved_includes(&l.items, found),
ContentItem::Annotation(a) => walk_for_unresolved_includes(&a.children, found),
_ => {}
}
}
}
fn assert_origins(tree: &Tree, expected: &[&str]) {
let actual = tree.distinct_origin_paths();
let want: BTreeSet<Option<PathBuf>> =
expected.iter().map(|s| Some(PathBuf::from(*s))).collect();
assert_eq!(
actual, want,
"origin paths mismatch: got {actual:?}, expected {want:?}"
);
}
fn assert_include_annotation_attached(tree: &Tree, expected_src: &str) {
for ann in &tree.doc.annotations {
if ann.is_include() && ann.include_src().as_deref() == Some(expected_src) {
return;
}
}
let mut found = false;
walk_for_attached_include(tree.root_children(), expected_src, &mut found);
assert!(
found,
"no preserved lex.include annotation found with src={expected_src:?}"
);
}
fn walk_for_attached_include(items: &[ContentItem], src: &str, found: &mut bool) {
for item in items {
if let ContentItem::Annotation(a) = item {
if a.is_include() && a.include_src().as_deref() == Some(src) {
*found = true;
return;
}
}
let attached = match item {
ContentItem::Session(s) => &s.annotations[..],
ContentItem::Definition(d) => &d.annotations[..],
ContentItem::ListItem(li) => &li.annotations[..],
ContentItem::Paragraph(p) => &p.annotations[..],
_ => &[],
};
for ann in attached {
if ann.is_include() && ann.include_src().as_deref() == Some(src) {
*found = true;
return;
}
}
match item {
ContentItem::Session(s) => walk_for_attached_include(&s.children, src, found),
ContentItem::Definition(d) => walk_for_attached_include(&d.children, src, found),
ContentItem::ListItem(li) => walk_for_attached_include(&li.children, src, found),
ContentItem::List(l) => walk_for_attached_include(&l.items, src, found),
ContentItem::Annotation(a) => walk_for_attached_include(&a.children, src, found),
_ => {}
}
if *found {
return;
}
}
}
macro_rules! assert_err_kind {
($result:expr, $pattern:pat $(if $guard:expr)?) => {
match $result {
Err(err) => {
assert!(
matches!(&err, $pattern $(if $guard)?),
"expected {} but got {err:?}",
stringify!($pattern),
);
err
}
Ok(_) => panic!(
"expected error matching {} but got Ok(_)",
stringify!($pattern)
),
}
};
}
#[test]
fn simple_paragraph_only_include() {
let tree = fixture(
":: lex.include src=\"frag.lex\" ::\n",
&[("/repo/frag.lex", "Just a paragraph.\n\nAnd another.\n")],
)
.unwrap();
let texts = tree.root_paragraph_texts();
assert!(texts.iter().any(|t| t == "Just a paragraph."), "{texts:?}");
assert!(texts.iter().any(|t| t == "And another."), "{texts:?}");
assert_no_unresolved_includes(&tree);
}
#[test]
fn include_with_top_level_session_at_root_is_allowed() {
let tree = fixture(
":: lex.include src=\"chapter.lex\" ::\n",
&[("/repo/chapter.lex", "1. Chapter One\n\n First para.\n")],
)
.unwrap();
assert_eq!(tree.root_session_titles(), vec!["1. Chapter One"]);
assert_no_unresolved_includes(&tree);
assert_include_annotation_attached(&tree, "chapter.lex");
}
#[test]
fn include_inside_session_with_sessions_is_allowed() {
let tree = fixture(
"1. Part One\n\n :: lex.include src=\"sub.lex\" ::\n",
&[("/repo/sub.lex", "1.1 Section A\n\n Body.\n")],
)
.unwrap();
let part_one = tree.find_session("1. Part One").expect("Part One missing");
let sub_titles: Vec<String> = part_one
.children
.iter()
.filter_map(|i| match i {
ContentItem::Session(s) => Some(s.title.as_string().to_string()),
_ => None,
})
.collect();
assert_eq!(sub_titles, vec!["1.1 Section A"]);
}
#[test]
fn doc_title_of_included_file_becomes_paragraph() {
let tree = fixture(
":: lex.include src=\"sub.lex\" ::\n",
&[("/repo/sub.lex", "Subtitle Line\n\nBody paragraph.\n")],
)
.unwrap();
let texts = tree.root_paragraph_texts();
assert!(
texts.iter().any(|t| t == "Subtitle Line"),
"title should appear as paragraph text, got {texts:?}"
);
assert!(
texts.iter().any(|t| t == "Body paragraph."),
"body should also be in the splice, got {texts:?}"
);
}
#[test]
fn doc_level_annotations_of_included_file_become_regular_annotations() {
let tree = fixture(
":: lex.include src=\"sub.lex\" ::\n",
&[("/repo/sub.lex", ":: meta version=\"1\" ::\n\nBody para.\n")],
)
.unwrap();
let labels = tree.all_attached_annotation_labels();
assert!(
labels.iter().any(|l| l == "meta"),
"meta annotation should have made it into the merged tree, got {labels:?}"
);
}
#[test]
fn multiple_includes_in_same_parent_are_independent() {
let tree = fixture(
":: lex.include src=\"a.lex\" ::\n\n:: lex.include src=\"b.lex\" ::\n",
&[
("/repo/a.lex", "1. Chapter A\n\n Para A.\n"),
("/repo/b.lex", "2. Chapter B\n\n Para B.\n"),
],
)
.unwrap();
assert_eq!(
tree.root_session_titles(),
vec!["1. Chapter A", "2. Chapter B"]
);
assert_include_annotation_attached(&tree, "a.lex");
assert_include_annotation_attached(&tree, "b.lex");
assert_no_unresolved_includes(&tree);
}
#[test]
fn root_absolute_path_resolves_against_root() {
let tree = fixture_at(
"/repo/pages/host.lex",
":: lex.include src=\"/shared/h.lex\" ::\n",
&[("/repo/shared/h.lex", "1. Shared\n\n Body.\n")],
)
.unwrap();
assert_eq!(tree.root_session_titles(), vec!["1. Shared"]);
}
#[test]
fn relative_path_resolves_from_host_directory() {
let tree = fixture_at(
"/repo/chapters/c1.lex",
":: lex.include src=\"sub/snippet.lex\" ::\n",
&[("/repo/chapters/sub/snippet.lex", "Snippet body.\n")],
)
.unwrap();
assert!(tree
.root_paragraph_texts()
.iter()
.any(|t| t == "Snippet body."));
}
#[test]
fn missing_target_surfaces_not_found_with_canonical_path() {
let result = fixture(":: lex.include src=\"missing.lex\" ::\n", &[]);
let err = assert_err_kind!(result, IncludeError::NotFound { .. });
if let IncludeError::NotFound {
path, include_site, ..
} = err
{
assert_eq!(path, PathBuf::from("/repo/missing.lex"));
assert_ne!(
include_site,
crate::lex::ast::Range::default(),
"include_site should locate the annotation, not be the default head-range",
);
}
}
#[test]
fn root_escape_via_dotdot_is_rejected() {
let result = fixture_at(
"/repo/pages/host.lex",
":: lex.include src=\"../../etc/passwd\" ::\n",
&[],
);
assert_err_kind!(result, IncludeError::RootEscape { .. });
}
#[test]
fn root_escape_via_chained_dotdot_from_relative_root_is_rejected() {
let result = fixture_at(
"/repo/a/b/c/host.lex",
":: lex.include src=\"../../../../etc/passwd\" ::\n",
&[],
);
assert_err_kind!(result, IncludeError::RootEscape { .. });
}
#[test]
fn include_inside_definition_with_sessions_is_policy_error() {
let result = fixture(
"Glossary:\n Some intro.\n\n :: lex.include src=\"chapter.lex\" ::\n",
&[("/repo/chapter.lex", "1. Chapter\n\n Body.\n")],
);
let err = assert_err_kind!(result, IncludeError::ContainerPolicy { .. });
if let IncludeError::ContainerPolicy {
container,
violation,
..
} = err
{
assert_eq!(container, "Definition");
assert_eq!(violation, "Sessions");
}
}
#[test]
fn include_inside_annotation_body_with_sessions_is_policy_error() {
let result = fixture(
":: review author=\"alice\" ::\n A note.\n\n :: lex.include src=\"chapter.lex\" ::\n",
&[("/repo/chapter.lex", "1. Chapter\n\n Body.\n")],
);
let err = assert_err_kind!(result, IncludeError::ContainerPolicy { .. });
if let IncludeError::ContainerPolicy { container, .. } = err {
assert_eq!(container, "Annotation body");
}
}
#[test]
fn include_inside_list_item_with_sessions_is_policy_error() {
let main =
"- An item with included content\n :: lex.include src=\"chapter.lex\" ::\n- Closer item\n";
let result = fixture(main, &[("/repo/chapter.lex", "1. Chapter\n\n Body.\n")]);
if let Err(err) = result {
assert!(
matches!(
&err,
IncludeError::ContainerPolicy { container, .. } if *container == "ListItem"
),
"if it errors, it must be ContainerPolicy::ListItem; got {err:?}"
);
}
}
#[test]
fn include_inside_annotation_body_without_sessions_is_allowed() {
let tree = fixture(
":: review author=\"alice\" ::\n A note.\n\n :: lex.include src=\"reviews.lex\" ::\n",
&[(
"/repo/reviews.lex",
":: review author=\"bob\" :: Looks good.\n\n:: review author=\"carol\" :: +1\n",
)],
)
.unwrap();
let labels = tree.all_attached_annotation_labels();
let review_count = labels.iter().filter(|l| *l == "review").count();
assert!(
review_count >= 3,
"expected at least 3 review annotations after splice, got {review_count} (labels={labels:?})"
);
}
#[test]
fn missing_src_parameter_surfaces_specific_error() {
let result = fixture(":: lex.include ::\n", &[]);
assert_err_kind!(result, IncludeError::MissingSrc { .. });
}
#[test]
fn invariant_origin_paths_are_stamped_for_entry_and_included_files() {
let tree = fixture(
":: lex.include src=\"chapter.lex\" ::\n",
&[("/repo/chapter.lex", "1. Chapter\n\n Body.\n")],
)
.unwrap();
assert_origins(&tree, &["/repo/main.lex", "/repo/chapter.lex"]);
}
#[test]
fn invariant_no_unresolved_includes_in_any_success_path() {
let cases = [
(":: lex.include src=\"f.lex\" ::\n", "Body.\n"),
(":: lex.include src=\"f.lex\" ::\n", "1. Ch\n\n Body.\n"),
(
":: lex.include src=\"f.lex\" ::\n",
"Title Line\n\n Body.\n",
),
(
":: lex.include src=\"f.lex\" ::\n",
":: meta v=\"1\" ::\n\nBody.\n",
),
];
for (main, frag) in cases {
let tree = fixture(main, &[("/repo/f.lex", frag)])
.unwrap_or_else(|e| panic!("fixture failed for case {main:?}/{frag:?}: {e:?}"));
assert_no_unresolved_includes(&tree);
}
}
#[test]
fn invariant_path_resolution_normalizes_dotdot_within_root() {
let tree = fixture_at(
"/repo/pages/host.lex",
":: lex.include src=\"../shared/foo.lex\" ::\n",
&[("/repo/shared/foo.lex", "Foo body.\n")],
)
.unwrap();
assert!(tree.root_paragraph_texts().iter().any(|t| t == "Foo body."));
assert_origins(&tree, &["/repo/pages/host.lex", "/repo/shared/foo.lex"]);
}
#[test]
fn invariant_resolved_tree_satisfies_container_policy() {
let tree = fixture(
"1. Part\n\n :: lex.include src=\"x.lex\" ::\n",
&[("/repo/x.lex", "1.1 Sub\n\n Body.\n")],
)
.unwrap();
assert!(tree.find_session("1.1 Sub").is_some());
}
#[test]
fn invariant_unrelated_annotations_in_included_file_keep_their_attachment_targets() {
let tree = fixture(
":: lex.include src=\"chapter.lex\" ::\n",
&[(
"/repo/chapter.lex",
"1. Chapter\n\n :: note :: Important.\n\n The body.\n",
)],
)
.unwrap();
let labels = tree.all_attached_annotation_labels();
assert!(
labels.iter().any(|l| l == "note"),
"note annotation should still be attached after splice, got {labels:?}"
);
}
#[test]
fn recursion_resolves_includes_inside_included_files() {
let tree = fixture(
":: lex.include src=\"outer.lex\" ::\n",
&[
(
"/repo/outer.lex",
"1. Outer\n\n :: lex.include src=\"inner.lex\" ::\n",
),
("/repo/inner.lex", "Inner body.\n"),
],
)
.unwrap();
let outer = tree.find_session("1. Outer").expect("outer missing");
let inner_paragraph_present = outer
.children
.iter()
.any(|item| matches!(item, ContentItem::Paragraph(p) if p.text() == "Inner body."));
assert!(
inner_paragraph_present,
"inner.lex body should be spliced inside outer session, got children: {:?}",
outer
.children
.iter()
.map(|i| i.node_type())
.collect::<Vec<_>>()
);
assert_no_unresolved_includes(&tree);
assert_origins(
&tree,
&["/repo/main.lex", "/repo/outer.lex", "/repo/inner.lex"],
);
}
#[test]
fn recursion_uses_each_files_own_host_dir() {
let tree = fixture(
":: lex.include src=\"sections/chapter.lex\" ::\n",
&[
(
"/repo/sections/chapter.lex",
"1. Chapter\n\n :: lex.include src=\"./fragment.lex\" ::\n",
),
("/repo/sections/fragment.lex", "Fragment body.\n"),
],
)
.unwrap();
let chapter = tree.find_session("1. Chapter").expect("chapter missing");
assert!(chapter
.children
.iter()
.any(|item| { matches!(item, ContentItem::Paragraph(p) if p.text() == "Fragment body.") }));
assert_origins(
&tree,
&[
"/repo/main.lex",
"/repo/sections/chapter.lex",
"/repo/sections/fragment.lex",
],
);
}
#[test]
fn cycle_direct_self_reference_errors() {
let result = fixture(
":: lex.include src=\"a.lex\" ::\n",
&[("/repo/a.lex", ":: lex.include src=\"a.lex\" ::\n")],
);
let err = assert_err_kind!(result, IncludeError::Cycle { .. });
if let IncludeError::Cycle { path, chain, .. } = err {
assert_eq!(path, PathBuf::from("/repo/a.lex"));
assert!(chain.iter().any(|p| *p == PathBuf::from("/repo/a.lex")));
}
}
#[test]
fn cycle_indirect_through_intermediate_errors() {
let result = fixture(
":: lex.include src=\"a.lex\" ::\n",
&[
("/repo/a.lex", ":: lex.include src=\"b.lex\" ::\n"),
("/repo/b.lex", ":: lex.include src=\"a.lex\" ::\n"),
],
);
let err = assert_err_kind!(result, IncludeError::Cycle { .. });
if let IncludeError::Cycle { chain, .. } = err {
assert!(chain.iter().any(|p| *p == PathBuf::from("/repo/a.lex")));
assert!(chain.iter().any(|p| *p == PathBuf::from("/repo/b.lex")));
}
}
#[test]
fn cycle_back_to_entry_errors() {
let result = fixture(
":: lex.include src=\"a.lex\" ::\n",
&[("/repo/a.lex", ":: lex.include src=\"main.lex\" ::\n")],
);
let err = assert_err_kind!(result, IncludeError::Cycle { .. });
if let IncludeError::Cycle { path, .. } = err {
assert_eq!(path, PathBuf::from("/repo/main.lex"));
}
}
#[test]
fn depth_limit_triggers_at_configured_threshold() {
let mut loader = MemoryLoader::new();
loader.insert("/repo/main.lex", ":: lex.include src=\"a.lex\" ::\n");
loader.insert("/repo/a.lex", ":: lex.include src=\"b.lex\" ::\n");
loader.insert("/repo/b.lex", ":: lex.include src=\"c.lex\" ::\n");
loader.insert("/repo/c.lex", ":: lex.include src=\"d.lex\" ::\n");
loader.insert("/repo/d.lex", "Leaf body.\n");
let config = ResolveConfig {
root: PathBuf::from(TEST_ROOT),
max_depth: 3,
max_total_includes: ResolveConfig::DEFAULT_MAX_TOTAL_INCLUDES,
};
let result = resolve_from_source(
":: lex.include src=\"a.lex\" ::\n",
Some(PathBuf::from(DEFAULT_MAIN_PATH)),
&config,
&loader,
);
let err = assert_err_kind!(result, IncludeError::DepthExceeded { .. });
if let IncludeError::DepthExceeded { limit, chain, .. } = err {
assert_eq!(limit, 3);
assert_eq!(chain.len(), 4);
}
}
#[test]
fn depth_limit_at_exact_max_is_allowed() {
let mut loader = MemoryLoader::new();
loader.insert("/repo/main.lex", ":: lex.include src=\"a.lex\" ::\n");
loader.insert("/repo/a.lex", ":: lex.include src=\"b.lex\" ::\n");
loader.insert("/repo/b.lex", "Leaf.\n");
let config = ResolveConfig {
root: PathBuf::from(TEST_ROOT),
max_depth: 2,
max_total_includes: ResolveConfig::DEFAULT_MAX_TOTAL_INCLUDES,
};
let doc = resolve_from_source(
":: lex.include src=\"a.lex\" ::\n",
Some(PathBuf::from(DEFAULT_MAIN_PATH)),
&config,
&loader,
)
.expect("exact-max chain should succeed");
let tree = Tree { doc };
assert!(tree.root_paragraph_texts().iter().any(|t| t == "Leaf."));
}
#[test]
fn invariant_recursion_preserves_origin_per_file() {
let tree = fixture(
":: lex.include src=\"a.lex\" ::\n",
&[
(
"/repo/a.lex",
"1. From A\n\n :: lex.include src=\"b.lex\" ::\n",
),
("/repo/b.lex", "B body.\n"),
],
)
.unwrap();
assert_origins(&tree, &["/repo/main.lex", "/repo/a.lex", "/repo/b.lex"]);
}
#[test]
fn invariant_sibling_includes_in_loaded_file_share_chain_state() {
let tree = fixture(
":: lex.include src=\"agg.lex\" ::\n",
&[
(
"/repo/agg.lex",
":: lex.include src=\"a.lex\" ::\n\n:: lex.include src=\"b.lex\" ::\n",
),
("/repo/a.lex", "Body A.\n"),
("/repo/b.lex", "Body B.\n"),
],
)
.unwrap();
let texts = tree.root_paragraph_texts();
assert!(texts.iter().any(|t| t == "Body A."), "{texts:?}");
assert!(texts.iter().any(|t| t == "Body B."), "{texts:?}");
}
#[test]
fn cycle_back_to_unnormalized_entry_path_still_detected() {
let mut loader = MemoryLoader::new();
loader.insert("/repo/main.lex", ":: lex.include src=\"a.lex\" ::\n");
loader.insert("/repo/a.lex", ":: lex.include src=\"main.lex\" ::\n");
let config = ResolveConfig::with_root(PathBuf::from(TEST_ROOT));
let result = resolve_from_source(
":: lex.include src=\"a.lex\" ::\n",
Some(PathBuf::from("/repo/./main.lex")),
&config,
&loader,
);
assert_err_kind!(result, IncludeError::Cycle { .. });
}
#[test]
fn invariant_nested_resolution_leaves_no_unresolved_includes() {
let tree = fixture(
":: lex.include src=\"outer.lex\" ::\n",
&[
(
"/repo/outer.lex",
"1. Outer\n\n :: lex.include src=\"inner.lex\" ::\n",
),
("/repo/inner.lex", "Inner body.\n"),
],
)
.unwrap();
assert_no_unresolved_includes(&tree);
}
#[test]
fn invariant_multiple_inclusions_of_same_file_do_not_collide() {
let tree = fixture(
":: lex.include src=\"chapter.lex\" ::\n\n:: lex.include src=\"chapter.lex\" ::\n",
&[("/repo/chapter.lex", "1. Chapter\n\n Body.\n")],
)
.unwrap();
let titles = tree.root_session_titles();
let chapter_count = titles.iter().filter(|t| t.as_str() == "1. Chapter").count();
assert_eq!(
chapter_count, 2,
"expected two copies of '1. Chapter', got {titles:?}"
);
assert_origins(&tree, &["/repo/main.lex", "/repo/chapter.lex"]);
}
#[test]
fn find_annotation_by_label_in_origin_filters_to_origin() {
let tree = fixture(
":: 1 :: Main's footnote.\n\n:: lex.include src=\"chapter.lex\" ::\n",
&[(
"/repo/chapter.lex",
"1. Chapter\n\n A para.\n\n :: 1 :: Chapter's footnote.\n",
)],
)
.unwrap();
let main_origin = std::path::Path::new("/repo/main.lex");
let chapter_origin = std::path::Path::new("/repo/chapter.lex");
let main_one = tree
.doc
.find_annotation_by_label_in_origin("1", Some(main_origin))
.expect("main's :: 1 :: missing");
let chapter_one = tree
.doc
.find_annotation_by_label_in_origin("1", Some(chapter_origin))
.expect("chapter's :: 1 :: missing");
assert!(
!std::ptr::eq(main_one, chapter_one),
"per-origin lookup returned the same annotation for both origins"
);
}
#[test]
fn find_annotation_by_label_in_origin_finds_attached_on_list_table_verbatim() {
let tree = fixture(
":: my_list_note ::\n\n\
- item one\n\
- item two\n\n\
:: my_table_note ::\n\n\
A table:\n\
| a | b |\n\
| c | d |\n\
:: table ::\n\n\
:: my_verbatim_note ::\n\n\
Some code:\n\
let x = 1;\n\
:: rust ::\n",
&[],
)
.unwrap();
let origin = std::path::Path::new("/repo/main.lex");
for label in ["my_list_note", "my_table_note", "my_verbatim_note"] {
assert!(
tree.doc
.find_annotation_by_label_in_origin(label, Some(origin))
.is_some(),
"origin-aware lookup missed {label:?} attached to its container — \
walker must check .annotations on List/Table/VerbatimBlock too"
);
}
}
#[test]
fn find_annotation_by_label_in_origin_returns_none_when_no_match() {
let tree = fixture(":: 1 :: Only one.\n\nA para.\n", &[]).unwrap();
let chapter_origin = std::path::Path::new("/repo/chapter.lex");
assert!(tree
.doc
.find_annotation_by_label_in_origin("1", Some(chapter_origin))
.is_none());
}
#[test]
fn find_annotation_by_label_in_origin_handles_none_origin() {
let mut loader = MemoryLoader::new();
loader.insert("/repo/main.lex", ":: 1 :: Top-level note.\n\nA para.\n");
let config = ResolveConfig::with_root(PathBuf::from(TEST_ROOT));
let doc = resolve_from_source(
":: 1 :: Top-level note.\n\nA para.\n",
None, &config,
&loader,
)
.unwrap();
assert!(doc.find_annotation_by_label_in_origin("1", None).is_some());
}
#[test]
fn resolve_file_reference_uses_ref_origin_for_relative_paths() {
let result = resolve_file_reference(
"./figure.png",
Some(std::path::Path::new("/repo/chapter.lex")),
std::path::Path::new("/repo"),
)
.unwrap();
assert_eq!(result, PathBuf::from("/repo/figure.png"));
}
#[test]
fn resolve_file_reference_handles_root_absolute() {
let result = resolve_file_reference(
"/shared/logo.svg",
Some(std::path::Path::new("/repo/chapters/c1.lex")),
std::path::Path::new("/repo"),
)
.unwrap();
assert_eq!(result, PathBuf::from("/repo/shared/logo.svg"));
}
#[test]
fn resolve_file_reference_falls_back_to_root_when_origin_missing() {
let result = resolve_file_reference("figure.png", None, std::path::Path::new("/repo")).unwrap();
assert_eq!(result, PathBuf::from("/repo/figure.png"));
}
#[test]
fn resolve_file_reference_rejects_root_escape() {
let result = resolve_file_reference(
"../../etc/passwd",
Some(std::path::Path::new("/repo/pages/host.lex")),
std::path::Path::new("/repo"),
);
assert_err_kind!(result, IncludeError::RootEscape { .. });
}
#[test]
fn invariant_resolve_file_reference_matches_include_path_resolution() {
let tree = fixture_at(
"/repo/pages/host.lex",
":: lex.include src=\"../shared/inc.lex\" ::\n",
&[("/repo/shared/inc.lex", "Body.\n")],
)
.unwrap();
let origins = tree.distinct_origin_paths();
assert!(origins.contains(&Some(PathBuf::from("/repo/shared/inc.lex"))));
let computed = resolve_file_reference(
"../shared/inc.lex",
Some(std::path::Path::new("/repo/pages/host.lex")),
std::path::Path::new("/repo"),
)
.unwrap();
assert_eq!(computed, PathBuf::from("/repo/shared/inc.lex"));
}
#[test]
fn resolve_config_default_depth() {
let cfg = ResolveConfig::with_root(PathBuf::from("/x"));
assert_eq!(cfg.max_depth, 8);
assert_eq!(ResolveConfig::DEFAULT_MAX_DEPTH, 8);
}
#[test]
fn memory_loader_returns_inserted_files() {
let loader = MemoryLoader::from_pairs([
(PathBuf::from("/a.lex"), "Aaa\n"),
(PathBuf::from("/b.lex"), "Bbb\n"),
]);
use std::path::Path;
let a = loader.load(Path::new("/a.lex")).unwrap();
assert_eq!(a.source, "Aaa\n");
assert_eq!(a.canonical_path, PathBuf::from("/a.lex"));
let b = loader.load(Path::new("/b.lex")).unwrap();
assert_eq!(b.source, "Bbb\n");
assert_eq!(b.canonical_path, PathBuf::from("/b.lex"));
}
#[test]
fn memory_loader_missing_returns_not_found() {
use std::path::Path;
let loader = MemoryLoader::new();
match loader.load(Path::new("/missing.lex")) {
Err(LoadError::NotFound { path }) => assert_eq!(path, PathBuf::from("/missing.lex")),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn errors_format_with_relevant_paths() {
let cycle = IncludeError::Cycle {
include_site: Range::default(),
path: PathBuf::from("/a.lex"),
chain: vec![PathBuf::from("/main.lex"), PathBuf::from("/a.lex")],
};
let s = cycle.to_string();
assert!(s.contains("/a.lex"));
assert!(s.contains("/main.lex"));
let depth = IncludeError::DepthExceeded {
include_site: Range::default(),
limit: 8,
chain: vec![PathBuf::from("/main.lex"), PathBuf::from("/a.lex")],
};
let s = depth.to_string();
assert!(s.contains("8"));
assert!(s.contains("/main.lex"));
let escape = IncludeError::RootEscape {
path: PathBuf::from("/etc/passwd"),
root: PathBuf::from("/project"),
};
let s = escape.to_string();
assert!(s.contains("/etc/passwd"));
assert!(s.contains("/project"));
let policy = IncludeError::ContainerPolicy {
include_site: Range::default(),
container: "Definition",
file: PathBuf::from("/chapter.lex"),
violation: "Sessions",
};
let s = policy.to_string();
assert!(s.contains("Definition"));
assert!(s.contains("/chapter.lex"));
assert!(s.contains("Sessions"));
assert!(s.contains("does not allow Sessions"));
}
use tempfile::TempDir;
fn canonical_tempdir() -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().expect("tempdir");
let canonical = std::fs::canonicalize(dir.path()).expect("canonicalize tempdir");
(dir, canonical)
}
#[test]
fn fsloader_reads_regular_file_under_root() {
let (_dir, root) = canonical_tempdir();
let target = root.join("legit.lex");
std::fs::write(&target, "Body\n").unwrap();
let loader = FsLoader::new(root.clone());
let loaded = loader.load(&target).expect("legit file under root loads");
assert_eq!(loaded.source, "Body\n");
assert_eq!(loaded.canonical_path, target);
}
#[test]
fn fsloader_missing_file_returns_not_found() {
let (_dir, root) = canonical_tempdir();
let loader = FsLoader::new(root.clone());
let target = root.join("does-not-exist.lex");
let err = loader.load(&target).expect_err("missing file should error");
assert!(matches!(err, LoadError::NotFound { .. }));
}
#[test]
fn fsloader_directory_target_is_rejected() {
let (_dir, root) = canonical_tempdir();
let sub = root.join("not-a-file");
std::fs::create_dir(&sub).unwrap();
let loader = FsLoader::new(root.clone());
let err = loader.load(&sub).expect_err("directory should not load");
match err {
LoadError::Io { message, .. } => assert!(
message.contains("regular file"),
"directory should be rejected as non-regular file, got: {message}"
),
other => panic!("expected Io with not-a-regular-file message, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn fsloader_rejects_symlink_pointing_outside_root() {
let (_root_dir, root) = canonical_tempdir();
let (_outside_dir, outside) = canonical_tempdir();
let secret = outside.join("secret.lex");
std::fs::write(&secret, "STOLEN\n").unwrap();
let link = root.join("sneaky.lex");
std::os::unix::fs::symlink(&secret, &link).unwrap();
let loader = FsLoader::new(root.clone());
let err = loader
.load(&link)
.expect_err("symlink to file outside root must be rejected");
match err {
LoadError::OutsideRoot { path, root: r } => {
assert_eq!(
path, secret,
"error reports the canonical out-of-root target"
);
assert_eq!(r, root, "error reports the canonical root");
}
other => panic!("expected OutsideRoot, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn fsloader_accepts_symlink_within_root() {
let (_dir, root) = canonical_tempdir();
let real = root.join("real.lex");
std::fs::write(&real, "Body\n").unwrap();
let link = root.join("link.lex");
std::os::unix::fs::symlink(&real, &link).unwrap();
let loader = FsLoader::new(root.clone());
let loaded = loader
.load(&link)
.expect("symlink within root should resolve");
assert_eq!(loaded.source, "Body\n");
assert_eq!(loaded.canonical_path, real);
}
#[cfg(unix)]
#[test]
fn fsloader_rejects_fifo_special_file() {
use std::ffi::CString;
let (_dir, root) = canonical_tempdir();
let fifo = root.join("named-pipe.lex");
let cpath = CString::new(fifo.as_os_str().to_str().unwrap()).unwrap();
let rc = unsafe { libc_mkfifo(cpath.as_ptr(), 0o644) };
assert_eq!(rc, 0, "mkfifo failed");
let loader = FsLoader::new(root.clone());
let err = loader.load(&fifo).expect_err("FIFO must be rejected");
match err {
LoadError::Io { message, .. } => assert!(
message.contains("regular file"),
"FIFO should be rejected as non-regular file, got: {message}"
),
other => panic!("expected Io non-regular-file, got {other:?}"),
}
}
#[cfg(unix)]
extern "C" {
#[link_name = "mkfifo"]
fn libc_mkfifo(path: *const std::os::raw::c_char, mode: u32) -> std::os::raw::c_int;
}
#[test]
fn cycle_detection_uses_canonical_path_from_loader() {
struct CaseFoldLoader;
impl Loader for CaseFoldLoader {
fn load(&self, path: &std::path::Path) -> Result<LoadedFile, LoadError> {
let canonical = lowercase_name(path);
let source = match canonical.to_str().unwrap() {
"/repo/a.lex" => ":: lex.include src=\"A.lex\" ::\n".to_string(),
_ => {
return Err(LoadError::NotFound {
path: path.to_path_buf(),
})
}
};
Ok(LoadedFile {
source,
canonical_path: canonical,
})
}
}
fn lowercase_name(p: &std::path::Path) -> std::path::PathBuf {
let parent = p.parent().unwrap_or_else(|| std::path::Path::new(""));
let name = p.file_name().unwrap().to_str().unwrap().to_lowercase();
parent.join(name)
}
let cfg = ResolveConfig::with_root(PathBuf::from("/repo"));
let entry = "1. Top\n\n :: lex.include src=\"A.lex\" ::\n";
let result = resolve_from_source(
entry,
Some(PathBuf::from("/repo/main.lex")),
&cfg,
&CaseFoldLoader,
);
match result.expect_err("case-folded self-include must error") {
IncludeError::Cycle { .. } => {}
IncludeError::DepthExceeded { .. } => panic!(
"case-folded re-include should be caught as Cycle, not DepthExceeded — \
cycle detection isn't using canonical_path from the loader"
),
other => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
fn total_includes_limit_caps_breadth() {
let mut loader = MemoryLoader::new();
loader.insert("/repo/leaf.lex", "Body.\n");
let host = "1. Host\n\n :: lex.include src=\"leaf.lex\" ::\n\n :: lex.include src=\"leaf.lex\" ::\n\n :: lex.include src=\"leaf.lex\" ::\n\n :: lex.include src=\"leaf.lex\" ::\n\n :: lex.include src=\"leaf.lex\" ::\n";
loader.insert("/repo/main.lex", host);
let config = ResolveConfig {
root: PathBuf::from(TEST_ROOT),
max_depth: ResolveConfig::DEFAULT_MAX_DEPTH,
max_total_includes: 3,
};
let result = resolve_from_source(
host,
Some(PathBuf::from(DEFAULT_MAIN_PATH)),
&config,
&loader,
);
match result.expect_err("breadth past total limit must error") {
IncludeError::TotalIncludesExceeded { limit, .. } => {
assert_eq!(limit, 3, "error reports the configured limit");
}
other => panic!("expected TotalIncludesExceeded, got {other:?}"),
}
}
#[test]
fn total_includes_limit_only_counts_successful_loads() {
let mut loader = MemoryLoader::new();
loader.insert("/repo/leaf.lex", "Body.\n");
let host = "1. Host\n\n :: lex.include src=\"missing.lex\" ::\n";
loader.insert("/repo/main.lex", host);
let config = ResolveConfig {
root: PathBuf::from(TEST_ROOT),
max_depth: 8,
max_total_includes: 5,
};
let result = resolve_from_source(
host,
Some(PathBuf::from(DEFAULT_MAIN_PATH)),
&config,
&loader,
);
match result.expect_err("missing include must surface NotFound") {
IncludeError::NotFound { .. } => {}
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn fsloader_rejects_file_exceeding_max_size() {
let (_dir, root) = canonical_tempdir();
let target = root.join("oversize.lex");
let body = "x".repeat(2 * 1024); std::fs::write(&target, &body).unwrap();
let loader = FsLoader::new(root.clone()).with_max_file_size(1024);
match loader
.load(&target)
.expect_err("file over limit must be rejected")
{
LoadError::TooLarge { size, limit, .. } => {
assert_eq!(size, 2048, "reports actual size");
assert_eq!(limit, 1024, "reports configured limit");
}
other => panic!("expected TooLarge, got {other:?}"),
}
}
#[test]
fn fsloader_accepts_small_file_under_default_limit() {
let (_dir, root) = canonical_tempdir();
let target = root.join("small.lex");
std::fs::write(&target, "Body.\n").unwrap();
let loader = FsLoader::new(root.clone());
let loaded = loader.load(&target).expect("small file loads");
assert_eq!(loaded.source, "Body.\n");
}
#[test]
fn file_too_large_carries_include_site() {
struct AlwaysTooLargeLoader;
impl Loader for AlwaysTooLargeLoader {
fn load(&self, path: &std::path::Path) -> Result<LoadedFile, LoadError> {
Err(LoadError::TooLarge {
path: path.to_path_buf(),
size: 100,
limit: 50,
})
}
}
let cfg = ResolveConfig::with_root(PathBuf::from("/repo"));
let entry = ":: lex.include src=\"big.lex\" ::\n";
let result = resolve_from_source(
entry,
Some(PathBuf::from("/repo/main.lex")),
&cfg,
&AlwaysTooLargeLoader,
);
match result.expect_err("too-large include must error") {
IncludeError::FileTooLarge {
include_site,
path,
size,
limit,
} => {
assert_eq!(size, 100);
assert_eq!(limit, 50);
assert_eq!(path, PathBuf::from("/repo/big.lex"));
assert_ne!(include_site, crate::lex::ast::Range::default());
}
other => panic!("expected FileTooLarge, got {other:?}"),
}
}
#[test]
fn root_absolute_leading_slash_still_works() {
let mut loader = MemoryLoader::new();
loader.insert("/repo/leaf.lex", "Body.\n");
loader.insert("/repo/main.lex", ":: lex.include src=\"/leaf.lex\" ::\n");
let cfg = ResolveConfig::with_root(PathBuf::from("/repo"));
let _doc = resolve_from_source(
":: lex.include src=\"/leaf.lex\" ::\n",
Some(PathBuf::from("/repo/main.lex")),
&cfg,
&loader,
)
.expect("root-absolute (leading /) include must still resolve");
}
#[cfg(windows)]
#[test]
fn windows_absolute_path_is_rejected_up_front() {
let cfg = ResolveConfig::with_root(PathBuf::from("C:\\repo"));
let loader = MemoryLoader::new();
let result = resolve_from_source(
":: lex.include src=\"C:\\\\secret.txt\" ::\n",
Some(PathBuf::from("C:\\repo\\main.lex")),
&cfg,
&loader,
);
match result.expect_err("Windows-absolute src must be rejected") {
IncludeError::AbsolutePath { path } => {
assert_eq!(path, PathBuf::from("C:\\secret.txt"));
}
other => panic!("expected AbsolutePath, got {other:?}"),
}
}
#[test]
fn absolute_path_error_message_is_actionable() {
let err = IncludeError::AbsolutePath {
path: PathBuf::from("C:\\secret.txt"),
};
let s = err.to_string();
assert!(s.contains("C:\\secret.txt"), "names the offending path");
assert!(
s.contains("relative") && s.contains("root-absolute"),
"points the user at the two spec-allowed shapes; got: {s}"
);
}