use crate::config::SiteConfig;
use crate::title::Title;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct Author {
name: String,
anonymous: bool,
}
impl Author {
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn is_anonymous(&self) -> bool {
self.anonymous
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UserLink {
pub(crate) start: usize,
pub(crate) end: usize,
pub(crate) author: Author,
}
pub(crate) fn find_user_links(text: &str, config: &SiteConfig) -> Vec<UserLink> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
if let Some(link) = parse_link(text, i, config) {
i = link.end;
out.push(link);
continue;
}
}
i += 1;
}
out
}
fn parse_link(text: &str, start: usize, config: &SiteConfig) -> Option<UserLink> {
let rest = &text[start + 2..];
let close = rest.find("]]")?;
let inner = &rest[..close];
let target = inner.split('|').next().unwrap_or(inner);
let target = target.split('#').next().unwrap_or(target).trim();
let title = Title::parse(target, config).ok()?;
let author = author_of(&title)?;
Some(UserLink {
start,
end: start + 2 + close + 2,
author,
})
}
fn author_of(title: &Title) -> Option<Author> {
match title.namespace().id() {
2 | 3 => {
let name = base_segment(title.dbkey());
(!name.is_empty()).then_some(Author {
name,
anonymous: false,
})
}
-1 => {
let (page, ip) = title.dbkey().split_once('/')?;
is_contributions(page).then(|| Author {
name: ip.replace('_', " "),
anonymous: true,
})
}
_ => None,
}
}
fn base_segment(dbkey: &str) -> String {
dbkey.split('/').next().unwrap_or(dbkey).replace('_', " ")
}
fn is_contributions(page: &str) -> bool {
page == "Contributions" || page == "Вклад"
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::NamespaceDef;
fn config() -> SiteConfig {
SiteConfig::builder("ru", "Википедия")
.namespace(NamespaceDef::main())
.namespace(NamespaceDef::new(
2,
"User",
"Участник",
["У", "Участница"],
true,
))
.namespace(NamespaceDef::new(
3,
"User talk",
"Обсуждение участника",
["ОУ"],
true,
))
.namespace(NamespaceDef::new(
-1,
"Special",
"Служебная",
Vec::<String>::new(),
true,
))
.build()
.unwrap()
}
fn names(text: &str) -> Vec<(String, bool)> {
find_user_links(text, &config())
.into_iter()
.map(|l| (l.author.name().to_owned(), l.author.is_anonymous()))
.collect()
}
#[test]
fn finds_registered_user_and_talk_links() {
let text = "[[User:John Doe|John]] and [[Обсуждение участника:Иван|обс]]";
assert_eq!(
names(text),
[("John Doe".to_owned(), false), ("Иван".to_owned(), false),]
);
}
#[test]
fn localized_and_aliased_prefixes_resolve() {
assert_eq!(names("[[Участник:Пётр]]"), [("Пётр".to_owned(), false)]);
assert_eq!(names("[[У:Пётр|П]]"), [("Пётр".to_owned(), false)]);
}
#[test]
fn subpages_resolve_to_the_base_account() {
assert_eq!(
names("[[User:Alice/Archive 1|link]]"),
[("Alice".to_owned(), false)]
);
}
#[test]
fn anonymous_contributions_link_is_an_anon_author() {
let text = "[[Служебная:Вклад/192.0.2.1|192.0.2.1]]";
assert_eq!(names(text), [("192.0.2.1".to_owned(), true)]);
}
#[test]
fn spans_cover_the_whole_link() {
let text = "x [[User:Bob|Bob]] y";
let links = find_user_links(text, &config());
assert_eq!(links.len(), 1);
assert_eq!(&text[links[0].start..links[0].end], "[[User:Bob|Bob]]");
}
#[test]
fn non_user_links_and_malformed_brackets_are_ignored() {
assert!(names("[[Article]] and [[File:X.png|thumb]]").is_empty());
assert!(names("[[Служебная:Log/x|l]]").is_empty());
assert!(names("[[User:").is_empty());
assert!(names("[[User:|empty]]").is_empty());
}
}