Skip to main content

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}