use std::sync::Arc;
use regex::{Regex, Captures, Error};
static AUTOLINKID: &str = "autolinker";
static CONSUMETEXTID : &str = "consumetext";
static NORMALTEXTID: &str = "normaltext";
static BRNEWLINEID: &str = "convertnewlinebr";
static NEWLINEID: &str = "newline";
const BASICTAGS: &[&str] = &[
"b", "i", "sup", "sub", "u", "s", "list", r"\*", "url", "img"
];
const EXTENDEDTAGS: &[&str] = &[
"h1", "h2", "h3", "anchor", "quote", "spoiler", "icode", "code", "youtube"
];
pub type EmitScope = Arc<dyn Fn(Option<Captures>, &str, Option<Captures>)->String + Send + Sync>;
pub type EmitSimple = Arc<dyn Fn(Captures)->String + Send + Sync>;
pub struct ScopeInfo {
pub only: Option<Vec<&'static str>>,
pub double_closes: bool,
pub emit: EmitScope,
}
impl Default for ScopeInfo {
fn default() -> Self {
Self {
only: None,
double_closes: false,
emit: Arc::new(|_o, b, _c| String::from(b))
}
}
}
impl ScopeInfo {
pub fn basic(emitter: EmitScope) -> Self {
let mut result = Self::default();
result.emit = emitter;
result
}
}
pub enum MatchType {
Simple(EmitSimple),
Open(Arc<ScopeInfo>),
Close
}
pub struct MatchInfo {
pub id: &'static str,
pub regex : Regex,
pub match_type: MatchType,
}
struct BBScope<'a> {
id: &'static str,
info: Arc<ScopeInfo>,
open_tag_capture: Option<Captures<'a>>,
body: String,
}
impl BBScope<'_> {
fn is_allowed(&self, info: &MatchInfo) -> bool {
if let Some(only) = &self.info.only {
if self.id == info.id && matches!(info.match_type, MatchType::Close) {
return true;
}
for &only_str in only.iter() {
if only_str.starts_with("attr:") {
let only_name = &only_str[5..];
if only_name == info.id {
if let Some(ref capture) = self.open_tag_capture {
if capture.name("attr").is_some() {
return true;
}
}
}
}
else if only_str == info.id {
return true;
}
}
return false;
}
else {
true
}
}
fn closes(&self, id: &str) -> bool {
self.id == id && self.info.double_closes
}
fn emit(self, close_captures: Option<Captures>) -> String {
let emitter = &self.info.emit;
emitter(self.open_tag_capture, &self.body, close_captures)
}
}
struct BBScoper<'a> {
scopes : Vec<BBScope<'a>>
}
impl<'a> BBScoper<'a>
{
fn new() -> Self {
Self {
scopes: vec![
BBScope {
id: "STARTING_SCOPE",
info: Arc::new(ScopeInfo::default()) ,
open_tag_capture: None,
body: String::new()
}
]
}
}
fn is_allowed(&self, id: &MatchInfo) -> bool {
self.scopes.last().unwrap().is_allowed(id)
}
fn close_last(&mut self, close_tag: Option<Captures>) {
if let Some(scope) = self.scopes.pop() {
let body = scope.emit(close_tag); self.add_text(&body);
}
else {
panic!("BBScoper::close_last HOW DID THIS HAPPEN? There were scopes from .last but pop returned none!");
}
}
fn add_text(&mut self, text: &str) {
let mut last_scope = self.scopes.pop().unwrap();
last_scope.body.push_str(text);
self.scopes.push(last_scope);
}
fn add_char(&mut self, ch: char) {
let mut last_scope = self.scopes.pop().unwrap();
last_scope.body.push(ch);
self.scopes.push(last_scope);
}
fn add_scope(&mut self, scope: BBScope<'a>) {
if let Some(topinfo) = self.scopes.last() {
if topinfo.closes(scope.id) {
self.close_last(None);
}
}
self.scopes.push(scope);
}
fn close_scope(&mut self, id: &'static str) -> usize {
let mut scope_count = 0;
let mut tag_found : bool = false;
for scope in self.scopes.iter().rev() {
scope_count += 1;
if id == scope.id {
tag_found = true;
break;
}
}
if tag_found {
for _i in 0..scope_count {
self.close_last(None);
}
scope_count
}
else {
0 }
}
fn dump_remaining(mut self) -> String {
while self.scopes.len() > 1 {
self.close_last(None)
}
self.scopes.pop().unwrap().emit(None)
}
}
#[derive(Clone, Debug, Default)]
pub enum BBCodeLinkTarget {
None,
#[default]
Blank
}
#[derive(Clone, Debug)]
pub struct BBCodeTagConfig {
pub link_target: BBCodeLinkTarget,
pub img_in_url: bool,
pub newline_to_br: bool,
pub accepted_tags: Vec<String>,
}
impl Default for BBCodeTagConfig {
fn default() -> Self {
Self {
link_target: BBCodeLinkTarget::default(),
img_in_url: true,
newline_to_br: true,
accepted_tags: BASICTAGS.iter().map(|t| t.to_string()).collect(),
}
}
}
impl BBCodeTagConfig {
pub fn extended() -> Self {
let mut config = BBCodeTagConfig::default();
let mut extags : Vec<String> = EXTENDEDTAGS.iter().map(|t| t.to_string()).collect();
config.accepted_tags.append(&mut extags);
config
}
}
#[derive(Clone)] pub struct BBCode {
pub matchers: Arc<Vec<MatchInfo>>,
#[cfg(feature = "profiling")]
pub profiler: onestop::OneList<onestop::OneDuration>
}
impl BBCode
{
pub fn default() -> Result<Self, Error> {
Ok(Self::from_config(BBCodeTagConfig::default(), None)?)
}
pub fn from_matchers(matchers: Vec<MatchInfo>) -> Self {
Self {
matchers: Arc::new(matchers),
#[cfg(feature = "profiling")]
profiler: onestop::OneList::<onestop::OneDuration>::new()
}
}
pub fn from_config(config: BBCodeTagConfig, additional_matchers: Option<Vec<MatchInfo>>) -> Result<Self, Error>
{
let mut matches : Vec<MatchInfo> = Vec::new();
matches.push(MatchInfo {
id: NORMALTEXTID,
regex: Regex::new(r#"^[^\[\n\rh]+"#)?,
match_type : MatchType::Simple(Arc::new(|c| String::from(html_escape::encode_quoted_attribute(&c[0]))))
});
matches.push(MatchInfo {
id: CONSUMETEXTID,
regex: Regex::new(r#"^[\r]+"#)?,
match_type: MatchType::Simple(Arc::new(|_c| String::new()))
});
let target_attr = match config.link_target {
BBCodeLinkTarget::Blank => "target=\"_blank\"",
BBCodeLinkTarget::None => ""
};
let target_attr_c1 = target_attr.clone();
let mut url_only = Self::plaintext_ids();
if config.img_in_url {
url_only.push("attr:img")
}
let accepted_tags : Vec<&str> = config.accepted_tags.iter().map(|t| t.as_str()).collect();
macro_rules! addmatch {
($name:literal, $value:expr) => {
addmatch!($name, $value, None, None)
};
($name:literal, $value:expr, $open:expr, $close:expr) => {
if accepted_tags.contains(&$name) {
Self::add_tagmatcher(&mut matches, $name, $value, $open, $close)?
}
}
}
#[allow(unused_variables)]
{
addmatch!("b", ScopeInfo::basic(Arc::new(|o,b,c| format!("<b>{b}</b>"))));
addmatch!("i", ScopeInfo::basic(Arc::new(|o,b,c| format!("<i>{b}</i>"))));
addmatch!("sup", ScopeInfo::basic(Arc::new(|o,b,c| format!("<sup>{b}</sup>"))));
addmatch!("sub", ScopeInfo::basic(Arc::new(|o,b,c| format!("<sub>{b}</sub>"))));
addmatch!("u", ScopeInfo::basic(Arc::new(|o,b,c| format!("<u>{b}</u>"))));
addmatch!("s", ScopeInfo::basic(Arc::new(|o,b,c| format!("<s>{b}</s>"))));
addmatch!("list", ScopeInfo::basic(Arc::new(|o,b,c| format!("<ul>{b}</ul>"))), Some((0,1)), Some((0,1)));
addmatch!(r"\*", ScopeInfo {
only: None, double_closes: true, emit: Arc::new(|o,b,c| format!("<li>{b}</li>"))
}, Some((1,0)), Some((1,0)));
addmatch!(r"url", ScopeInfo {
only: Some(url_only),
double_closes: false,
emit: Arc::new(move |o,b,c| format!(r#"<a href="{}" {}>{}</a>"#, Self::attr_or_body(&o,b), target_attr, b) )
});
addmatch!(r"img", ScopeInfo {
only: Some(Self::plaintext_ids()),
double_closes: false,
emit: Arc::new(|o,b,c| format!(r#"<img src="{}">"#, Self::attr_or_body(&o,b)) )
});
addmatch!("h1", ScopeInfo::basic(Arc::new(|_o,b,_c| format!("<h1>{}</h1>",b))), Some((0,1)), Some((1,1)));
addmatch!("h2", ScopeInfo::basic(Arc::new(|_o,b,_c| format!("<h2>{}</h2>",b))), Some((0,1)), Some((1,1)));
addmatch!("h3", ScopeInfo::basic(Arc::new(|_o,b,_c| format!("<h3>{}</h3>",b))), Some((0,1)), Some((1,1)));
addmatch!("anchor", ScopeInfo::basic(
Arc::new(|o,b,_c| format!(r##"<a{} href="#{}">{}</a>"##, Self::attr_or_nothing(&o,"name"), Self::attr_or_body(&o,""), b))));
addmatch!("quote", ScopeInfo::basic(
Arc::new(|o,b,_c| format!(r#"<blockquote{}>{}</blockquote>"#, Self::attr_or_nothing(&o,"cite"), b))), Some((0,1)), Some((0,1)));
addmatch!("spoiler", ScopeInfo::basic(
Arc::new(|o,b,_c| format!(r#"<details class="spoiler">{}{}</details>"#, Self::tag_or_something(&o,"summary", Some("Spoiler")), b))));
addmatch!("icode", ScopeInfo {
only: Some(Self::plaintext_ids()),
double_closes: false,
emit: Arc::new(|_o,b,_c| format!(r#"<span class="icode">{b}</span>"#))
});
addmatch!("code", ScopeInfo {
only: Some(Self::plaintext_ids()),
double_closes: false,
emit: Arc::new(|o,b,_c| format!(r#"<pre class="code"{}>{}</pre>"#, Self::attr_or_nothing(&o, "data-code"), b) )
}, Some((0,1)), Some((0,1)));
addmatch!("youtube", ScopeInfo {
only: Some(Self::plaintext_ids()),
double_closes: false,
emit: Arc::new(|o,b,_c| format!(r#"<a href={} target="_blank" data-youtube>{}</a>"#, Self::attr_or_body(&o, b), b) )
});
}
if let Some(m) = additional_matchers {
matches.extend(m);
}
if config.newline_to_br {
matches.push(MatchInfo {
id: BRNEWLINEID,
regex: Regex::new(r#"^\n"#)?,
match_type: MatchType::Simple(Arc::new(|_c| String::from("<br>")))
})
}
matches.push(MatchInfo { id: NEWLINEID,
regex: Regex::new(r#"^[\n]+"#)?,
match_type: MatchType::Simple(Arc::new(|c| String::from(&c[0])))
});
let url_chars = r#"[-a-zA-Z0-9_/%&=#+~@$*'!?,.;:]*"#;
let end_chars = r#"[-a-zA-Z0-9_/%&=#+~@$*']"#;
let autolink_regex = format!("^https?://{0}{1}([(]{0}[)]({0}{1})?)?", url_chars, end_chars);
matches.push(MatchInfo {
id: AUTOLINKID,
regex: Regex::new(&autolink_regex)?,
match_type: MatchType::Simple(Arc::new(move |c| format!(r#"<a href="{0}" {1}>{0}</a>"#, &c[0], target_attr_c1)))
});
Ok(Self::from_matchers(matches))
}
pub fn to_consumer(&mut self)
{
let new_matchers : Vec<MatchInfo> =
self.matchers.iter().map(|m|
{
match &m.match_type {
MatchType::Open(_) | MatchType::Close => {
MatchInfo {
id: m.id,
regex: m.regex.clone(),
match_type: MatchType::Simple(Arc::new(|_| String::new()))
}
},
MatchType::Simple(f) => {
MatchInfo {
id: m.id,
regex: m.regex.clone(),
match_type: MatchType::Simple(f.clone())
}
}
}
}).collect();
self.matchers = Arc::new(new_matchers);
}
pub fn get_tagregex(tag: &'static str, open_consume: Option<(i32,i32)>, close_consume: Option<(i32,i32)>) -> (String, String)
{
let pre_openchomp; let post_openchomp; let pre_closechomp; let post_closechomp;
match open_consume {
Some((pre, post)) => {
pre_openchomp = format!("(?:\r?\n){{0,{}}}", pre);
post_openchomp = format!("(?:\r?\n){{0,{}}}", post);
},
None => {
pre_openchomp = String::new();
post_openchomp = String::new();
}
}
match close_consume {
Some((pre, post)) => {
pre_closechomp = format!("(?:\r?\n){{0,{}}}", pre);
post_closechomp = format!("(?:\r?\n){{0,{}}}", post);
},
None => {
pre_closechomp = String::new();
post_closechomp = String::new();
}
}
let open_tag = format!(r#"^{0}\[{1}((?:[ \t]+{1})?=(?P<attr>[^\]\n]*))?\]{2}"#, pre_openchomp, Self::tag_insensitive(tag), post_openchomp);
let close_tag = format!(r#"^{}\[/{}\]{}"#, pre_closechomp, Self::tag_insensitive(tag), post_closechomp);
(open_tag, close_tag)
}
pub fn add_tagmatcher(matchers: &mut Vec<MatchInfo>, tag: &'static str, info: ScopeInfo, open_consume: Option<(i32,i32)>, close_consume: Option<(i32,i32)>) -> Result<(), Error> { let (open_tag, close_tag) = Self::get_tagregex(tag, open_consume, close_consume);
matchers.push(MatchInfo {
id: tag,
regex: Regex::new(&open_tag)?,
match_type: MatchType::Open(Arc::new(info))
});
matchers.push(MatchInfo {
id: tag,
regex: Regex::new(&close_tag)?,
match_type: MatchType::Close,
});
Ok(())
}
fn tag_insensitive(tag: &str) -> String {
let mut result = String::with_capacity(tag.len() * 4);
let mut skip = 0;
for c in tag.to_ascii_lowercase().chars() {
if c == '\\' {
skip = 2;
}
if skip > 0 {
skip -= 1;
result.push(c);
continue;
}
result.push_str("[");
result.push(c);
result.push(c.to_ascii_uppercase());
result.push_str("]");
}
result
}
fn attr_or_body(opener: &Option<Captures>, body: &str) -> String {
if let Some(opener) = opener {
if let Some(group) = opener.name("attr") {
return String::from(html_escape::encode_quoted_attribute(group.as_str()));
}
}
return String::from(body);
}
fn attr_or_nothing(opener: &Option<Captures>, name: &str) -> String {
if let Some(opener) = opener {
if let Some(group) = opener.name("attr") {
return format!(" {}=\"{}\"", name, html_escape::encode_quoted_attribute(group.as_str()));
}
}
return String::new();
}
fn tag_or_something(opener: &Option<Captures>, tag: &str, something: Option<&str>) -> String {
if let Some(opener) = opener {
if let Some(group) = opener.name("attr") {
return format!("<{0}>{1}</{0}>", tag, html_escape::encode_quoted_attribute(group.as_str()));
}
}
if let Some(something) = something {
return format!("<{0}>{1}</{0}>", tag, html_escape::encode_quoted_attribute(something));
}
return String::new();
}
pub fn plaintext_ids() -> Vec<&'static str> {
vec![NORMALTEXTID, CONSUMETEXTID]
}
pub fn parse(&self, input: &str) -> String
{
let mut slice = &input[0..];
let mut scoper = BBScoper::new();
while slice.len() > 0
{
let mut matched_info : Option<&MatchInfo> = None;
for matchinfo in self.matchers.iter() {
if !scoper.is_allowed(matchinfo) {
continue;
}
else if matchinfo.regex.is_match(slice) {
matched_info = Some(matchinfo);
break;
}
}
if let Some(tagdo) = matched_info
{
for captures in tagdo.regex.captures_iter(slice) {
slice = &slice[captures[0].len()..];
match &tagdo.match_type {
MatchType::Simple(closure) => {
scoper.add_text(&closure(captures));
}
MatchType::Open(info) => {
let new_scope = BBScope {
id: tagdo.id,
info: info.clone(),
open_tag_capture: Some(captures),
body: String::new()
};
scoper.add_scope(new_scope);
},
MatchType::Close => {
scoper.close_scope(tagdo.id);
}
}
}
}
else {
if let Some(ch) = slice.chars().next() {
scoper.add_char(ch);
slice = &slice[ch.len_utf8()..];
}
else {
println!("In BBCode::parse, there were no more characters but there were leftover bytes!");
break;
}
}
}
scoper.dump_remaining()
}
pub fn parse_profiled_opt(&mut self, input: &str, _name: String) -> String
{
#[cfg(feature = "profiling")]
{
let mut profile = onestop::OneDuration::new(_name);
let result = self.parse(input);
profile.finish();
self.profiler.add(profile);
result
}
#[cfg(not(feature = "profiling"))]
return self.parse(input);
}
}
#[cfg(test)]
mod tests {
use super::*;
macro_rules! bbtest_basics {
($($name:ident: $value:expr;)*) => {
$(
#[test]
fn $name() {
let bbcode = BBCode::default().unwrap(); let (input, expected) = $value;
assert_eq!(bbcode.parse(input), expected);
}
)*
}
}
macro_rules! bbtest_nondefaults{
($($name:ident: $value:expr;)*) => {
$(
#[test]
fn $name() {
let mut config = BBCodeTagConfig::default();
config.link_target = BBCodeLinkTarget::None;
config.img_in_url = false;
config.newline_to_br = false;
config.accepted_tags = vec![String::from("b"), String::from("u"), String::from("code"), String::from("url"), String::from("img")];
let bbcode = BBCode::from_config(config, None).unwrap();
let (input, expected) = $value;
assert_eq!(bbcode.parse(input), expected);
}
)*
}
}
macro_rules! bbtest_extras {
($($name:ident: $value:expr;)*) => {
$(
#[test]
fn $name() {
let mut matchers = Vec::<MatchInfo>::new(); let color_emitter : EmitScope = Arc::new(|open_capture,body,_c| {
let color = open_capture.unwrap().name("attr").unwrap().as_str();
format!(r#"<span style="color:{}">{}</span>"#, color, body)
});
BBCode::add_tagmatcher(&mut matchers, "color", ScopeInfo::basic(color_emitter), None, None).unwrap();
let bbcode = BBCode::from_config(BBCodeTagConfig::extended(), Some(matchers)).unwrap();
let (input, expected) = $value;
assert_eq!(bbcode.parse(input), expected);
}
)*
}
}
macro_rules! bbtest_consumer {
($($name:ident: $value:expr;)*) => {
$(
#[test]
fn $name() {
let mut bbcode = BBCode::from_config(BBCodeTagConfig::extended(), None).unwrap();
bbcode.to_consumer();
let (input, expected) = $value;
assert_eq!(bbcode.parse(input), expected);
}
)*
}
}
#[test]
fn build_init() {
let _bbcode = BBCode::default().unwrap();
}
#[cfg(feature = "bigtest")]
#[test]
fn performance_issues()
{
use pretty_assertions::{assert_eq};
let bbcode = BBCode::from_config(BBCodeTagConfig::extended(), None).unwrap();
let testdir = "bigtests";
let entries = std::fs::read_dir(testdir).unwrap();
let mut checks: Vec<(String,String,String)> = Vec::new();
for entry in entries
{
let entry = entry.unwrap();
let path = entry.path();
let metadata = std::fs::metadata(&path).unwrap();
if metadata.is_file() {
let base_text = std::fs::read_to_string(&path).unwrap();
let parse_path = std::path::Path::new(testdir).join("parsed").join(path.file_name().unwrap());
let parse_text = std::fs::read_to_string(&parse_path).unwrap();
checks.push((base_text, parse_text, String::from(path.file_name().unwrap().to_str().unwrap())));
println!("Found test file: {:?}", path);
}
}
println!("Total tests: {}", checks.len());
let start = std::time::Instant::now();
for (raw, parsed, path) in checks {
let test_start = start.elapsed();
let result = bbcode.parse(&raw);
let test_end = start.elapsed();
assert_eq!(result, parsed);
println!(" Test '{}' : {:?}", path, test_end - test_start);
}
let elapsed = start.elapsed();
println!("Parse total: {:?}", elapsed);
}
#[cfg(feature = "bigtest")]
#[test] fn benchmark_10000() {
let bbcode = BBCode::from_config(BBCodeTagConfig::extended(), None).unwrap();
let parselem = vec![
("it's a %CRAZY% <world> 💙=\"yeah\" 👨👨👧👦>>done",
"it's a %CRAZY% <world> 💙="yeah" 👨👨👧👦>>done"),
("[][[][6][a[ab]c[i]italic[but][][* not] 8[]]][", "[][[][6][a[ab]c<i>italic[but][][* not] 8[]]][</i>"),
("[url]this[b]is[/b]a no-no[i][/url]", r#"<a href="this[b]is[/b]a no-no[i]" target="_blank">this[b]is[/b]a no-no[i]</a>"#),
("[img=https://old.smiflebosicswoace.com/user_uploads/avatars/t1647374379.png]abc 123[/img]", r#"<img src="https://old.smiflebosicswoace.com/user_uploads/avatars/t1647374379.png">"#),
("[spoiler]this[b]is empty[/spoiler]", r#"<details class="spoiler"><summary>Spoiler</summary>this<b>is empty</b></details>"#)
];
let start = std::time::Instant::now();
for i in 0..10000 {
if let Some((input, output)) = parselem.get(i % parselem.len()) {
if bbcode.parse(*input) != *output {
panic!("Hang on, bbcode isn't working!");
}
}
else {
panic!("WHAT? INDEX OUT OF BOUNDS??");
}
}
let elapsed = start.elapsed();
println!("10000 iterations took: {:?}", elapsed);
}
bbtest_basics! {
no_alter: ("hello", "hello");
lt_single: ("h<ello", "h<ello");
gt_single: ("h>ello", "h>ello");
amp_single: ("h&ello", "h&ello");
quote_single: ("h'ello", "h'ello");
doublequote_single: ("h\"ello", "h"ello");
return_byebye: ("h\rello", "hello");
newline_br: ("h\nello", "h<br>ello");
complex_escape: (
"it's a %CRAZY% <world> 💙=\"yeah\" 👨👨👧👦>>done",
"it's a %CRAZY% <world> 💙="yeah" 👨👨👧👦>>done"
);
simple_bold: ("[b]hello[/b]", "<b>hello</b>");
simple_sup: ("[sup]hello[/sup]", "<sup>hello</sup>");
simple_sub: ("[sub]hello[/sub]", "<sub>hello</sub>");
simple_strikethrough: ("[s]hello[/s]", "<s>hello</s>");
simple_underline: ("[u]hello[/u]", "<u>hello</u>");
simple_italic: ("[i]hello[/i]", "<i>hello</i>");
simple_nospaces: ("[b ]hello[/ b]", "[b ]hello[/ b]");
simple_insensitive: ("[sUp]hello[/SuP]", "<sup>hello</sup>");
simple_sensitivevalue: ("[sUp]OK but The CAPITALS[/SuP]YEA", "<sup>OK but The CAPITALS</sup>YEA");
simple_bolditalic: ("[b][i]hello[/i][/b]", "<b><i>hello</i></b>");
nested_bold: ("[b]hey[b]extra bold[/b] less bold again[/b]", "<b>hey<b>extra bold</b> less bold again</b>");
simple_url_default: ("[url]https://google.com[/url]", r#"<a href="https://google.com" target="_blank">https://google.com</a>"#);
simple_url_witharg: ("[url=http://ha4l6o7op9dy.com]furries lol[/url]", r#"<a href="http://ha4l6o7op9dy.com" target="_blank">furries lol</a>"#);
url_escape: ("[url=http'://ha4l<6o7op9dy>.com]furries lol[/url]", r#"<a href="http'://ha4l<6o7op9dy>.com" target="_blank">furries lol</a>"#);
simple_img: ("[img]https://old.smiflebosicswoace.com/user_uploads/avatars/t1647374379.png[/img]", r#"<img src="https://old.smiflebosicswoace.com/user_uploads/avatars/t1647374379.png">"#);
simple_img_nonstd: ("[img=https://old.smiflebosicswoace.com/user_uploads/avatars/t1647374379.png][/img]", r#"<img src="https://old.smiflebosicswoace.com/user_uploads/avatars/t1647374379.png">"#);
simple_img_nonstd_inner: ("[img=https://old.smiflebosicswoace.com/user_uploads/avatars/t1647374379.png]abc 123[/img]", r#"<img src="https://old.smiflebosicswoace.com/user_uploads/avatars/t1647374379.png">"#);
url_with_img: ("[url=https://google.com][img]https://some.image.url/junk.png[/img][/url]", r#"<a href="https://google.com" target="_blank"><img src="https://some.image.url/junk.png"></a>"#);
url_with_img_attr: ("[url=https://google.com][img=https://some.image.url/junk.png][/url]", r#"<a href="https://google.com" target="_blank"><img src="https://some.image.url/junk.png"></a>"#);
url_with_img_nourl: ("[url][img=https://some.image.url/junk.png][/url]", r#"<a href="[img=https://some.image.url/junk.png]" target="_blank">[img=https://some.image.url/junk.png]</a>"#);
url_no_other_tags: ("[url=https://what.non][b][i][u][s][/url]", r#"<a href="https://what.non" target="_blank">[b][i][u][s]</a>"#);
url_nested: ("[url=https://what.non][url=https://abc123.com][/url][/url]", r#"<a href="https://what.non" target="_blank">[url=https://abc123.com]</a>"#);
list_basic: ("[list][*]item 1[/*][*]item 2[/*][*]list[/*][/list]", "<ul><li>item 1</li><li>item 2</li><li>list</li></ul>");
unclosed_basic: ("[b] this is bold [i]also italic[/b] oops close all[/i]", "<b> this is bold <i>also italic</i></b> oops close all");
verbatim_url: ("[url]this[b]is[/b]a no-no[i][/url]", r#"<a href="this[b]is[/b]a no-no[i]" target="_blank">this[b]is[/b]a no-no[i]</a>"#);
inner_hack: ("[[b][/b]b]love[/[b][/b]b]", "[<b></b>b]love[/<b></b>b]");
random_brackets: ("[][[][6][a[ab]c[i]italic[but][][* not] 8[]]][", "[][[][6][a[ab]c<i>italic[but][][* not] 8[]]][</i>");
autolink_basic: ("this is https://google.com ok?", r#"this is <a href="https://google.com" target="_blank">https://google.com</a> ok?"#);
newline_list1: ("[list]\n[*]item", "<ul><li>item</li></ul>");
newline_list2: ("[list]\r\n[*]item", "<ul><li>item</li></ul>");
newline_listmega: ("\n[list]\r\n[*]item\r\n[*]item2 yeah[\r\n\r\n[*]three", "<br><ul><li>item</li><li>item2 yeah[<br></li><li>three</li></ul>");
newline_bold: ("\n[b]\nhellow\n[/b]\n", "<br><b><br>hellow<br></b><br>");
newline_italic: ("\n[i]\nhellow\n[/i]\n", "<br><i><br>hellow<br></i><br>");
newline_underline: ("\n[u]\nhellow\n[/u]\n", "<br><u><br>hellow<br></u><br>");
newline_strikethrough: ("\n[s]\nhellow\n[/s]\n", "<br><s><br>hellow<br></s><br>");
newline_sup: ("\n[sup]\nhellow\n[/sup]\n", "<br><sup><br>hellow<br></sup><br>");
newline_sub: ("\n[sub]\nhellow\n[/sub]\n", "<br><sub><br>hellow<br></sub><br>");
consume_attribute: ("[b=haha ok]but maybe? [/b]{no}", "<b>but maybe? </b>{no}");
e_dangling: ("[b]foo", "<b>foo</b>");
e_normal: ("[b]foo[/b]", "<b>foo</b>");
e_nested: ("[b]foo[b]bar[/b][/b]", "<b>foo<b>bar</b></b>");
e_empty: ("[b]foo[b][/b]bar[/b]", "<b>foo<b></b>bar</b>");
e_closemulti: ("[b]foo[i]bar[u]baz[/b]quux", "<b>foo<i>bar<u>baz</u></i></b>quux");
e_faketag: ("[b]foo[i]bar[u]baz[/fake]quux", "<b>foo<i>bar<u>baz[/fake]quux</u></i></b>");
e_reallyfake: ("[fake][b]foo[i]bar[u]baz[/fake]quux", "[fake]<b>foo<i>bar<u>baz[/fake]quux</u></i></b>");
e_ignoreclose: ("[b]foo[/b]bar[/b][/b][/b]", "<b>foo</b>bar");
e_weirdignoreclose: ("[b]foo[/b]bar[/fake][/b][/fake]", "<b>foo</b>bar[/fake][/fake]");
e_fancytag: ("[[i]b[/i]]", "[<i>b</i>]");
e_escapemadness: ("&[&]<[<]>[>]", "&[&]<[<]>[>]");
e_bracket_url: ("[url=#Ports][1][/url]", r##"<a href="#Ports" target="_blank">[1]</a>"##);
}
bbtest_nondefaults! {
restricted_tags: ("[s]not supported haha![/s]", "[s]not supported haha![/s]");
newlines_not_br: ("[b]this\n[i]\nis\n[u]silly!\n[/s]\n", "<b>this\n[i]\nis\n<u>silly!\n[/s]\n</u></b>");
no_target_in_url: ("[url=https://valid.com]target[/url]", "<a href=\"https://valid.com\" >target</a>");
no_img_in_url: ("[url=https://valid.com][img=https://notvalid.net][/url]", "<a href=\"https://valid.com\" >[img=https://notvalid.net]</a>");
no_img_in_url_noendtag: ("[url=https://valid.com][img=https://notvalid.net][/img]", "<a href=\"https://valid.com\" >[img=https://notvalid.net][/img]</a>");
}
bbtest_extras! {
e_emptyquote: ("[quote]...[/quote]", "<blockquote>...</blockquote>");
e_normalquote: ("[quote=foo]...[/quote]", r#"<blockquote cite="foo">...</blockquote>"#);
simple_spoiler: ("[spoiler=wow]amazing[/spoiler]", r#"<details class="spoiler"><summary>wow</summary>amazing</details>"#);
simple_emptyspoiler: ("[spoiler]this[b]is empty[/spoiler]", r#"<details class="spoiler"><summary>Spoiler</summary>this<b>is empty</b></details>"#);
spoiler_simeon: ("[spoiler spoiler=what is this]i hate it[/spoiler]", r#"<details class="spoiler"><summary>what is this</summary>i hate it</details>"#);
cite_escape: ("[quote=it's<mad>lad]yeah[/quote]",r#"<blockquote cite="it's<mad>lad">yeah</blockquote>"#);
h1_simple: ("[h1] so about that header [/h1]", "<h1> so about that header </h1>");
h2_simple: (" [h2]Not as important", " <h2>Not as important</h2>");
h3_simple: ("[h3][h3]wHaAt-Are-u-doin[/h3]", "<h3><h3>wHaAt-Are-u-doin</h3></h3>");
quote_newlines: ("\n[quote]\n\nthere once was\na boy\n[/quote]\n", "<br><blockquote><br>there once was<br>a boy<br></blockquote>");
anchor_simple: ("[anchor=Look_Here]The Title[/anchor]", r##"<a name="Look_Here" href="#Look_Here">The Title</a>"##);
anchor_inside: ("[anchor=name][h1]A title[/h1][/anchor]", r##"<a name="name" href="#name"><h1>A title</h1></a>"##);
icode_simple: ("[icode=Nothing Yet]Some[b]code[url][/i][/icode]", r#"<span class="icode">Some[b]code[url][/i]</span>"#);
code_simple: ("\n[code=SB3]\nSome[b]code[url][/i]\n[/code]\n", "<br><pre class=\"code\" data-code=\"SB3\">Some[b]code[url][/i]\n</pre>");
simple_customtag: ("[color=wow]amazing[/color]", r#"<span style="color:wow">amazing</span>"#);
simple_customtag_withdefault: ("[color=#FF5500][b][i]ama\nzing[/color]", r#"<span style="color:#FF5500"><b><i>ama<br>zing</i></b></span>"#);
}
bbtest_consumer! {
consume_standard: ("[b]wow[/b] but like [i]uh no scoping [s] rules [/sup] and ugh[/quote]", "wow but like uh no scoping rules and ugh");
consume_stillescape: ("<>'\"oof[img=wow][url][code][/url][/code]\n", "<>'"oof");
}
}