use lex_core::lex::ast::anchoring::ReferenceAnchor;
use lex_core::lex::ast::elements::inlines::{AnchorDirection, AnchorKind, InlineNode};
use lex_core::lex::ast::elements::Document as LexDocument;
use lex_core::lex::ast::TextContent;
use super::nodes::InlineContent;
pub(crate) fn reference_href(raw: &str) -> String {
raw.to_string()
}
pub(crate) fn apply_word_anchors(
nodes: &[InlineNode],
convert: &dyn Fn(&InlineNode) -> InlineContent,
) -> Vec<InlineContent> {
let mut out: Vec<InlineContent> = Vec::with_capacity(nodes.len());
let mut skip: Option<usize> = None;
for (i, node) in nodes.iter().enumerate() {
if skip == Some(i) {
continue;
}
let InlineNode::Reference { data, .. } = node else {
out.push(convert(node));
continue;
};
let anchorable = data.reference_type.anchoring() == AnchorKind::WholeLineCapable;
let Some(word_anchor) = data.word_anchor.as_ref().filter(|_| anchorable) else {
out.push(convert(node));
continue;
};
let href = reference_href(&data.raw);
match word_anchor.direction {
AnchorDirection::Preceding => {
if !wrap_preceding_word(&mut out, &word_anchor.word, &href) {
out.push(convert(node));
}
}
AnchorDirection::Following => match consume_following_word(nodes, i, &word_anchor.word)
{
Some(consumed) => {
out.push(InlineContent::Link {
text: word_anchor.word.clone(),
href,
});
if !consumed.remainder.is_empty() {
out.push(InlineContent::Text(consumed.remainder));
}
skip = Some(consumed.index);
}
None => out.push(convert(node)),
},
}
}
out
}
fn wrap_preceding_word(out: &mut Vec<InlineContent>, word: &str, href: &str) -> bool {
for idx in (0..out.len()).rev() {
let InlineContent::Text(text) = &out[idx] else {
continue;
};
let trimmed_end = text.trim_end();
if trimmed_end.is_empty() {
continue;
}
let last_space = trimmed_end.rfind(char::is_whitespace);
let (prefix, last_word) = match last_space {
Some(pos) => (&trimmed_end[..=pos], &trimmed_end[pos + 1..]),
None => ("", trimmed_end),
};
if strip_word_punct(last_word) != word {
continue;
}
if last_word.starts_with(|c: char| !c.is_alphanumeric()) {
return false;
}
let prefix = prefix.to_string();
let last_word = last_word.to_string();
let mut replacement: Vec<InlineContent> = Vec::with_capacity(2);
if !prefix.is_empty() {
replacement.push(InlineContent::Text(prefix));
}
replacement.push(InlineContent::Link {
text: last_word,
href: href.to_string(),
});
out.splice(idx..=idx, replacement);
return true;
}
false
}
struct ConsumedFollowing {
index: usize,
remainder: String,
}
fn consume_following_word(
nodes: &[InlineNode],
ref_index: usize,
word: &str,
) -> Option<ConsumedFollowing> {
let j = ref_index + 1;
let InlineNode::Plain { text, .. } = nodes.get(j)? else {
return None;
};
let rest = text.trim_start();
let first_space = rest.find(char::is_whitespace);
let (first_word, suffix) = match first_space {
Some(pos) => (&rest[..pos], &rest[pos..]),
None => (rest, ""),
};
if strip_word_punct(first_word) != word {
return None;
}
let trailing_punct = match first_word.rfind(word) {
Some(pos) => &first_word[pos + word.len()..],
None => "",
};
let remainder = format!("{trailing_punct}{suffix}");
Some(ConsumedFollowing {
index: j,
remainder,
})
}
fn strip_word_punct(word: &str) -> &str {
word.trim_matches(|c: char| !c.is_alphanumeric())
}
#[derive(Debug, Clone)]
pub(crate) struct WholeElementAnchor {
pub span: std::ops::Range<usize>,
pub anchor_text: String,
pub href: String,
}
#[derive(Debug, Clone)]
pub(crate) struct SelfLink {
pub span: std::ops::Range<usize>,
pub raw: String,
}
#[derive(Debug, Default)]
pub(crate) struct AnchorIndex {
whole_element: Vec<WholeElementAnchor>,
self_links: Vec<SelfLink>,
}
impl AnchorIndex {
pub(crate) fn from_document(doc: &LexDocument) -> Self {
let mut whole_element = Vec::new();
let mut self_links = Vec::new();
for line in doc.reference_lines() {
match &line.anchor {
ReferenceAnchor::WholeElement {
anchor_text,
anchor_range,
..
} => {
whole_element.push(WholeElementAnchor {
span: anchor_range.span.clone(),
anchor_text: anchor_text.clone(),
href: reference_href(&line.reference.raw),
});
}
ReferenceAnchor::SelfLink => {
self_links.push(SelfLink {
span: line.reference_range.span.clone(),
raw: line.reference.raw.clone(),
});
}
}
}
Self {
whole_element,
self_links,
}
}
pub(crate) fn is_empty(&self) -> bool {
self.whole_element.is_empty() && self.self_links.is_empty()
}
pub(crate) fn match_head_line(&self, head: &TextContent) -> Option<&WholeElementAnchor> {
let loc = head.location.as_ref()?;
let head_span = &loc.span;
self.whole_element
.iter()
.find(|a| head_span.start <= a.span.start && a.span.end <= head_span.end)
}
pub(crate) fn self_links_in(&self, range: std::ops::Range<usize>) -> Vec<&SelfLink> {
let mut v: Vec<&SelfLink> = self
.self_links
.iter()
.filter(|s| s.span.start >= range.start && s.span.start < range.end)
.collect();
v.sort_by_key(|s| s.span.start);
v
}
}
pub(crate) fn wrap_head_line(anchor: &WholeElementAnchor) -> Vec<InlineContent> {
vec![InlineContent::Link {
text: anchor.anchor_text.clone(),
href: anchor.href.clone(),
}]
}
#[cfg(test)]
mod tests {
use super::*;
use lex_core::lex::ast::elements::inlines::{ReferenceInline, ReferenceType, WordAnchor};
fn convert(node: &InlineNode) -> InlineContent {
match node {
InlineNode::Plain { text, .. } => InlineContent::Text(text.clone()),
InlineNode::Reference { data, .. } => InlineContent::Reference {
raw: data.raw.clone(),
kind: data.reference_type.clone(),
},
other => panic!("unexpected node in test: {other:?}"),
}
}
fn convert_full(node: &InlineNode) -> InlineContent {
match node {
InlineNode::Code { text, .. } => InlineContent::Code(text.clone()),
other => convert(other),
}
}
fn plain(text: &str) -> InlineNode {
InlineNode::plain(text.to_string())
}
fn reference(raw: &str, kind: ReferenceType, anchor: Option<WordAnchor>) -> InlineNode {
let mut data = ReferenceInline::new(raw.to_string());
data.reference_type = kind;
data.word_anchor = anchor;
InlineNode::reference(data)
}
#[test]
fn preceding_word_becomes_link_and_bracket_drops() {
let nodes = vec![
plain("the project website "),
reference(
"https://lex.ing",
ReferenceType::Url {
target: "https://lex.ing".into(),
},
Some(WordAnchor {
word: "website".into(),
direction: AnchorDirection::Preceding,
}),
),
plain(" today"),
];
let out = apply_word_anchors(&nodes, &convert);
assert_eq!(
out,
vec![
InlineContent::Text("the project ".into()),
InlineContent::Link {
text: "website".into(),
href: "https://lex.ing".into(),
},
InlineContent::Text(" today".into()),
]
);
}
#[test]
fn following_word_becomes_link_and_is_removed_from_text() {
let nodes = vec![
reference(
"https://lex.ing",
ReferenceType::Url {
target: "https://lex.ing".into(),
},
Some(WordAnchor {
word: "is".into(),
direction: AnchorDirection::Following,
}),
),
plain(" is the home page."),
];
let out = apply_word_anchors(&nodes, &convert);
assert_eq!(
out,
vec![
InlineContent::Link {
text: "is".into(),
href: "https://lex.ing".into(),
},
InlineContent::Text(" the home page.".into()),
]
);
}
#[test]
fn abutting_preceding_word() {
let nodes = vec![
plain("Hello"),
reference(
"./file.txt",
ReferenceType::File {
target: "./file.txt".into(),
},
Some(WordAnchor {
word: "Hello".into(),
direction: AnchorDirection::Preceding,
}),
),
plain(" World"),
];
let out = apply_word_anchors(&nodes, &convert);
assert_eq!(
out,
vec![
InlineContent::Link {
text: "Hello".into(),
href: "./file.txt".into(),
},
InlineContent::Text(" World".into()),
]
);
}
#[test]
fn marker_reference_keeps_its_bracket_even_with_word_anchor() {
let nodes = vec![
plain("See "),
reference(
"42",
ReferenceType::FootnoteNumber { number: 42 },
Some(WordAnchor {
word: "See".into(),
direction: AnchorDirection::Preceding,
}),
),
plain(" later."),
];
let out = apply_word_anchors(&nodes, &convert);
assert_eq!(
out,
vec![
InlineContent::Text("See ".into()),
InlineContent::Reference {
raw: "42".into(),
kind: ReferenceType::FootnoteNumber { number: 42 },
},
InlineContent::Text(" later.".into()),
]
);
}
#[test]
fn following_word_keeps_trailing_punctuation() {
let nodes = vec![
reference(
"https://x",
ReferenceType::Url {
target: "https://x".into(),
},
Some(WordAnchor {
word: "is".into(),
direction: AnchorDirection::Following,
}),
),
plain(" is, indeed here"),
];
let out = apply_word_anchors(&nodes, &convert);
assert_eq!(
out,
vec![
InlineContent::Link {
text: "is".into(),
href: "https://x".into(),
},
InlineContent::Text(", indeed here".into()),
]
);
}
#[test]
fn preceding_word_in_earlier_text_node_is_found() {
let mut out = vec![
InlineContent::Text("the project website".into()),
InlineContent::Link {
text: "prior".into(),
href: "https://prior".into(),
},
InlineContent::Text("".into()),
];
let found = wrap_preceding_word(&mut out, "website", "https://lex.ing");
assert!(found, "must locate the word in an earlier text node");
assert_eq!(
out,
vec![
InlineContent::Text("the project ".into()),
InlineContent::Link {
text: "website".into(),
href: "https://lex.ing".into(),
},
InlineContent::Link {
text: "prior".into(),
href: "https://prior".into(),
},
InlineContent::Text("".into()),
]
);
}
#[test]
fn preceding_word_not_found_falls_back_to_reference() {
let nodes = vec![
InlineNode::code("website".into()),
reference(
"https://lex.ing",
ReferenceType::Url {
target: "https://lex.ing".into(),
},
Some(WordAnchor {
word: "website".into(),
direction: AnchorDirection::Preceding,
}),
),
];
let out = apply_word_anchors(&nodes, &convert_full);
assert_eq!(
out,
vec![
InlineContent::Code("website".into()),
InlineContent::Reference {
raw: "https://lex.ing".into(),
kind: ReferenceType::Url {
target: "https://lex.ing".into(),
},
},
],
"unfound preceding anchor must fall back to the original reference"
);
}
#[test]
fn preceding_word_with_leading_punctuation_falls_back_to_reference() {
let nodes = vec![
plain("(see "),
reference(
"#6",
ReferenceType::Session {
target: "#6".into(),
},
Some(WordAnchor {
word: "see".into(),
direction: AnchorDirection::Preceding,
}),
),
];
let out = apply_word_anchors(&nodes, &convert);
assert_eq!(
out,
vec![
InlineContent::Text("(see ".into()),
InlineContent::Reference {
raw: "#6".into(),
kind: ReferenceType::Session {
target: "#6".into(),
},
},
],
"guide text with leading punctuation must stay outside the link"
);
}
#[test]
fn unsafe_nearest_preceding_word_does_not_wrap_earlier_match() {
let nodes = vec![
plain("see"),
InlineNode::code(" gap ".into()),
plain("(see "),
reference(
"#6",
ReferenceType::Session {
target: "#6".into(),
},
Some(WordAnchor {
word: "see".into(),
direction: AnchorDirection::Preceding,
}),
),
];
let out = apply_word_anchors(&nodes, &convert_full);
assert_eq!(
out,
vec![
InlineContent::Text("see".into()),
InlineContent::Code(" gap ".into()),
InlineContent::Text("(see ".into()),
InlineContent::Reference {
raw: "#6".into(),
kind: ReferenceType::Session {
target: "#6".into(),
},
},
],
"unsafe nearest match must fall back instead of wrapping an earlier occurrence"
);
}
#[test]
fn following_non_text_node_falls_back_to_reference() {
let nodes = vec![
reference(
"https://lex.ing",
ReferenceType::Url {
target: "https://lex.ing".into(),
},
Some(WordAnchor {
word: "code".into(),
direction: AnchorDirection::Following,
}),
),
InlineNode::code("code".into()),
];
let out = apply_word_anchors(&nodes, &convert_full);
assert_eq!(
out,
vec![
InlineContent::Reference {
raw: "https://lex.ing".into(),
kind: ReferenceType::Url {
target: "https://lex.ing".into(),
},
},
InlineContent::Code("code".into()),
],
"non-text following node must fall back to the original reference"
);
}
#[test]
fn link_like_reference_without_word_anchor_stays_reference() {
let nodes = vec![reference(
"https://example.com",
ReferenceType::Url {
target: "https://example.com".into(),
},
None,
)];
let out = apply_word_anchors(&nodes, &convert);
assert_eq!(
out,
vec![InlineContent::Reference {
raw: "https://example.com".into(),
kind: ReferenceType::Url {
target: "https://example.com".into(),
},
}]
);
}
}