mod limits;
#[cfg(feature = "presets")]
mod presets;
use std::collections::{HashMap, HashSet};
pub use limits::Limits;
use crate::error::ConfigError;
use crate::title::Namespace;
const DEFAULT_PROTOCOLS: &[&str] = &[
"//",
"bitcoin:",
"ftp://",
"ftps://",
"geo:",
"git://",
"gopher://",
"http://",
"https://",
"irc://",
"ircs://",
"magnet:",
"mailto:",
"matrix:",
"mms://",
"news:",
"nntp://",
"redis://",
"sftp://",
"sip:",
"sips:",
"sms:",
"ssh://",
"svn://",
"tel:",
"telnet://",
"urn:",
"worldwind://",
"xmpp:",
];
#[derive(Debug, Clone)]
pub struct NamespaceDef {
pub id: i32,
pub canonical: String,
pub localized: String,
pub aliases: Vec<String>,
pub first_letter: bool,
}
impl NamespaceDef {
#[must_use]
pub fn main() -> Self {
Self {
id: 0,
canonical: String::new(),
localized: String::new(),
aliases: Vec::new(),
first_letter: true,
}
}
pub fn new<A>(
id: i32,
canonical: impl Into<String>,
localized: impl Into<String>,
aliases: A,
first_letter: bool,
) -> Self
where
A: IntoIterator,
A::Item: Into<String>,
{
Self {
id,
canonical: canonical.into(),
localized: localized.into(),
aliases: aliases.into_iter().map(Into::into).collect(),
first_letter,
}
}
}
#[derive(Debug, Clone)]
pub struct MagicWordDef {
pub id: String,
pub aliases: Vec<String>,
pub case_sensitive: bool,
}
impl MagicWordDef {
pub fn new<A>(id: impl Into<String>, aliases: A, case_sensitive: bool) -> Self
where
A: IntoIterator,
A::Item: Into<String>,
{
Self {
id: id.into(),
aliases: aliases.into_iter().map(Into::into).collect(),
case_sensitive,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtTagContent {
Opaque,
InlineWikitext,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MagicLinks {
pub rfc: bool,
pub pmid: bool,
pub isbn: bool,
}
impl Default for MagicLinks {
fn default() -> Self {
Self {
rfc: true,
pmid: true,
isbn: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MonthNames {
pub genitive: [String; 12],
pub nominative: [String; 12],
pub abbreviations: [String; 12],
}
impl Default for MonthNames {
fn default() -> Self {
let full = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
.map(String::from);
let abbr = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
.map(String::from);
Self {
genitive: full.clone(),
nominative: full,
abbreviations: abbr,
}
}
}
#[derive(Debug, Clone)]
pub struct SiteConfig {
lang: String,
site_name: String,
namespaces: Vec<NamespaceDef>,
ns_index: HashMap<i32, usize>,
ns_aliases: HashMap<String, i32>,
magic_words: Vec<MagicWordDef>,
magic_exact: HashMap<String, usize>,
magic_folded: HashMap<String, usize>,
redirect_aliases: Vec<String>,
ext_tags: HashMap<String, ExtTagContent>,
protocols: Vec<String>,
link_trail: LinkTrail,
interwiki: HashSet<String>,
magic_links: MagicLinks,
months: MonthNames,
lang_converter: bool,
}
impl SiteConfig {
#[must_use]
pub fn builder(lang: impl Into<String>, site_name: impl Into<String>) -> SiteConfigBuilder {
SiteConfigBuilder::new(lang, site_name)
}
#[must_use]
pub fn lang(&self) -> &str {
&self.lang
}
#[must_use]
pub fn site_name(&self) -> &str {
&self.site_name
}
#[must_use]
pub fn resolve_namespace(&self, prefix: &str) -> Option<Namespace> {
let folded = fold(prefix);
if folded.is_empty() {
return None;
}
self.ns_aliases.get(&folded).copied().map(Namespace::new)
}
#[must_use]
pub fn namespace_name(&self, ns: Namespace) -> Option<&str> {
self.ns_index
.get(&ns.id())
.map(|&i| self.namespaces[i].localized.as_str())
}
#[must_use]
pub fn ns_first_letter(&self, ns: Namespace) -> bool {
self.ns_index
.get(&ns.id())
.is_none_or(|&i| self.namespaces[i].first_letter)
}
#[must_use]
pub fn resolve_magic_word(&self, alias: &str) -> Option<&str> {
let index = self
.magic_exact
.get(alias)
.or_else(|| self.magic_folded.get(&fold(alias)))?;
Some(self.magic_words[*index].id.as_str())
}
#[must_use]
pub fn redirect_aliases(&self) -> &[String] {
&self.redirect_aliases
}
#[must_use]
pub fn ext_tag(&self, name: &str) -> Option<ExtTagContent> {
self.ext_tags.get(&name.to_lowercase()).copied()
}
#[must_use]
pub fn protocols(&self) -> &[String] {
&self.protocols
}
#[must_use]
pub fn trail_accepts(&self, c: char) -> bool {
self.link_trail.accepts(c)
}
#[must_use]
pub fn is_interwiki_prefix(&self, prefix: &str) -> bool {
self.interwiki.contains(&fold(prefix))
}
#[must_use]
pub fn magic_links(&self) -> MagicLinks {
self.magic_links
}
#[must_use]
pub fn lang_converter(&self) -> bool {
self.lang_converter
}
#[must_use]
pub fn months(&self) -> &MonthNames {
&self.months
}
}
#[derive(Debug)]
pub struct SiteConfigBuilder {
lang: String,
site_name: String,
namespaces: Vec<NamespaceDef>,
magic_words: Vec<MagicWordDef>,
ext_tags: Vec<(String, ExtTagContent)>,
protocols: Vec<String>,
link_trail: String,
interwiki: Vec<String>,
magic_links: MagicLinks,
months: MonthNames,
lang_converter: bool,
}
impl SiteConfigBuilder {
fn new(lang: impl Into<String>, site_name: impl Into<String>) -> Self {
Self {
lang: lang.into(),
site_name: site_name.into(),
namespaces: Vec::new(),
magic_words: Vec::new(),
ext_tags: Vec::new(),
protocols: DEFAULT_PROTOCOLS.iter().map(|&p| p.to_owned()).collect(),
link_trail: "a-z".to_owned(),
interwiki: Vec::new(),
magic_links: MagicLinks::default(),
months: MonthNames::default(),
lang_converter: false,
}
}
#[must_use]
pub fn months(mut self, months: MonthNames) -> Self {
self.months = months;
self
}
#[must_use]
pub fn namespace(mut self, def: NamespaceDef) -> Self {
self.namespaces.push(def);
self
}
#[must_use]
pub fn magic_word(mut self, def: MagicWordDef) -> Self {
self.magic_words.push(def);
self
}
#[must_use]
pub fn ext_tag(mut self, name: impl Into<String>, content: ExtTagContent) -> Self {
self.ext_tags.push((name.into(), content));
self
}
#[must_use]
pub fn protocols<P>(mut self, protocols: P) -> Self
where
P: IntoIterator,
P::Item: Into<String>,
{
self.protocols = protocols.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn link_trail(mut self, class: impl Into<String>) -> Self {
self.link_trail = class.into();
self
}
#[must_use]
pub fn interwiki<P>(mut self, prefixes: P) -> Self
where
P: IntoIterator,
P::Item: Into<String>,
{
self.interwiki = prefixes.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn magic_links(mut self, links: MagicLinks) -> Self {
self.magic_links = links;
self
}
#[must_use]
pub fn lang_converter(mut self, enabled: bool) -> Self {
self.lang_converter = enabled;
self
}
pub fn build(self) -> Result<SiteConfig, ConfigError> {
let mut ns_index: HashMap<i32, usize> = HashMap::new();
for (i, def) in self.namespaces.iter().enumerate() {
if ns_index.insert(def.id, i).is_some() {
return Err(ConfigError::DuplicateNamespace(def.id));
}
}
if !ns_index.contains_key(&0) {
return Err(ConfigError::MissingMainNamespace);
}
let mut ns_aliases: HashMap<String, i32> = HashMap::new();
for def in &self.namespaces {
let names = std::iter::once(&def.canonical)
.chain(std::iter::once(&def.localized))
.chain(def.aliases.iter());
for name in names {
let folded = fold(name);
if folded.is_empty() {
continue;
}
match ns_aliases.get(&folded) {
Some(&existing) if existing != def.id => {
return Err(ConfigError::ConflictingNamespaceAlias {
alias: folded,
first: existing,
second: def.id,
});
}
_ => {
ns_aliases.insert(folded, def.id);
}
}
}
}
let mut magic_exact = HashMap::new();
let mut magic_folded = HashMap::new();
let mut redirect_aliases = Vec::new();
for (i, word) in self.magic_words.iter().enumerate() {
for alias in &word.aliases {
if word.case_sensitive {
magic_exact.insert(alias.clone(), i);
} else {
magic_folded.insert(fold(alias), i);
}
if word.id == "redirect" {
redirect_aliases.push(fold(alias));
}
}
}
let ext_tags = self
.ext_tags
.into_iter()
.map(|(name, content)| (name.to_lowercase(), content))
.collect();
let mut protocols = self.protocols;
protocols.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
let link_trail =
LinkTrail::parse(&self.link_trail).ok_or_else(|| ConfigError::LinkTrail {
pattern: self.link_trail.clone(),
})?;
let interwiki = self.interwiki.iter().map(|p| fold(p)).collect();
Ok(SiteConfig {
lang: self.lang,
site_name: self.site_name,
namespaces: self.namespaces,
ns_index,
ns_aliases,
magic_words: self.magic_words,
magic_exact,
magic_folded,
redirect_aliases,
ext_tags,
protocols,
link_trail,
interwiki,
magic_links: self.magic_links,
months: self.months,
lang_converter: self.lang_converter,
})
}
}
pub(crate) fn fold(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut pending_space = false;
for c in s.chars() {
if c == '_' || c.is_whitespace() {
pending_space = true;
continue;
}
if pending_space && !out.is_empty() {
out.push(' ');
}
pending_space = false;
out.extend(c.to_lowercase());
}
out
}
#[derive(Debug, Clone, Default)]
struct LinkTrail {
ranges: Vec<(char, char)>,
}
impl LinkTrail {
fn parse(class: &str) -> Option<Self> {
let chars: Vec<char> = class.chars().collect();
let mut ranges = Vec::new();
let mut i = 0;
while i < chars.len() {
if i + 2 < chars.len() && chars[i + 1] == '-' {
let (lo, hi) = (chars[i], chars[i + 2]);
if lo > hi {
return None;
}
ranges.push((lo, hi));
i += 3;
} else {
ranges.push((chars[i], chars[i]));
i += 1;
}
}
ranges.sort_unstable();
Some(Self { ranges })
}
fn accepts(&self, c: char) -> bool {
self.ranges.iter().any(|&(lo, hi)| lo <= c && c <= hi)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> SiteConfigBuilder {
SiteConfig::builder("ru", "Википедия")
.namespace(NamespaceDef::main())
.namespace(NamespaceDef::new(
10,
"Template",
"Шаблон",
["Ш", "T"],
true,
))
.namespace(NamespaceDef::new(6, "File", "Файл", ["Изображение"], true))
}
#[test]
fn builder_compiles_and_exposes_identity() {
let config = base().build().unwrap();
assert_eq!(config.lang(), "ru");
assert_eq!(config.site_name(), "Википедия");
assert!(!config.lang_converter());
assert_eq!(config.magic_links(), MagicLinks::default());
assert!(format!("{config:?}").contains("SiteConfig"));
let _clone = config.clone();
}
#[test]
fn namespace_resolution_folds_case_and_underscores() {
let config = base().build().unwrap();
for prefix in ["Шаблон", "шаблон", "TEMPLATE", "т"] {
let resolved = config.resolve_namespace(prefix);
if prefix == "т" {
assert_eq!(resolved, None, "unknown alias must not resolve");
} else {
assert_eq!(resolved.map(Namespace::id), Some(10), "{prefix}");
}
}
assert_eq!(
config.resolve_namespace("_изображение_").map(Namespace::id),
Some(6)
);
assert_eq!(config.resolve_namespace(""), None);
assert_eq!(config.namespace_name(Namespace::TEMPLATE), Some("Шаблон"));
assert_eq!(config.namespace_name(Namespace::MAIN), Some(""));
assert_eq!(config.namespace_name(Namespace::MODULE), None);
assert!(
config.ns_first_letter(Namespace::MODULE),
"unknown ns defaults to first-letter"
);
}
#[test]
fn missing_main_namespace_is_rejected() {
let err = SiteConfig::builder("en", "Wiki")
.namespace(NamespaceDef::new(
10,
"Template",
"Template",
Vec::<String>::new(),
true,
))
.build()
.unwrap_err();
assert!(matches!(err, ConfigError::MissingMainNamespace));
}
#[test]
fn duplicate_namespace_ids_are_rejected() {
let err = base()
.namespace(NamespaceDef::new(
10,
"Other",
"Другое",
Vec::<String>::new(),
true,
))
.build()
.unwrap_err();
assert!(matches!(err, ConfigError::DuplicateNamespace(10)));
}
#[test]
fn conflicting_aliases_are_rejected() {
let err = base()
.namespace(NamespaceDef::new(828, "Module", "Модуль", ["Ш"], true))
.build()
.unwrap_err();
assert_eq!(
err,
ConfigError::ConflictingNamespaceAlias {
alias: "ш".to_owned(),
first: 10,
second: 828,
}
);
}
#[test]
fn same_alias_on_the_same_namespace_is_fine() {
let config = base()
.namespace(NamespaceDef::new(
14,
"Category",
"Category",
["category"],
true,
))
.build()
.unwrap();
assert_eq!(
config.resolve_namespace("Category").map(Namespace::id),
Some(14)
);
}
#[test]
fn magic_words_respect_case_sensitivity() {
let config = base()
.magic_word(MagicWordDef::new("notoc", ["__NOTOC__"], true))
.magic_word(MagicWordDef::new(
"redirect",
["#REDIRECT", "#перенаправление"],
false,
))
.build()
.unwrap();
assert_eq!(config.resolve_magic_word("__NOTOC__"), Some("notoc"));
assert_eq!(
config.resolve_magic_word("__notoc__"),
None,
"case-sensitive word"
);
assert_eq!(config.resolve_magic_word("#Redirect"), Some("redirect"));
assert_eq!(
config.resolve_magic_word("#ПЕРЕНАПРАВЛЕНИЕ"),
Some("redirect")
);
assert_eq!(config.resolve_magic_word("nope"), None);
assert_eq!(config.redirect_aliases(), ["#redirect", "#перенаправление"]);
}
#[test]
fn ext_tags_look_up_case_insensitively() {
let config = base()
.ext_tag("nowiki", ExtTagContent::Opaque)
.ext_tag("Poem", ExtTagContent::InlineWikitext)
.build()
.unwrap();
assert_eq!(config.ext_tag("NoWiki"), Some(ExtTagContent::Opaque));
assert_eq!(config.ext_tag("POEM"), Some(ExtTagContent::InlineWikitext));
assert_eq!(config.ext_tag("ref"), None);
}
#[test]
fn protocols_default_to_mediawiki_list_sorted_longest_first() {
let config = base().build().unwrap();
let protocols = config.protocols();
assert!(protocols.iter().any(|p| p == "https://"));
assert!(protocols.iter().any(|p| p == "//"));
for pair in protocols.windows(2) {
assert!(pair[0].len() >= pair[1].len(), "{pair:?}");
}
let custom = base().protocols(["http://"]).build().unwrap();
assert_eq!(custom.protocols(), ["http://"]);
}
#[test]
fn link_trail_classes_parse_ranges_singles_and_literal_dashes() {
let config = base().link_trail("a-zа-яё-").build().unwrap();
for c in ['a', 'z', 'м', 'ё', '-'] {
assert!(config.trail_accepts(c), "{c}");
}
for c in ['A', 'Я', '1', ' '] {
assert!(!config.trail_accepts(c), "{c}");
}
let disabled = base().link_trail("").build().unwrap();
assert!(!disabled.trail_accepts('a'));
let err = base().link_trail("z-a").build().unwrap_err();
assert!(matches!(err, ConfigError::LinkTrail { pattern } if pattern == "z-a"));
}
#[test]
fn interwiki_prefixes_fold_case() {
let config = base().interwiki(["fr", "De"]).build().unwrap();
assert!(config.is_interwiki_prefix("FR"));
assert!(config.is_interwiki_prefix("de"));
assert!(!config.is_interwiki_prefix("en"));
}
#[test]
fn magic_links_and_lang_converter_toggles() {
let config = base()
.magic_links(MagicLinks {
rfc: false,
pmid: true,
isbn: false,
})
.lang_converter(true)
.build()
.unwrap();
assert!(!config.magic_links().rfc);
assert!(config.magic_links().pmid);
assert!(!config.magic_links().isbn);
assert!(config.lang_converter());
}
#[test]
fn months_default_to_english_and_the_builder_overrides_them() {
let default = base().build().unwrap();
assert_eq!(default.months().genitive[0], "January");
assert_eq!(default.months().nominative[4], "May");
assert_eq!(default.months().abbreviations[11], "Dec");
assert_eq!(default.months(), &MonthNames::default());
let ru_gen = [
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
]
.map(String::from);
let months = MonthNames {
genitive: ru_gen.clone(),
..MonthNames::default()
};
let config = base().months(months).build().unwrap();
assert_eq!(config.months().genitive, ru_gen);
assert_eq!(
config.months().nominative[0],
"January",
"untouched form stays default"
);
assert!(format!("{:?}", config.months()).contains("января"));
}
#[test]
fn fold_collapses_and_lowercases() {
assert_eq!(fold(" Foo__BAR baz "), "foo bar baz");
assert_eq!(fold("ЁЖ"), "ёж");
assert_eq!(fold("___"), "");
}
#[test]
fn definition_types_are_cloneable_and_debuggable() {
let ns = NamespaceDef::new(4, "Project", "Проект", ["ВП"], true);
let word = MagicWordDef::new("if", ["if"], false);
let rendered = format!(
"{:?} {:?} {:?}",
ns.clone(),
word.clone(),
ExtTagContent::Opaque
);
assert!(rendered.contains("NamespaceDef"));
assert_eq!(ns.aliases, ["ВП"]);
assert!(!word.case_sensitive);
}
}