use crate::{AttributePart, Attributes, Inline, Xref};
use std::collections::BTreeMap;
pub(crate) const ASCIIDOC_EXTENSIONS: [&str; 5] = [".adoc", ".asciidoc", ".asc", ".ad", ".txt"];
pub(crate) type Includes = BTreeMap<String, bool>;
#[derive(Clone, Copy)]
pub(crate) struct Scope<'a> {
pub includes: &'a Includes,
pub outfilesuffix: Option<&'a str>,
}
pub(crate) fn register_include(includes: &mut Includes, key: String, full: bool) {
*includes.entry(key).or_insert(false) |= full;
}
pub(crate) fn include_key(document: &str, include: &str) -> String {
let relative = relative_path(document, include);
match relative.rfind('.') {
Some(dot) => relative[..dot].to_owned(),
None => relative,
}
}
pub(crate) fn relative_path(document: &str, include: &str) -> String {
let base: Vec<_> = document
.rsplit_once('/')
.map_or("", |(dir, _)| dir)
.split('/')
.filter(|s| !s.is_empty())
.collect();
let path: Vec<_> = include.split('/').filter(|s| !s.is_empty()).collect();
let common = base.iter().zip(&path).take_while(|(a, b)| a == b).count();
let mut relative: Vec<&str> = vec![".."; base.len() - common];
relative.extend(&path[common..]);
relative.join("/")
}
fn extname(path: &str) -> bool {
path.rfind('.')
.is_some_and(|dot| !path[dot..].contains('/'))
}
pub(crate) fn resolve(
refid: &str,
spelled: &str,
macro_form: bool,
compat: bool,
attributes: &Attributes,
scope: Option<Scope<'_>>,
) -> Xref {
let mut path = None;
let mut fragment = None;
let mut src2src = false;
let mut explicit = None;
let entity = |text: &str| {
text.find('#').is_some_and(|i| {
if i == 0 {
text.ends_with('&')
} else {
text[..i].ends_with('&')
}
})
};
let hash = refid
.find('#')
.filter(|_| !entity(refid) && !entity(spelled));
if compat {
fragment = Some(refid.to_owned());
} else if let Some(hash) = hash {
if hash > 0 {
let mut name = &refid[..hash];
if hash + 1 < refid.len() {
fragment = Some(refid[hash + 1..].to_owned());
}
if macro_form {
if let Some(stem) = name.strip_suffix(".adoc") {
name = stem;
src2src = true;
} else {
src2src = !extname(name);
}
} else {
if ASCIIDOC_EXTENSIONS.iter().any(|ext| name.ends_with(ext)) {
name = &name[..name.rfind('.').unwrap()];
}
src2src = true;
}
path = Some(name.to_owned());
} else {
explicit = Some(refid.to_owned());
fragment = Some(refid[1..].to_owned());
}
} else if macro_form {
if let Some(stem) = refid.strip_suffix(".adoc") {
src2src = true;
path = Some(stem.to_owned());
} else if extname(refid) {
path = Some(refid.to_owned());
} else {
fragment = Some(refid.to_owned());
}
} else {
fragment = Some(refid.to_owned());
}
if let Some(target) = explicit {
return Xref {
target,
refid: fragment.clone(),
fragment,
..Default::default()
};
}
let Some(name) = path else {
let fragment = fragment.unwrap_or_default();
return Xref {
natural: (!compat).then(|| fragment.clone()),
..Xref::to_id(fragment)
};
};
let included = scope.is_some_and(|scope| scope.includes.get(&name) == Some(&true));
if src2src && (attributes.get("docname") == Some(name.as_str()) || included) {
return match fragment {
Some(fragment) => Xref::to_id(fragment),
None => Xref {
target: "#".into(),
..Default::default()
},
};
}
let suffix = if src2src {
attributes
.get("relfilesuffix")
.or(scope.and_then(|scope| scope.outfilesuffix))
.unwrap_or("")
} else {
""
};
let output = format!(
"{}{name}{suffix}",
attributes.get("relfileprefix").unwrap_or("")
);
match fragment {
Some(fragment) => Xref {
target: format!("{output}#{fragment}"),
refid: Some(format!("{name}#{fragment}")),
path: Some(output),
fragment: Some(fragment),
..Default::default()
},
None => Xref {
target: output.clone(),
refid: Some(name),
path: Some(output),
..Default::default()
},
}
}
pub(crate) fn looks_like_text(fragment: &str) -> bool {
fragment.contains(' ') || fragment.chars().any(|c| c.to_lowercase().ne(Some(c)))
}
pub(crate) fn retarget(xref: &mut Xref, id: &str, lexical: bool) {
let target = if lexical { protect(id) } else { id.to_owned() };
xref.target = format!("#{target}");
xref.refid = Some(target.clone());
xref.fragment = Some(target);
}
pub(crate) fn protect(value: &str) -> String {
crate::attributes::reference_text(&[AttributePart::Text(value.to_owned())])
}
pub(crate) fn lexical_label(nodes: &[Inline]) -> Option<String> {
fn plain(nodes: &[Inline]) -> bool {
nodes.iter().all(|node| match node {
Inline::Link { .. } | Inline::CrossReference { .. } | Inline::Anchor { .. } => false,
Inline::Strong { children, .. }
| Inline::Emphasis { children, .. }
| Inline::Monospace { children, .. }
| Inline::Mark { children, .. }
| Inline::Superscript { children, .. }
| Inline::Subscript { children, .. } => plain(children),
Inline::Substituted { parts, .. } | Inline::AttributeRef { parts, .. } => {
!parts.iter().any(|part| {
matches!(
part,
AttributePart::OpenLink(_)
| AttributePart::OpenXref(_)
| AttributePart::Anchor(_)
)
})
}
_ => true,
})
}
plain(nodes).then(|| crate::attributes::reference_text(&crate::resolve::label_parts(nodes)))
}
#[cfg(test)]
mod tests {
use super::*;
fn xref(refid: &str, macro_form: bool) -> Xref {
let mut attributes = Attributes::new();
attributes.set("docname", "main");
let includes = Includes::from([("ch/full".into(), true), ("part".into(), false)]);
resolve(
refid,
refid,
macro_form,
false,
&attributes,
Some(Scope {
includes: &includes,
outfilesuffix: Some(".html"),
}),
)
}
#[test]
fn interdocument_paths_follow_the_macro_form() {
let x = xref("other.adoc#frag", false);
assert_eq!(x.target, "other.html#frag");
assert_eq!(x.refid.as_deref(), Some("other#frag"));
assert_eq!(x.path.as_deref(), Some("other.html"));
assert_eq!(x.fragment.as_deref(), Some("frag"));
assert_eq!(xref("other.md#x", false).target, "other.md.html#x");
assert_eq!(xref("other.md#x", true).target, "other.md#x");
assert_eq!(xref("other.adoc", true).target, "other.html");
assert_eq!(xref("other.html", true).target, "other.html");
assert_eq!(xref("other#", true).target, "other.html");
assert_eq!(xref("dir/x.y/z#a", true).target, "dir/x.y/z.html#a");
let x = xref("other.adoc", false);
assert_eq!(
(x.target.as_str(), x.natural.is_some()),
("#other.adoc", true)
);
let x = xref("other", true);
assert_eq!((x.target.as_str(), x.path), ("#other", None));
}
#[test]
fn this_document_and_full_includes_stay_internal() {
let x = xref("main.adoc#top", false);
assert_eq!(
(x.target.as_str(), x.path, x.natural.is_some()),
("#top", None, false)
);
let x = xref("main.adoc", true);
assert_eq!((x.target.as_str(), x.refid), ("#", None));
assert_eq!(xref("ch/full.adoc#s", true).target, "#s");
assert_eq!(xref("part.adoc#s", true).target, "part.html#s");
let x = xref("#", false);
assert_eq!((x.target.as_str(), x.refid.as_deref()), ("#", Some("")));
}
#[test]
fn character_references_do_not_split_paths() {
assert_eq!(xref("a&b", true).target, "#a&b");
assert_eq!(xref("#x&", false).target, "##x&");
}
#[test]
fn prefix_and_suffix_attributes_apply_to_source_paths() {
let mut attributes = Attributes::new();
attributes.set("relfileprefix", "../");
attributes.set("relfilesuffix", "/");
let x = resolve(
"other.adoc#x",
"other.adoc#x",
false,
false,
&attributes,
None,
);
assert_eq!(x.target, "../other/#x");
let x = resolve("file.pdf", "file.pdf", true, false, &attributes, None);
assert_eq!(x.target, "../file.pdf");
let x = resolve(
"other.adoc#x",
"other.adoc#x",
false,
true,
&attributes,
None,
);
assert_eq!(
(x.target.as_str(), x.natural.is_some()),
("#other.adoc#x", false)
);
}
#[test]
fn include_keys_are_relative_to_the_document_directory() {
assert_eq!(include_key("/doc/main.adoc", "/doc/ch/a.adoc"), "ch/a");
assert_eq!(include_key("/doc/main.adoc", "/lib/a.adoc"), "../lib/a");
assert_eq!(
relative_path("/doc/main.adoc", "/lib/a.adoc"),
"../lib/a.adoc"
);
let mut includes = Includes::new();
register_include(&mut includes, "a".into(), false);
register_include(&mut includes, "a".into(), true);
register_include(&mut includes, "a".into(), false);
assert_eq!(includes.get("a"), Some(&true));
}
#[test]
fn natural_text_detection() {
assert!(looks_like_text("Section One"));
assert!(looks_like_text("Title"));
assert!(!looks_like_text("section_one"));
}
}