use crate::ctx::Ctx;
use nagisa_types::prelude::*;
use nagisa_types::segment::Segment;
use std::borrow::Cow;
use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct ParsedCommand {
pub command: String,
pub args: Vec<Segment>,
pub args_text: String,
pub captures: Vec<String>,
pub named_captures: HashMap<Cow<'static, str>, Option<String>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Flank {
Whole,
Pre,
Post,
}
#[derive(Clone, Debug)]
pub struct SlotSpec {
pub names: Vec<Cow<'static, str>>,
pub src: String,
pub optional: bool,
pub flank: Flank,
}
impl SlotSpec {
pub fn named(name: impl Into<Cow<'static, str>>, src: impl Into<String>) -> Self {
SlotSpec { names: vec![name.into()], src: src.into(), optional: false, flank: Flank::Whole }
}
pub fn optional(name: impl Into<Cow<'static, str>>, src: impl Into<String>) -> Self {
SlotSpec { names: vec![name.into()], src: src.into(), optional: true, flank: Flank::Whole }
}
pub fn literal(src: impl Into<String>) -> Self {
SlotSpec { names: Vec::new(), src: src.into(), optional: false, flank: Flank::Whole }
}
}
#[derive(Clone, Debug)]
pub struct CommandUsage(pub String);
#[derive(Clone, Debug)]
pub struct Matcher {
patterns: Vec<regex::Regex>,
pub mention_me: bool,
pub to_me_only: bool,
usage: Option<String>,
slot_names: Vec<(Cow<'static, str>, usize)>,
strictness: Strictness,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum Strictness {
#[default]
Any,
NoArgs,
Exact,
}
impl Matcher {
pub fn command<I, S>(alts: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let alts: Vec<String> = alts.into_iter().map(Into::into).collect();
let body = if alts.is_empty() {
r"\z\A".to_string()
} else {
alts.iter().map(|a| regex::escape(a).replace(' ', r"\s+")).collect::<Vec<_>>().join("|")
};
let pat = format!(r"^(?:{body})(?:\s|$)");
let re = regex::Regex::new(&pat).expect("escaped command literals form a valid regex");
Self {
patterns: vec![re],
mention_me: false,
to_me_only: false,
usage: None,
slot_names: Vec::new(),
strictness: Strictness::Any,
}
}
pub fn regex(re: &str) -> std::result::Result<Self, regex::Error> {
Ok(Self {
patterns: vec![regex::Regex::new(re)?],
mention_me: false,
to_me_only: false,
usage: None,
slot_names: Vec::new(),
strictness: Strictness::Any,
})
}
pub fn slots(specs: Vec<SlotSpec>) -> std::result::Result<Self, regex::Error> {
let mut ordered: Vec<&SlotSpec> = specs.iter().collect();
ordered.sort_by_key(|s| match s.flank {
Flank::Pre => 0u8,
Flank::Whole => 1,
Flank::Post => 2,
});
let mut pat = String::from("^");
let mut slot_names: Vec<(Cow<'static, str>, usize)> = Vec::new();
let mut group_idx = 0usize; for spec in ordered {
if spec.names.is_empty() {
group_idx += count_capturing_groups(&spec.src);
if spec.optional {
pat.push_str("(?:");
pat.push_str(&spec.src);
pat.push_str(")?");
} else {
pat.push_str(&spec.src);
}
continue;
}
let inner = count_capturing_groups(&spec.src);
let src = if inner == 0 && spec.names.len() == 1 {
std::borrow::Cow::Owned(format!("({})", spec.src))
} else {
std::borrow::Cow::Borrowed(spec.src.as_str())
};
if spec.optional {
pat.push_str("(?:");
pat.push_str(&src);
pat.push_str(")?");
} else {
pat.push_str(&src);
}
for name in &spec.names {
group_idx += 1;
slot_names.push((name.clone(), group_idx));
}
let bound = spec.names.len();
if inner > bound {
group_idx += inner - bound;
}
}
let re = regex::Regex::new(&pat)?;
Ok(Self {
patterns: vec![re],
mention_me: false,
to_me_only: false,
usage: None,
slot_names,
strictness: Strictness::Any,
})
}
pub fn with_usage(mut self, usage: impl Into<String>) -> Self {
self.usage = Some(usage.into());
self
}
pub fn mention_me(mut self) -> Self {
self.mention_me = true;
self.to_me_only = true;
self
}
pub fn to_me(mut self) -> Self {
self.to_me_only = true;
self
}
pub fn no_args(mut self) -> Self {
self.strictness = Strictness::NoArgs;
self
}
pub fn exact(mut self) -> Self {
self.strictness = Strictness::Exact;
self
}
fn is_mention_self(seg: &Segment, self_id: Uin) -> bool {
matches!(seg, Segment::Mention { user, .. } if *user == self_id)
}
pub fn match_event(&self, ctx: &Ctx) -> Option<ParsedCommand> {
let msg = ctx.message()?;
let self_id = ctx.bot().self_id();
let mut to_me = msg.peer.scene != Scene::Group; let reply_prefix = usize::from(matches!(msg.content.first(), Some(Segment::Reply { .. })));
let mut stripped = 0usize;
while stripped < 2 {
match msg.content.get(reply_prefix + stripped) {
Some(s) if Self::is_mention_self(s, self_id) => {
to_me = true;
stripped += 1;
}
_ => break,
}
}
let segs: std::borrow::Cow<[Segment]> = if stripped == 0 {
std::borrow::Cow::Borrowed(&msg.content[..])
} else if reply_prefix == 0 {
std::borrow::Cow::Borrowed(&msg.content[stripped..])
} else {
let mut v = Vec::with_capacity(msg.content.len() - stripped);
v.extend_from_slice(&msg.content[..reply_prefix]);
v.extend_from_slice(&msg.content[reply_prefix + stripped..]);
std::borrow::Cow::Owned(v)
};
let segs: &[Segment] = &segs;
if self.to_me_only && !to_me {
return None;
}
let first_text_idx = segs.iter().position(|s| matches!(s, Segment::Text(_)));
let (match_text_owned, first_text_idx) = match first_text_idx {
Some(idx) => {
let raw = match &segs[idx] {
Segment::Text(t) => t.as_str(),
_ => unreachable!(),
};
let trimmed =
if self.mention_me { raw.trim_start().trim_start_matches('/').trim() } else { raw.trim() };
(trimmed.to_string(), idx)
}
None => (String::new(), segs.len()),
};
let match_text: &str = &match_text_owned;
for re in &self.patterns {
if let Some(caps) = re.captures(match_text) {
let m0 = caps.get(0);
let whole = m0.map(|m| m.as_str().trim().to_string()).unwrap_or_default();
let remainder = match m0 {
Some(m) => {
let mut r = String::new();
r.push_str(&match_text[..m.start()]);
r.push_str(&match_text[m.end()..]);
r.trim().to_string()
}
None => match_text.to_string(),
};
let groups: Vec<String> =
caps.iter().skip(1).map(|g| g.map(|m| m.as_str().to_string()).unwrap_or_default()).collect();
let mut named_captures: HashMap<Cow<'static, str>, Option<String>> =
HashMap::with_capacity(self.slot_names.len());
for (name, gi) in &self.slot_names {
let val = caps.get(*gi).map(|m| m.as_str().to_string());
named_captures.insert(name.clone(), val);
}
let args = splice_args(segs, first_text_idx, &remainder);
let blocked = match self.strictness {
Strictness::Any => false,
Strictness::NoArgs => {
!remainder.is_empty()
|| args.iter().any(|s| match s {
Segment::Reply { .. } => false,
Segment::Text(t) => !t.trim().is_empty(),
_ => true,
})
}
Strictness::Exact => {
!remainder.is_empty()
|| args.iter().any(|s| match s {
Segment::Text(t) => !t.trim().is_empty(),
_ => true,
})
}
};
if blocked {
return None;
}
if let Some(u) = &self.usage {
ctx.insert_ext(CommandUsage(u.clone()));
}
return Some(self.make_parsed(whole, args, groups, named_captures));
}
}
None
}
fn make_parsed(
&self,
command: String,
args: Vec<Segment>,
captures: Vec<String>,
named_captures: HashMap<Cow<'static, str>, Option<String>>,
) -> ParsedCommand {
let args_text = args.extract_text();
ParsedCommand { command, args, args_text, captures, named_captures }
}
}
pub fn regex_escape(s: &str) -> String {
regex::escape(s)
}
fn count_capturing_groups(src: &str) -> usize {
let bytes = src.as_bytes();
let mut n = 0usize;
let mut i = 0usize;
let mut in_class = false; while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2, b'[' if !in_class => {
in_class = true;
i += 1;
}
b']' if in_class => {
in_class = false;
i += 1;
}
b'(' if !in_class => {
if bytes.get(i + 1) == Some(&b'?') {
let is_named = matches!(bytes.get(i + 2), Some(&b'P') | Some(&b'<'));
if is_named {
n += 1;
}
} else {
n += 1;
}
i += 1;
}
_ => i += 1,
}
}
n
}
fn splice_args(segs: &[Segment], first_text_idx: usize, remainder: &str) -> Vec<Segment> {
let mut args = Vec::new();
args.extend_from_slice(&segs[..first_text_idx.min(segs.len())]);
if !remainder.is_empty() {
args.push(Segment::text(remainder));
}
let after_start = (first_text_idx + 1).min(segs.len());
args.extend_from_slice(&segs[after_start..]);
args
}