nagisa_core/matcher.rs
1//! 命令触发匹配器 + MentionMe 预处理。
2//!
3//! **一个**触发匹配器:正则核心。`command([..])` 是字面量糖(编译成
4//! `^(?:a|b)(?:\s|$)`),`regex(..)` 是原始正则。给定 `&Ctx` 决定消息是否命中并产出
5//! [`ParsedCommand`]:
6//! - **呼叫姿势预处理(所有命令)**:前导 `@self`(以运行期 `bot.self_id()` 为准)在匹配前
7//! 统一剥掉、不进参数区——否则 `at`/`at_or_id` 元素参会把它当目标吃掉;**MentionMe** 额外
8//! 要求 to_me,并在文本层再剥前导 `/`。
9//! - **匹配**:正则跑在(剥离后的)首个文本段上,`command`=整段匹配(group0,trim),
10//! `captures`=捕获组。
11//! - 命中后 `args` = 去掉命令文本前缀后的消息段(**保留非文本段**,如图片/回复),
12//! 供 `Args<T>` 在段流上做有序 + 元素解析。
13//! - **剩余内容要求**:无参命令**默认**([`Matcher::no_args`],`#[command]` 自动)只认
14//! 「命令词」与「回复 / @bot / 空白 + 命令词」,再有别的内容整体不算命中,防「我的 xxx」
15//! 这类日常说话误触发;显式严格([`Matcher::exact`],`#[command(.., exact)]`)更狠,
16//! 整条消息只能是命令词本身。带参命令不受影响。
17use crate::ctx::Ctx;
18use nagisa_types::prelude::*;
19use nagisa_types::segment::Segment;
20use std::borrow::Cow;
21use std::collections::HashMap;
22
23/// 解析后的命令:命令字面量 + 剩余段 + 剩余纯文本 + 正则捕获组。
24#[derive(Clone, Debug)]
25pub struct ParsedCommand {
26 /// 命中的命令文本(`command([..])` 为命中的词;`regex` 为整段匹配)。
27 pub command: String,
28 /// 命令之后剩余的消息段(**保留非文本段**,如图片/回复)。
29 pub args: Vec<Segment>,
30 /// `args` 的纯文本(对 `args` 跑 `MessageExt::extract_text`)。
31 pub args_text: String,
32 /// 正则捕获组(`command([..])` 糖无捕获组,故为空)。
33 pub captures: Vec<String>,
34 /// 命名正则槽:名 → `Some(text)`(命中) | `None`(缺省的可选槽)。
35 /// **仅** `Matcher::slots` 构建时填充;`command`/`regex` 永远为空——故 `Captures`/`Args`
36 /// 既有提取器字节级不受影响。键为 `Cow<'static, str>`,使命令式
37 /// 运行时槽名也是一等。
38 pub named_captures: HashMap<Cow<'static, str>, Option<String>>,
39}
40
41/// 一个 slot 相对字面量头的位置。`Pre`+字面量+`Post` 程序编成 `(?:..)?lit(?:..)?`,使字面量头
42/// 两侧的可选修饰可表达。
43///
44/// **注**:`#[derive(Slots)]`(区块序列模型,字面量是一等区块、按声明序穿插)只产出 `Whole` 槽;
45/// `Pre`/`Post` 仅供**手搓** `Matcher::slots(vec![..])` 时表达「单字面量头两侧的修饰」。日常派生用不到。
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum Flank {
48 /// 槽即整个程序(无字面量头侧)。
49 Whole,
50 /// 槽在字面量头**之前**。
51 Pre,
52 /// 槽在字面量头**之后**。
53 Post,
54}
55
56/// 一个有序 slot 规格。`Matcher::slots` 把一组 `SlotSpec` 编成**一条**锚定正则,
57/// 命名槽成编号组,并保留 名→组下标 的映射。
58///
59/// `src` 内部已含 `names.len()` 个捕获组(单捕获槽 1 个;tuple 槽如 `(u8,u8)` 是 **2** 个,
60/// 由 `#[derive(Slots)]` 在它**知道**两个内组的那一层产出——tuple 是多组关注,不是单捕获)。
61/// `names[i]` 是第 i 个内组的注入键;`""` 表示字面量头
62/// (无捕获组、不进 `named_captures`)。`name` 为 `Cow` 使运行时槽名无需泄漏 `'static`。
63/// `src` 为 `String`(非 `&'static`),故 config 派生的字面量/union 经命令式路径可用。
64#[derive(Clone, Debug)]
65pub struct SlotSpec {
66 /// `src` 内每个捕获组的注入键(按组顺序)。单捕获槽 1 个;tuple 槽 N 个;字面量头空。
67 pub names: Vec<Cow<'static, str>>,
68 /// 正则片段(FullMatch 转义;union 拼 `a|b`;tuple 含两内组)。命令式运行时构造亦可填入。
69 pub src: String,
70 /// `true` ⇒ 整段外包 `(?:..)?` ⇒ 结构体里的 `Option<T>`(缺省时其各内组皆为 `None`)。
71 pub optional: bool,
72 /// 槽相对字面量头的位置。
73 pub flank: Flank,
74}
75
76impl SlotSpec {
77 /// 命名的单捕获必填槽(`Flank::Whole`)。`src` 须恰含一个捕获组(或由 `Matcher::slots`
78 /// 在无捕获组时自动外包一层)。
79 pub fn named(name: impl Into<Cow<'static, str>>, src: impl Into<String>) -> Self {
80 SlotSpec { names: vec![name.into()], src: src.into(), optional: false, flank: Flank::Whole }
81 }
82 /// 命名的单捕获可选槽(`(?:..)?` ⇒ `Option<T>`)。
83 pub fn optional(name: impl Into<Cow<'static, str>>, src: impl Into<String>) -> Self {
84 SlotSpec { names: vec![name.into()], src: src.into(), optional: true, flank: Flank::Whole }
85 }
86 /// 字面量头(无捕获组、不进 `named_captures`);`src` 应已转义。
87 pub fn literal(src: impl Into<String>) -> Self {
88 SlotSpec { names: Vec::new(), src: src.into(), optional: false, flank: Flank::Whole }
89 }
90}
91
92/// 命中命令时随 `Ctx` 携带的显式用法串(`#[command(usage="…")]`)。
93///
94/// 刻意是 `ParsedCommand` 之外的**独立** ext——故 `ParsedCommand` 字段不变(既有测试零改动);
95/// 由 [`Matcher::match_event`] 在命中且 `usage` 存在时插入,与 `ParsedCommand` 同生命周期
96/// (router 跑完 handler 后一并清除)。`Args<T>` 的 parse-miss 路径优先用它而非 dev 自动 hint。
97#[derive(Clone, Debug)]
98pub struct CommandUsage(pub String);
99
100/// 命令触发匹配器:一组正则(任一命中即整体命中)+ MentionMe / to_me 开关。
101#[derive(Clone, Debug)]
102pub struct Matcher {
103 /// 触发正则;按序尝试,第一个命中者产出结果。
104 patterns: Vec<regex::Regex>,
105 /// 是否启用 MentionMe 预处理(剥前导 @self + 前导 `/`)。
106 pub mention_me: bool,
107 /// 是否要求消息「to me」(私聊或 @self)才匹配。
108 pub to_me_only: bool,
109 /// 显式用法串(`#[command(usage="…")]`);命中后作为独立 [`CommandUsage`] ext 随事件携带,
110 /// 供 parse-miss 路径优先于 dev 自动 hint 回贴。`None` ⇒ 无。
111 usage: Option<String>,
112 /// `Matcher::slots` 的 命名槽→组下标 映射。空 ⇒ `command`/`regex`(无命名槽),
113 /// 命中后 `named_captures` 也为空(既有提取器零影响)。
114 slot_names: Vec<(Cow<'static, str>, usize)>,
115 /// 命中后对剩余内容的要求(见 [`Matcher::no_args`] / [`Matcher::exact`])。
116 strictness: Strictness,
117}
118
119/// 命中后对剩余内容的要求。
120#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
121enum Strictness {
122 /// 缺省(带参命令):剩余内容不影响命中,交给参数解析。
123 #[default]
124 Any,
125 /// 无参命令的默认:除前置呼叫姿势(回复 / @bot / 空白)外不得有剩余内容
126 /// (命令词后面挂的任何段,含 @bot,都算内容)。
127 NoArgs,
128 /// 显式严格(`exact`):整条消息只能是命令词本身,呼叫姿势也不算。
129 Exact,
130}
131
132impl Matcher {
133 /// 字面量命令词触发器(最常用)。`["echo","签到"]` 编译成 `^(?:echo|签到)(?:\s|$)`
134 /// ——首词精确命中其一即触发,命中词为 `command`,其余进 `args`。
135 ///
136 /// 多词命令(如 `"git add"`)里的字面空格放宽成 `\s+`,容忍多空格——故 `"a b"` 也命中
137 /// `"a b"`。要匹配字面空格请改用 [`Matcher::regex`]。空 `alts` 永不命中。
138 pub fn command<I, S>(alts: I) -> Self
139 where
140 I: IntoIterator<Item = S>,
141 S: Into<String>,
142 {
143 let alts: Vec<String> = alts.into_iter().map(Into::into).collect();
144 let body = if alts.is_empty() {
145 // 空集合:永不匹配(用一个不可能命中的模式)。
146 r"\z\A".to_string()
147 } else {
148 // 转义字面量;多词命令(如 "git add")里的空白放宽成 `\s+`,容忍多空格。
149 // (regex::escape 不转义空格,故替换的是字面空格。)
150 alts.iter().map(|a| regex::escape(a).replace(' ', r"\s+")).collect::<Vec<_>>().join("|")
151 };
152 let pat = format!(r"^(?:{body})(?:\s|$)");
153 let re = regex::Regex::new(&pat).expect("escaped command literals form a valid regex");
154 Self {
155 patterns: vec![re],
156 mention_me: false,
157 to_me_only: false,
158 usage: None,
159 slot_names: Vec::new(),
160 strictness: Strictness::Any,
161 }
162 }
163
164 /// 原始正则触发器;编译失败返回 `regex::Error`。捕获组进 `ParsedCommand.captures`。
165 ///
166 /// **切分语义(留意未锚定的写法)**:命中后 `ParsedCommand.command` = 整段匹配文本
167 /// (捕获组 0,trim 后——含被捕获的"参数"部分),`args`/`args_text` = 匹配区间**之外**的
168 /// 文本(匹配前缀 + 匹配后缀按字节区间拼接)。对 `^` 锚定的模式这就是「命令头 + 其后参数」
169 /// 的直觉切分;**未锚定**的模式(如 `r"weather (\w+)"` 匹配 `"today weather beijing now"`)
170 /// 则会得到 `command = "weather beijing"`、`args_text = "today now"`(前缀保留、中间留缝)。
171 /// 想要参数,优先从 `captures` 取捕获组,或给模式加 `^` 锚定。
172 pub fn regex(re: &str) -> std::result::Result<Self, regex::Error> {
173 Ok(Self {
174 patterns: vec![regex::Regex::new(re)?],
175 mention_me: false,
176 to_me_only: false,
177 usage: None,
178 slot_names: Vec::new(),
179 strictness: Strictness::Any,
180 })
181 }
182
183 /// 第三种构造器(增量):一组有序 [`SlotSpec`] 编成**一条**锚定正则,
184 /// 命名槽成编号组,保留 名→组下标 映射。
185 ///
186 /// 编译规则:
187 /// - 整条正则 `^` 锚定(在首个文本段上跑),保证头部确定性匹配。
188 /// - `Flank::Pre` 的槽排在最前、`Whole` 居中、`Post` 排在最后——故 `pre`+字面量头+`post`
189 /// 程序编成 `^(?:pre)?lit(?:post)?`(字面量头两侧的可选修饰)。
190 /// - 命名槽(`name != ""`)外包一层捕获组 `(...)`,其 1-based 组下标进 `slot_names`;
191 /// `optional` 再外包 `(?:...)?`。字面量头(`name == ""`)不额外包捕获组(不进映射)。
192 ///
193 /// 命中后 `match_event` 据 `slot_names` 把每个命名组(`Some(text)` | 缺省 `None`)填入
194 /// `ParsedCommand.named_captures`,供 `Slots<T>` 类型化投影。
195 pub fn slots(specs: Vec<SlotSpec>) -> std::result::Result<Self, regex::Error> {
196 // Pre 槽在前、Whole 居中、Post 在后(稳定排序保留同 flank 内的声明顺序)。
197 let mut ordered: Vec<&SlotSpec> = specs.iter().collect();
198 ordered.sort_by_key(|s| match s.flank {
199 Flank::Pre => 0u8,
200 Flank::Whole => 1,
201 Flank::Post => 2,
202 });
203
204 let mut pat = String::from("^");
205 let mut slot_names: Vec<(Cow<'static, str>, usize)> = Vec::new();
206 let mut group_idx = 0usize; // 已开的捕获组计数(1-based 命名时用)。
207 for spec in ordered {
208 if spec.names.is_empty() {
209 // 字面量头:不命名,但其 src 内若含捕获组仍推进计数,保证下标对齐。
210 group_idx += count_capturing_groups(&spec.src);
211 if spec.optional {
212 pat.push_str("(?:");
213 pat.push_str(&spec.src);
214 pat.push_str(")?");
215 } else {
216 pat.push_str(&spec.src);
217 }
218 continue;
219 }
220 // 命名槽:`src` 内的捕获组数须与 `names` 数一致;若 `src` 无捕获组而只命名一个,
221 // 自动外包一层(便于 `SlotSpec::named("x", "\\d+")` 这种无组写法)。
222 let inner = count_capturing_groups(&spec.src);
223 let src = if inner == 0 && spec.names.len() == 1 {
224 std::borrow::Cow::Owned(format!("({})", spec.src))
225 } else {
226 std::borrow::Cow::Borrowed(spec.src.as_str())
227 };
228 // 整段可选 ⇒ 外包 `(?:..)?`(不新增捕获组;其内各组缺省时为 None)。
229 if spec.optional {
230 pat.push_str("(?:");
231 pat.push_str(&src);
232 pat.push_str(")?");
233 } else {
234 pat.push_str(&src);
235 }
236 // 把整段里的每个捕获组依次绑到 names(按组顺序),记录其 1-based 全局下标。
237 for name in &spec.names {
238 group_idx += 1;
239 slot_names.push((name.clone(), group_idx));
240 }
241 // 若 src 的捕获组多于 names(防御),把多出的也计入偏移。
242 let bound = spec.names.len();
243 if inner > bound {
244 group_idx += inner - bound;
245 }
246 }
247
248 let re = regex::Regex::new(&pat)?;
249 Ok(Self {
250 patterns: vec![re],
251 mention_me: false,
252 to_me_only: false,
253 usage: None,
254 slot_names,
255 strictness: Strictness::Any,
256 })
257 }
258
259 /// 附带显式用法串(`#[command(usage="…")]`):命中后随 `ParsedCommand.usage` 携带,
260 /// parse-miss 时优先于 dev 自动 hint 回贴。增量构造器,不影响既有匹配语义。
261 pub fn with_usage(mut self, usage: impl Into<String>) -> Self {
262 self.usage = Some(usage.into());
263 self
264 }
265
266 /// 要求 @bot 呼叫(置 `to_me_only`),文本层再剥前导 `/`。前导 @self 段的剥离
267 /// 对所有命令统一做,不属本开关。
268 pub fn mention_me(mut self) -> Self {
269 self.mention_me = true;
270 self.to_me_only = true;
271 self
272 }
273
274 /// 仅要求 to_me(不剥 @;私聊或 @self 时通过)。
275 pub fn to_me(mut self) -> Self {
276 self.to_me_only = true;
277 self
278 }
279
280 /// 无参命令的**默认**剩余内容要求(`#[command]` 对无命令体形参的命令自动调用,
281 /// 命令式注册无参命令请手动加):只认「命令词」与「**前置** 回复 / @bot / 空白 + 命令词」;
282 /// 剩余文本非空,或剩余段里有前导回复以外的段(表情 / 图片 / 任何 @ 含 @bot…)都算内容,
283 /// 整体不算命中(静默放行,不进 parse-miss)——「我的 xxx」是日常说话,不该触发「我的」。
284 pub fn no_args(mut self) -> Self {
285 self.strictness = Strictness::NoArgs;
286 self
287 }
288
289 /// 严格模式(`#[command(.., exact)]` 显式开启):整条消息**只能是命令词本身**,
290 /// 连 回复 / @bot 这些呼叫姿势都不算——比 [`no_args`](Self::no_args) 更狠。
291 /// (与 `mention_me` 同用时 @self 在匹配前已被剥掉,不受此限。)
292 pub fn exact(mut self) -> Self {
293 self.strictness = Strictness::Exact;
294 self
295 }
296
297 /// 判断某段是否是「@本 bot」。`self_id` 唯一真值来源是运行期 `bot.self_id()`。
298 fn is_mention_self(seg: &Segment, self_id: Uin) -> bool {
299 matches!(seg, Segment::Mention { user, .. } if *user == self_id)
300 }
301
302 /// 针对事件运行匹配。`None` = 不匹配(或非消息事件);`Some` = 命中并解析。
303 pub fn match_event(&self, ctx: &Ctx) -> Option<ParsedCommand> {
304 let msg = ctx.message()?;
305 let self_id = ctx.bot().self_id();
306
307 // —— 呼叫姿势预处理(**所有**命令):剥前导 @self(≤2 个),记录 to_me。——
308 // 前导 @bot 是在叫机器人,不是参数——不剥的话 `at`/`at_or_id` 元素参会把它当目标
309 // 吃掉(「@bot 转账 @张三 100」收款人解析成 bot)。一个前导 Reply 段(「回复 +
310 // @bot + 命令」很常见)保留在 args 里,但不能挡住 @self 检测——故 @self 从 Reply
311 // 之后开始看。mention_me 仅额外要求 to_me,并在文本层再剥前导 `/`。
312 let mut to_me = msg.peer.scene != Scene::Group; // 私聊天然 to_me
313 let reply_prefix = usize::from(matches!(msg.content.first(), Some(Segment::Reply { .. })));
314 let mut stripped = 0usize;
315 while stripped < 2 {
316 match msg.content.get(reply_prefix + stripped) {
317 Some(s) if Self::is_mention_self(s, self_id) => {
318 to_me = true;
319 stripped += 1;
320 }
321 _ => break,
322 }
323 }
324 let segs: std::borrow::Cow<[Segment]> = if stripped == 0 {
325 std::borrow::Cow::Borrowed(&msg.content[..])
326 } else if reply_prefix == 0 {
327 std::borrow::Cow::Borrowed(&msg.content[stripped..])
328 } else {
329 // 中间挖掉 @self、保留前导 Reply,需新建 Vec。
330 let mut v = Vec::with_capacity(msg.content.len() - stripped);
331 v.extend_from_slice(&msg.content[..reply_prefix]);
332 v.extend_from_slice(&msg.content[reply_prefix + stripped..]);
333 std::borrow::Cow::Owned(v)
334 };
335 let segs: &[Segment] = &segs;
336
337 if self.to_me_only && !to_me {
338 return None;
339 }
340
341 // 找到第一个 Text 段作为命令匹配目标(只操作首个文本段)。
342 // 若 mention_me 启用则额外剥前导 `/`。
343 let first_text_idx = segs.iter().position(|s| matches!(s, Segment::Text(_)));
344
345 // 取出首个文本段的内容,**两端**去空白(并视情况去掉前导 '/')。两端 trim 让所有匹配器对
346 // 尾随空白不敏感——消息末尾不小心多个空格很常见,不该把 `$` 锚定的命令(如 `^[0-9]{6}$`)
347 // 挡在外面。`command` 匹配器本就靠 `(?:\s|$)` 边界容忍尾随空白;`regex`/`slots` 经此也一致受益。
348 // (参数侧不受影响:remainder 本就 `.trim()`,尾随空白早已不进 args。)
349 let (match_text_owned, first_text_idx) = match first_text_idx {
350 Some(idx) => {
351 let raw = match &segs[idx] {
352 Segment::Text(t) => t.as_str(),
353 _ => unreachable!(),
354 };
355 let trimmed =
356 if self.mention_me { raw.trim_start().trim_start_matches('/').trim() } else { raw.trim() };
357 (trimmed.to_string(), idx)
358 }
359 // 无文本段:只有非文本段(正则跑空串,如纯图片消息)。
360 None => (String::new(), segs.len()),
361 };
362 let match_text: &str = &match_text_owned;
363
364 for re in &self.patterns {
365 if let Some(caps) = re.captures(match_text) {
366 let m0 = caps.get(0);
367 let whole = m0.map(|m| m.as_str().trim().to_string()).unwrap_or_default();
368 // 剩余文本 = 匹配区间之外(匹配前 + 匹配后)——**无损**(按字节区间切,
369 // 不靠 strip_prefix);命令型(`^` 锚定)时匹配前为空,即命令之后的部分。
370 let remainder = match m0 {
371 Some(m) => {
372 let mut r = String::new();
373 r.push_str(&match_text[..m.start()]);
374 r.push_str(&match_text[m.end()..]);
375 r.trim().to_string()
376 }
377 None => match_text.to_string(),
378 };
379 let groups: Vec<String> =
380 caps.iter().skip(1).map(|g| g.map(|m| m.as_str().to_string()).unwrap_or_default()).collect();
381 // 命名槽(`Matcher::slots`):据 slot_names 把每个命名组(Some(text) | 缺省 None)
382 // 收进 named_captures。`command`/`regex` 的 slot_names 为空 ⇒ 此 map 为空。
383 let mut named_captures: HashMap<Cow<'static, str>, Option<String>> =
384 HashMap::with_capacity(self.slot_names.len());
385 for (name, gi) in &self.slot_names {
386 let val = caps.get(*gi).map(|m| m.as_str().to_string());
387 named_captures.insert(name.clone(), val);
388 }
389 let args = splice_args(segs, first_text_idx, &remainder);
390 // 剩余内容要求:超出允许范围 ⇒ 不算命中(静默放行,不插任何 ext)。
391 // NoArgs(无参命令默认)只容忍前置呼叫姿势:前置 @bot 已在预处理剥掉,这里
392 // 放行的只有前导回复段与空白;Exact(显式严格)连呼叫姿势都不容忍。
393 let blocked = match self.strictness {
394 Strictness::Any => false,
395 Strictness::NoArgs => {
396 !remainder.is_empty()
397 || args.iter().any(|s| match s {
398 Segment::Reply { .. } => false,
399 Segment::Text(t) => !t.trim().is_empty(),
400 _ => true,
401 })
402 }
403 Strictness::Exact => {
404 !remainder.is_empty()
405 || args.iter().any(|s| match s {
406 Segment::Text(t) => !t.trim().is_empty(),
407 _ => true,
408 })
409 }
410 };
411 if blocked {
412 return None;
413 }
414 // 命中且有显式用法串 → 随事件携带(与 ParsedCommand 同生命周期),供 parse-miss 回贴。
415 if let Some(u) = &self.usage {
416 ctx.insert_ext(CommandUsage(u.clone()));
417 }
418 return Some(self.make_parsed(whole, args, groups, named_captures));
419 }
420 }
421 None
422 }
423
424 fn make_parsed(
425 &self,
426 command: String,
427 args: Vec<Segment>,
428 captures: Vec<String>,
429 named_captures: HashMap<Cow<'static, str>, Option<String>>,
430 ) -> ParsedCommand {
431 let args_text = args.extract_text();
432 ParsedCommand { command, args, args_text, captures, named_captures }
433 }
434}
435
436/// 转义一段字面量为正则片段(`#[derive(Slots)]` 的 `full=`/`union=` codegen 用,
437/// 免得业务/宏直接依赖 `regex` crate)。等价 `regex::escape`。
438pub fn regex_escape(s: &str) -> String {
439 regex::escape(s)
440}
441
442/// 数一个正则片段里**捕获组**的数量(忽略 `(?:..)`/`(?P<..)` 等非捕获 / 已转义括号)。
443/// 用于 `Matcher::slots` 计算组下标偏移。不是完整正则解析器,但对槽 src(简单分组)足够,
444/// 且即便误判也只影响下标对齐的诊断——实际匹配仍由 regex crate 负责。
445fn count_capturing_groups(src: &str) -> usize {
446 let bytes = src.as_bytes();
447 let mut n = 0usize;
448 let mut i = 0usize;
449 let mut in_class = false; // 处于 [..] 字符类内(其中的 ( 不是分组)。
450 while i < bytes.len() {
451 match bytes[i] {
452 b'\\' => i += 2, // 转义:跳过下一个字符。
453 b'[' if !in_class => {
454 in_class = true;
455 i += 1;
456 }
457 b']' if in_class => {
458 in_class = false;
459 i += 1;
460 }
461 b'(' if !in_class => {
462 // `(?...` 为非捕获 / 断言 / 命名组开头——只有裸 `(` 是捕获组。
463 if bytes.get(i + 1) == Some(&b'?') {
464 // 命名捕获 `(?P<..>` / `(?<..>` 仍是捕获组;其余 `(?:` `(?=` 等不是。
465 let is_named = matches!(bytes.get(i + 2), Some(&b'P') | Some(&b'<'));
466 if is_named {
467 n += 1;
468 }
469 } else {
470 n += 1;
471 }
472 i += 1;
473 }
474 _ => i += 1,
475 }
476 }
477 n
478}
479
480/// 把命令之后的 `remainder`(已算好的剩余文本)拼回剩余段:
481/// 输出 = `segs[..first_text_idx]`(命令前的非文本段,如 Reply)
482/// + (若 `remainder` 非空)`Segment::Text(remainder)`
483/// + `segs[first_text_idx+1..]`(命令后的所有其余段,如 Image、后续 Text)。
484fn splice_args(segs: &[Segment], first_text_idx: usize, remainder: &str) -> Vec<Segment> {
485 let mut args = Vec::new();
486 // 前置非文本段(命令段之前,如 Reply)。
487 args.extend_from_slice(&segs[..first_text_idx.min(segs.len())]);
488 // 命令之后的剩余文本。
489 if !remainder.is_empty() {
490 args.push(Segment::text(remainder));
491 }
492 // 首文本段之后的所有段(保留,如 Image、后续 Text)。
493 let after_start = (first_text_idx + 1).min(segs.len());
494 args.extend_from_slice(&segs[after_start..]);
495 args
496}