use crate::config::SiteConfig;
use crate::engine::ast::{Inline, TextRange};
use crate::title::Title;
use crate::warn::{Warning, WarningCollector};
use super::scan::{Step, Token};
pub(super) fn scan_internal(
flat: &str,
pos: usize,
end: usize,
config: &SiteConfig,
warnings: &mut WarningCollector,
depth: u8,
) -> Step {
let inner = pos + 2;
let Some(close_rel) = flat[inner..end].find("]]") else {
return Step::Miss;
};
let first_close = inner + close_rel;
let pipe = flat[inner..first_close].find('|').map(|r| inner + r);
let target_end = pipe.unwrap_or(first_close);
let raw_target = &flat[inner..target_end];
let trimmed = raw_target.trim();
let escaped = trimmed.starts_with(':');
let unescaped = if escaped {
trimmed[1..].trim_start()
} else {
trimmed
};
if let Some((prefix, rest)) = unescaped.split_once(':') {
if config.is_interwiki_prefix(prefix.trim()) {
return Step::Emit(
Token::Node(Inline::InterLanguage {
prefix: prefix.trim().to_owned(),
target: rest.trim().to_owned(),
escaped,
}),
first_close + 2,
);
}
}
let (title_part, fragment) = match unescaped.split_once('#') {
Some((t, f)) => (t, Some(f)),
None => (unescaped, None),
};
if title_part.trim().is_empty() {
if fragment.is_some() || pipe.is_some() {
return Step::Text(inner);
}
warnings.emit(Warning::InvalidLinkTarget {
target: raw_target.to_owned(),
});
return Step::Text(inner);
}
let Ok(title) = Title::parse(title_part, config) else {
warnings.emit(Warning::InvalidLinkTarget {
target: raw_target.to_owned(),
});
return Step::Text(inner);
};
if title.namespace().is_category() && !escaped {
let sortkey = pipe.map(|p| flat[p + 1..first_close].to_owned());
if sortkey.as_deref() == Some("") {
return Step::Text(inner);
}
return Step::Emit(
Token::Node(Inline::Category {
category: title,
sortkey,
}),
first_close + 2,
);
}
if title.namespace().is_file() && !escaped {
return scan_image(flat, end, target_end, title, config, warnings, depth);
}
let label = match pipe {
None => None,
Some(p) if p + 1 == first_close => {
return Step::Text(inner);
}
Some(p) => Some(super::parse_at_depth(
flat,
TextRange::new((p + 1) as u32, first_close as u32),
config,
warnings,
depth + 1,
)),
};
let mut trail_end = first_close + 2;
for c in flat[trail_end..end].chars() {
if !config.trail_accepts(c) {
break;
}
trail_end += c.len_utf8();
}
let trail = if trail_end > first_close + 2 {
Some(TextRange::new((first_close + 2) as u32, trail_end as u32))
} else {
None
};
Step::Emit(
Token::Node(Inline::Link {
target: title,
written: TextRange::new(inner as u32, target_end as u32),
fragment: fragment.map(str::to_owned),
label,
trail,
pipe_trick: false,
}),
trail_end,
)
}
fn scan_image(
flat: &str,
end: usize,
target_end: usize,
target: Title,
config: &SiteConfig,
warnings: &mut WarningCollector,
depth: u8,
) -> Step {
let mut splits: Vec<usize> = Vec::new();
let mut close = None;
let mut nest = 0u32;
let mut i = target_end;
while i < end {
let rest = &flat[i..end];
if rest.starts_with("[[") {
nest += 1;
i += 2;
} else if rest.starts_with("]]") {
if nest == 0 {
close = Some(i);
break;
}
nest -= 1;
i += 2;
} else if nest == 0 && rest.starts_with('|') {
splits.push(i);
i += 1;
} else {
i += utf8_len(flat.as_bytes()[i]);
}
}
let Some(close) = close else {
return Step::Miss;
};
let mut bounds = splits;
bounds.push(close);
let mut params: Vec<TextRange> = Vec::new();
for w in bounds.windows(2) {
params.push(TextRange::new((w[0] + 1) as u32, w[1] as u32));
}
let mut caption = None;
if let Some(&last) = params.last() {
if !recognized_param(last.slice(flat)) {
params.pop();
caption = Some(super::parse_at_depth(
flat,
last,
config,
warnings,
depth + 1,
));
}
}
Step::Emit(
Token::Node(Inline::Image {
target,
params,
caption,
}),
close + 2,
)
}
fn utf8_len(byte: u8) -> usize {
match byte {
b if b < 0x80 => 1,
b if b < 0xe0 => 2,
b if b < 0xf0 => 3,
_ => 4,
}
}
fn recognized_param(raw: &str) -> bool {
const KEYWORDS: &[&str] = &[
"baseline",
"border",
"bottom",
"center",
"frame",
"framed",
"frameless",
"left",
"middle",
"none",
"right",
"sub",
"super",
"text-bottom",
"text-top",
"thumb",
"thumbnail",
"top",
];
const VALUE_KEYS: &[&str] = &[
"alt",
"class",
"lang",
"link",
"page",
"thumb",
"thumbnail",
"upright",
];
let t = raw.trim();
if KEYWORDS.binary_search(&t).is_ok() {
return true;
}
if let Some(size) = t.strip_suffix("px") {
if is_size_spec(size) {
return true;
}
}
match t.split_once('=') {
Some((key, _)) => VALUE_KEYS.binary_search(&key.trim()).is_ok(),
None => false,
}
}
fn is_size_spec(s: &str) -> bool {
let digits = |part: &str| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit());
match s.split_once('x') {
Some((w, h)) => (w.is_empty() || digits(w)) && digits(h),
None => digits(s),
}
}
#[cfg(test)]
mod tests {
use super::super::testutil::{check, dump, parse};
use super::{is_size_spec, recognized_param, utf8_len};
use crate::warn::Warning;
#[test]
fn bare_link() {
check("[[dog]]", "Link(Dog)");
}
#[test]
fn piped_link() {
check("[[a|b]]", "Link(A|Text(b))");
}
#[test]
fn label_is_everything_after_the_first_pipe() {
check("[[a|b|c]]", "Link(A|Text(b|c))");
}
#[test]
fn fragment_splits_before_title_parse() {
check("[[a#s|b]]", "Link(A#s|Text(b))");
check("[[a#s]]", "Link(A#s)");
}
#[test]
fn same_page_fragment_stays_literal_without_warning() {
check("[[#frag]]", "Text([[#frag]])");
check("[[:#frag]]", "Text([[:#frag]])");
}
#[test]
fn link_trail_extends_the_label() {
check("[[dog]]s", "Link(Dog+s)");
check("[[дог]]ами", "Link(Дог+ами)");
}
#[test]
fn non_trail_character_stops_immediately() {
check("[[dog]]Ы", "Link(Dog) Text(Ы)");
}
#[test]
fn category_link() {
check("[[Category:X]]", "Cat(Категория:X)");
}
#[test]
fn category_sortkey_localized() {
check("[[Категория:X|ключ]]", "Cat(Категория:X|ключ)");
}
#[test]
fn category_sortkey_keeps_extra_pipes_raw() {
check("[[Category:X|a|b]]", "Cat(Категория:X|a|b)");
}
#[test]
fn empty_pipe_category_is_literal_text() {
check("a[[Category:X|]]b", "Text(a[[Category:X|]]b)");
check("[[Category:X| ]]", "Cat(Категория:X| )");
}
#[test]
fn escaped_category_is_an_ordinary_link() {
check("[[:Category:X]]", "Link(Категория:X)");
check("[[:Категория:X|метка]]", "Link(Категория:X|Text(метка))");
}
#[test]
fn escaped_main_namespace_link() {
check("[[:dog]]", "Link(Dog)");
}
#[test]
fn image_with_params_and_nested_caption() {
check(
"[[File:X.jpg|thumb|200px|upright=1.2|A [[nested]] caption]]",
"Img(Файл:X.jpg|[thumb,200px,upright=1.2]|Text(A ) Link(Nested) Text( caption))",
);
}
#[test]
fn image_namespace_alias() {
check("[[Image:X.jpg]]", "Img(Файл:X.jpg|[])");
check("[[Изображение:X.jpg]]", "Img(Файл:X.jpg|[])");
}
#[test]
fn recognized_last_segment_is_a_param_not_a_caption() {
check("[[File:X.jpg|thumb]]", "Img(Файл:X.jpg|[thumb])");
}
#[test]
fn unrecognized_last_segment_is_the_caption() {
check(
"[[File:X.jpg|thumb|подпись €😀]]",
"Img(Файл:X.jpg|[thumb]|Text(подпись €😀))",
);
}
#[test]
fn empty_caption_segment_parses_to_nothing() {
check("[[File:X.jpg|thumb|]]", "Img(Файл:X.jpg|[thumb]|)");
}
#[test]
fn escaped_file_is_an_ordinary_link() {
check("[[:File:X.jpg]]", "Link(Файл:X.jpg)");
}
#[test]
fn nested_pipe_does_not_split_image_segments() {
check(
"[[File:X.jpg|cap [[a|b]] tail]]",
"Img(Файл:X.jpg|[]|Text(cap ) Link(A|Text(b)) Text( tail))",
);
}
#[test]
fn unbalanced_image_stays_literal() {
check("[[File:X.jpg|cap [[y]]", "Text([[File:X.jpg|cap ) Link(Y)");
}
#[test]
fn interwiki_link() {
check("[[fr:Chat]]", "Iw(fr:Chat)");
}
#[test]
fn interwiki_prefix_is_case_insensitive() {
check("[[FR:Chat]]", "Iw(FR:Chat)");
}
#[test]
fn escaped_interwiki_is_interlanguage_with_the_escape_flag() {
check("[[:fr:Chat]]", "Iw(:fr:Chat)");
}
#[test]
fn written_keeps_the_target_exactly_as_typed() {
let src = "[[кот]]ами и [[:Категория:Служебные]]";
let (nodes, warnings) = parse(src);
let written: Vec<&str> = nodes
.iter()
.filter_map(|n| match n {
crate::engine::ast::Inline::Link { written, .. } => Some(written.slice(src)),
_ => None,
})
.collect();
assert_eq!(written, ["кот", ":Категория:Служебные"]);
assert_eq!(warnings, Vec::new());
}
#[test]
fn interwiki_label_is_dropped() {
check("[[de:X|lbl]]", "Iw(de:X)");
check("[[de:X#s]]", "Iw(de:X#s)");
}
#[test]
fn template_namespace_alias_link() {
check("[[Ш:X]]", "Link(Шаблон:X)");
}
#[test]
fn invalid_target_warns_and_stays_literal() {
let src = "[[a<b]]";
let (nodes, warnings) = parse(src);
assert_eq!(dump(src, &nodes), "Text([[a<b]])");
assert_eq!(
warnings,
vec![Warning::InvalidLinkTarget {
target: "a<b".to_owned()
}]
);
}
#[test]
fn empty_target_warns_and_stays_literal() {
let src = "[[]]";
let (nodes, warnings) = parse(src);
assert_eq!(dump(src, &nodes), "Text([[]])");
assert_eq!(
warnings,
vec![Warning::InvalidLinkTarget {
target: String::new()
}]
);
}
#[test]
fn reverse_pipe_trick_stays_literal_without_warning() {
check("[[|x]]", "Text([[|x]])");
}
#[test]
fn unclosed_link_stays_literal() {
check("[[a", "Text([[a)");
}
#[test]
fn pipe_trick_form_stays_literal_text() {
check("[[Dog (animal)|]]", "Text([[Dog (animal)|]])");
}
#[test]
fn quotes_resolve_inside_the_label_independently() {
check(
"''a [[b|c''d]] e''",
"I[Text(a ) Link(B|Text(c) I[Text(d)]) Text( e)]",
);
}
#[test]
fn utf8_len_covers_all_sequence_lengths() {
assert_eq!(utf8_len(b'a'), 1);
assert_eq!(utf8_len(0xd0), 2); assert_eq!(utf8_len(0xe2), 3); assert_eq!(utf8_len(0xf0), 4); }
#[test]
fn size_specs() {
assert!(is_size_spec("200"));
assert!(is_size_spec("x200"));
assert!(is_size_spec("100x200"));
assert!(!is_size_spec(""));
assert!(!is_size_spec("axb"));
assert!(!is_size_spec("10x"));
}
#[test]
fn recognized_params() {
assert!(recognized_param(" frameless "));
assert!(recognized_param("x200px"));
assert!(recognized_param("link=Dog"));
assert!(!recognized_param("abcpx"));
assert!(!recognized_param("foo=bar"));
assert!(!recognized_param("A caption"));
}
}