diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..663ee18
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+name: Check examples
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+
+jobs:
+ examples:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: requirements-ci.txt
+
+ - name: Install checker dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -r requirements-ci.txt
+
+ - name: Run example checker
+ run: python scripts/check_examples.py
diff --git a/.gitignore b/.gitignore
index 6f556f1..551b4be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,10 @@ Thumbs.db
*.swp
*.swo
*~
+node_modules/
+__pycache__/
+*.py[cod]
+.venv/
+venv/
+.env
+.pytest_cache/
diff --git a/README.md b/README.md
index 43a76e0..9340f82 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,27 @@ Verifiable regex cheat sheet and study notes (Chinese). Not a full textbook.
不要只看模式「长得对」。正则难的是边界:多一个空格、少一个转义、引擎不同,结果都会变。
+仓库里还有一份可自动跑的清单,见 [如何运行校验](#如何运行校验)。
+
+---
+
+## 如何运行校验
+
+正文里的 ✓ / ✗ 抽了一份到 [`examples.yml`](examples.yml),用脚本自动跑,避免笔记和真实引擎各说各话。
+
+**CI 用的引擎接近 PCRE2,但不是 regex101 的完整复刻。** GitHub Actions(`ubuntu-latest`)跑的是 **Python 3 + [`pcre2`](https://pypi.org/project/pcre2/) 包**(捆绑 libpcre2,比标准库 `re`、也比 PyPI 上的 `regex` 库更接近本文默认引擎),并默认加上 `ASCII`,让 `\w` / `\d` / `\b` 接近文中说的 PCRE 默认(**不含汉字**)。这和 JavaScript `RegExp`、Python `re`、以及 regex101 上每一个勾选项都可能有边角差别。递归 `(?R)`、.NET 平衡组不会放进这份会执行的清单。
+
+本地(仓库根目录):
+
+```bash
+python3 -m pip install -r requirements-ci.txt
+python3 scripts/check_examples.py
+```
+
+失败时脚本会打印是哪一条 `id`、哪一个测试字符串不符合。往 `examples.yml` 增补用例后,请在本地跑通再推送;推到 `master` 的 push / pull request 也会跑 [`.github/workflows/ci.yml`](.github/workflows/ci.yml)。
+
+灾难性回溯那种教学模式(如 `^(a+)+$`)只写在清单里作文档,标了 `skip: true`,**不会**在 CI 里当计时炸弹执行。变长且含捕获的后行(正文里的 `(?<=<(\w+)>).*?(?=\1>)`)同样跳过:CI 用的 PCRE2 绑定会拒绝无限长后行;请用旁边那条不依赖后行的 `<(\w+)>(.*?)\1>` 来对拍,或到 regex101 选 PCRE2 手试。
+
---
## 参考与致谢
@@ -89,7 +110,7 @@ Verifiable regex cheat sheet and study notes (Chinese). Not a full textbook.
19. [本文修正过什么](#本文修正过什么)
20. [许可证](#许可证)
-前 13 节把语法对上例子;14 节动手套常用模式;15 节是刹车——学完「能写」之后,再记住「不该写」。
+前 13 节把语法对上例子;14 节动手套常用模式;15 节是刹车——学完「能写」之后,再记住「不该写」。改正文里的 ✓ / ✗ 时,请同步 [`examples.yml`](examples.yml) 并看 [如何运行校验](#如何运行校验)。
**阅读约定(核心语法尽量统一成下面四行):**
@@ -776,6 +797,7 @@ PCRE 一类引擎往往还有回溯上限,但「写成更朴素的模式」仍
7. 把「处理选项」扩成跨语言的 [标志与选项对照表](#标志与选项对照表)(`g` 按「找出全部」来讲,不当成 PCRE 模式正文修饰符)。
8. [贪婪与懒惰](#贪婪与懒惰) 改成同一测试字符串的 ✓ / ✗ 对照,并补了标签例子。
9. 语法后面接上 [常用实战模式](#常用实战模式) 和 [什么时候不该用正则](#什么时候不该用正则)(含 ReDoS 的教学说明,不是吓唬人)。
+10. 增加 [`examples.yml`](examples.yml) + [`scripts/check_examples.py`](scripts/check_examples.py) + GitHub Actions,把正文里能对上号的 ✓ / ✗ 自动跑一遍(见 [如何运行校验](#如何运行校验))。
仍可能有引擎边角差异。发现问题请对照 [regex101](https://regex101.com/)(PCRE2)和你实际语言的文档,欢迎直接改笔记。
diff --git a/examples.yml b/examples.yml
new file mode 100644
index 0000000..e69faec
--- /dev/null
+++ b/examples.yml
@@ -0,0 +1,593 @@
+# 教学用例:从 README 正文的 ✓ / ✗ 抽出来,供 scripts/check_examples.py 自动核对。
+#
+# CI 引擎:Python 包 pcre2(真实 PCRE2)+ 默认 ASCII,使 \w/\d/\b 接近文中
+# 「PCRE 默认不含汉字」。不是 regex101 的完整 UI 复刻。
+#
+# 字段:
+# id 稳定编号
+# pattern 模式(单引号字符串,反斜杠按字面保存)
+# flavor pcre(文档默认;pcre2 也可)
+# "yes" 必须匹配的字符串(键必须加引号,否则 YAML 1.1 会变成 true)
+# "no" 必须不匹配的字符串(同上,否则会变成 false)
+# flags 可选,如 i / m / s / x;写 u 则改走 Unicode
+# fullmatch 可选,true 时用整串匹配(教学里的「整串校验」)
+# first 可选,yes[0] 上第一次 search 的命中文本
+# all 可选,yes[0] 上全部非重叠命中(类似 regex101 勾 Global)
+# skip 可选,true 则不编译、不执行(ReDoS、CI 引擎不支持的写法)
+# note 可选说明
+#
+# yes/no 里的每一项都请加引号,避免 12345、007、2026-09-15 被当成数字或日期。
+
+version: 1
+engine: pcre2
+
+cases:
+ # --- 从例子开始 ---
+ - id: intro-word-hi
+ pattern: '\bhi\b'
+ flavor: pcre
+ note: 独立单词 hi
+ "yes":
+ - "hi"
+ - "say hi."
+ "no":
+ - "him"
+ - "history"
+
+ - id: intro-hi-then-lucy
+ pattern: '\bhi\b.*\bLucy\b'
+ flavor: pcre
+ note: 默认点号不匹配换行
+ "yes":
+ - "hi Lucy"
+ - "hi there, Lucy"
+ "no":
+ - "hi\nLucy"
+ - "hi there"
+
+ - id: intro-phone-area3
+ pattern: '0\d{2}-\d{8}'
+ flavor: pcre
+ "yes":
+ - "010-12345678"
+ "no":
+ - "0376-1234567"
+
+ # --- 元字符 ---
+ - id: meta-word-starting-a
+ pattern: '\ba\w*\b'
+ flavor: pcre
+ "yes":
+ - "apple"
+ - "an"
+ - "a"
+ "no":
+ - "banana"
+ - "Apple"
+
+ - id: meta-digits-plus
+ pattern: '\d+'
+ flavor: pcre
+ "yes":
+ - "1"
+ - "007"
+ - "2026"
+ "no":
+ - ""
+ - "abc"
+
+ - id: meta-word-exactly-6
+ pattern: '\b\w{6}\b'
+ flavor: pcre
+ "yes":
+ - "python"
+ - "regexp"
+ "no":
+ - "regex"
+ - "regular"
+
+ - id: meta-whole-digits-5-to-12
+ pattern: '^\d{5,12}$'
+ flavor: pcre
+ fullmatch: true
+ "yes":
+ - "12345"
+ - "123456789012"
+ "no":
+ - "1234"
+ - "1234567890123"
+ - "12345abc"
+
+ # --- 字符转义 ---
+ - id: escape-dot
+ pattern: '\.'
+ flavor: pcre
+ first: "."
+ "yes":
+ - "deerchao.cn"
+ - "."
+ "no":
+ - "abc"
+
+ - id: escape-star
+ pattern: '\*'
+ flavor: pcre
+ first: "*"
+ "yes":
+ - "3*4"
+ - "*"
+ "no":
+ - "34"
+
+ - id: escape-backslash
+ pattern: '\\'
+ flavor: pcre
+ first: "\\"
+ "yes":
+ - "C:\\Windows"
+ "no":
+ - "Windows"
+
+ # --- 重复(量词) ---
+ - id: quant-windows-digits
+ pattern: 'Windows\d+'
+ flavor: pcre
+ "yes":
+ - "Windows7"
+ - "Windows11"
+ "no":
+ - "Windows"
+ - "windows11"
+
+ - id: quant-first-word-from-start
+ pattern: '^\w+'
+ flavor: pcre
+ first: "Hello"
+ "yes":
+ - "Hello world"
+ - "Hello"
+ "no":
+ - " Hello"
+
+ # --- 字符类 ---
+ - id: class-vowel
+ pattern: '[aeiou]'
+ flavor: pcre
+ "yes":
+ - "a"
+ - "e"
+ - "regex"
+ "no":
+ - "b"
+ - "A"
+
+ - id: class-punct
+ pattern: '[.?!]'
+ flavor: pcre
+ "yes":
+ - "."
+ - "?"
+ - "!"
+ "no":
+ - ","
+ - " "
+
+ - id: class-digit
+ pattern: '[0-9]'
+ flavor: pcre
+ "yes":
+ - "0"
+ - "9"
+ "no":
+ - "a"
+
+ - id: class-word-ascii
+ pattern: '[A-Za-z0-9_]'
+ flavor: pcre
+ "yes":
+ - "A"
+ - "z"
+ - "0"
+ - "_"
+ "no":
+ - "-"
+ - " "
+ - "汉"
+
+ - id: class-phone-loose
+ pattern: '\(?0\d{2}[) -]?\d{8}'
+ flavor: pcre
+ note: 字符类版偏松;配对错误的号码也可能命中,故不放进 no
+ "yes":
+ - "(010)88886666"
+ - "022-22334455"
+ - "02912345678"
+ "no":
+ - "0376-1234567"
+
+ # --- 分枝条件 ---
+ - id: branch-two-landline-formats
+ pattern: '0\d{2}-\d{8}|0\d{3}-\d{7}'
+ flavor: pcre
+ "yes":
+ - "010-12345678"
+ - "0376-1234567"
+ "no":
+ - "01012345678"
+ - "010-1234567"
+
+ - id: branch-area3-optional-parens-anchored
+ pattern: '^(?:\(0\d{2}\)[- ]?\d{8}|0\d{2}[- ]?\d{8})$'
+ flavor: pcre
+ fullmatch: true
+ note: 正文里用 ^$ 才能拒绝括号不配对的串
+ "yes":
+ - "(010)88886666"
+ - "010-22334455"
+ - "02912345678"
+ "no":
+ - "010)12345678"
+ - "(022-87654321"
+
+ - id: branch-us-zip-correct-order
+ pattern: '^(?:\d{5}-\d{4}|\d{5})$'
+ flavor: pcre
+ fullmatch: true
+ "yes":
+ - "12345"
+ - "12345-6789"
+ "no":
+ - "1234"
+ - "12345-678"
+
+ - id: branch-us-zip-wrong-order-first-match
+ pattern: '\d{5}|\d{5}-\d{4}'
+ flavor: pcre
+ first: "12345"
+ note: 更具体的分枝在右时,查找往往只吃到前 5 位
+ "yes":
+ - "12345-6789"
+
+ # --- 分组 / IPv4 ---
+ - id: group-ipv4-loose
+ pattern: '(\d{1,3}\.){3}\d{1,3}'
+ flavor: pcre
+ note: 教学简化版会放过 256.300.888.999
+ "yes":
+ - "192.168.1.1"
+ - "0.0.0.0"
+ - "256.300.888.999"
+ "no":
+ - "192.168.1"
+ - "abc.def.ghi.jkl"
+
+ - id: group-ipv4-strict-octet
+ pattern: '^((2[0-4]\d|25[0-5]|[01]?\d\d?)\.){3}(2[0-4]\d|25[0-5]|[01]?\d\d?)$'
+ flavor: pcre
+ fullmatch: true
+ "yes":
+ - "192.168.0.1"
+ - "255.255.255.255"
+ - "01.02.03.04"
+ "no":
+ - "256.1.1.1"
+ - "1.1.1.999"
+ - "1.1.1"
+
+ # --- 反义 ---
+ - id: negated-non-space
+ pattern: '\S+'
+ flavor: pcre
+ "yes":
+ - "hello"
+ - "a_b-1"
+ "no":
+ - " "
+ - "\t"
+
+ - id: negated-a-tag
+ pattern: ']+>'
+ flavor: pcre
+ "yes":
+ - ""
+ - ""
+ "no":
+ - ""
+ - ""
+
+ # --- 反向引用 ---
+ - id: backref-repeated-word
+ pattern: '\b(\w+)\b\s+\1\b'
+ flavor: pcre
+ "yes":
+ - "go go"
+ - "kitty kitty"
+ - "go go"
+ "no":
+ - "go to"
+ - "go Go"
+
+ - id: backref-repeated-word-named
+ pattern: '\b(?\w+)\b\s+\k\b'
+ flavor: pcre
+ note: PCRE 命名组 + \k
+ "yes":
+ - "go go"
+ "no":
+ - "go to"
+
+ # --- 环视 ---
+ - id: lookaround-stem-before-ing
+ pattern: '\b\w+(?=ing\b)'
+ flavor: pcre
+ first: "sing"
+ all:
+ - "sing"
+ - "danc"
+ "yes":
+ - "I'm singing while you are dancing"
+ - "singing"
+ "no":
+ - "ing"
+
+ - id: lookaround-after-re-prefix
+ pattern: '(?<=\bre)\w+\b'
+ flavor: pcre
+ first: "ading"
+ "yes":
+ - "reading"
+ "no":
+ - "bread"
+
+ - id: lookaround-digits-between-spaces
+ pattern: '(?<=\s)\d+(?=\s)'
+ flavor: pcre
+ first: "42"
+ "yes":
+ - " 42 "
+ "no":
+ - "42"
+ - "42."
+
+ - id: lookaround-q-not-followed-by-u
+ pattern: '\b\w*q(?!u)\w*\b'
+ flavor: pcre
+ "yes":
+ - "Iraq"
+ - "Benq"
+ - "qat"
+ "no":
+ - "quick"
+ - "equal"
+
+ - id: lookaround-three-digits-not-more
+ pattern: '\d{3}(?!\d)'
+ flavor: pcre
+ first: "123"
+ note: 1234 里第一次命中会滑到后三位 234,所以不把它放进 no
+ "yes":
+ - "abc123"
+ - "123."
+ "no":
+ - "ab"
+
+ - id: lookaround-word-without-abc
+ pattern: '\b(?:(?!abc)\w)+\b'
+ flavor: pcre
+ "yes":
+ - "ab"
+ - "axbc"
+ - "word"
+ "no":
+ - "abc"
+ - "xabcy"
+
+ - id: lookaround-seven-digits-not-after-lower
+ pattern: '(?(.*?)\1>'
+ flavor: pcre
+ first: "bold"
+ note: 正文提供的不依赖变长后行的写法;内容在第 2 组
+ "yes":
+ - "bold"
+ "no":
+ - "bold"
+ - "y
"
+
+ - id: lookaround-var-lookbehind-html
+ pattern: '(?<=<(\w+)>).*?(?=\1>)'
+ flavor: pcre
+ skip: true
+ note: 变长且含捕获的后行;regex101 的 PCRE2 可试,CI 用的 pcre2 绑定会拒绝无限长后行,故不执行
+
+ # --- 贪婪与懒惰 ---
+ - id: greedy-a-dotstar-b
+ pattern: 'a.*b'
+ flavor: pcre
+ first: "aabab"
+ all:
+ - "aabab"
+ "yes":
+ - "aabab"
+
+ - id: lazy-a-dotstar-b
+ pattern: 'a.*?b'
+ flavor: pcre
+ first: "aab"
+ all:
+ - "aab"
+ - "ab"
+ "yes":
+ - "aabab"
+
+ - id: greedy-b-tags
+ pattern: '.*'
+ flavor: pcre
+ first: "onetwo"
+ all:
+ - "onetwo"
+ "yes":
+ - "onetwo"
+
+ - id: lazy-b-tags
+ pattern: '.*?'
+ flavor: pcre
+ first: "one"
+ all:
+ - "one"
+ - "two"
+ "yes":
+ - "onetwo"
+
+ # --- 标志 ---
+ - id: flags-inline-ignorecase-windows
+ pattern: '(?i)windows\d+'
+ flavor: pcre
+ "yes":
+ - "Windows11"
+ - "windows11"
+ - "WINDOWS11"
+ "no":
+ - "window11"
+
+ - id: flags-i-windows
+ pattern: 'windows\d+'
+ flavor: pcre
+ flags: i
+ "yes":
+ - "Windows11"
+ - "windows11"
+ "no":
+ - "window11"
+
+ - id: comment-ipv4-octet
+ pattern: '2[0-4]\d(?#200-249)|25[0-5](?#250-255)|[01]?\d\d?(?#0-199)'
+ flavor: pcre
+ fullmatch: true
+ note: (?#…) 内嵌注释不参与匹配
+ "yes":
+ - "200"
+ - "255"
+ - "199"
+ - "01"
+ "no":
+ - "256"
+ - "abc"
+
+ # --- 常用实战模式 ---
+ - id: cookbook-email
+ pattern: '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'
+ flavor: pcre
+ fullmatch: true
+ "yes":
+ - "user@example.com"
+ - "a.b-c@mail.co"
+ "no":
+ - "user@"
+ - "@example.com"
+ - "user@.com"
+ - "user@example"
+
+ - id: cookbook-cn-mobile
+ pattern: '^1[3-9]\d{9}$'
+ flavor: pcre
+ fullmatch: true
+ "yes":
+ - "13812345678"
+ - "19900001111"
+ "no":
+ - "12812345678"
+ - "1381234567"
+ - "138123456789"
+ - "138-1234-5678"
+
+ - id: cookbook-date-ymd
+ pattern: '^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$'
+ flavor: pcre
+ fullmatch: true
+ note: 不管闰年与大小月,2026-02-31 也会过
+ "yes":
+ - "2026-09-15"
+ - "2026-01-01"
+ - "2026-12-31"
+ - "2026-02-31"
+ "no":
+ - "2026-13-01"
+ - "2026-09-32"
+ - "26-09-15"
+ - "2026/09/15"
+
+ - id: cookbook-url-httpish
+ pattern: '^https?://[A-Za-z0-9.-]+(?::\d{1,5})?(?:/[^\s]*)?$'
+ flavor: pcre
+ fullmatch: true
+ "yes":
+ - "https://example.com"
+ - "http://example.com/path"
+ - "https://example.com:8080/a?x=1"
+ "no":
+ - "ftp://example.com"
+ - "example.com"
+ - "https://"
+ - "https://example.com/has space"
+
+ - id: cookbook-integer
+ pattern: '^-?\d+$'
+ flavor: pcre
+ fullmatch: true
+ "yes":
+ - "0"
+ - "-42"
+ - "2026"
+ - "007"
+ "no":
+ - "3.14"
+ - "01a"
+ - "+3"
+ - ""
+
+ - id: cookbook-decimal-required
+ pattern: '^-?\d+\.\d+$'
+ flavor: pcre
+ fullmatch: true
+ "yes":
+ - "3.14"
+ - "-0.5"
+ - "0.0"
+ "no":
+ - ".5"
+ - "3."
+ - "42"
+ - "3.14.15"
+
+ - id: cookbook-integer-or-decimal
+ pattern: '^-?\d+(?:\.\d+)?$'
+ flavor: pcre
+ fullmatch: true
+ "yes":
+ - "42"
+ - "3.14"
+ - "-0.5"
+ "no":
+ - ".5"
+ - "3."
+
+ # --- ReDoS:只作文档,不执行 ---
+ - id: redos-nested-plus-doc-only
+ pattern: '^(a+)+$'
+ flavor: pcre
+ skip: true
+ note: 灾难性回溯教学示例,不在 CI 中执行,避免把嵌套量词当计时炸弹
+ "yes":
+ - "aaaaaaa"
+ "no":
+ - "aaaaaaaaaaaaaaaaaaaaX"
diff --git a/requirements-ci.txt b/requirements-ci.txt
new file mode 100644
index 0000000..4d3fd3a
--- /dev/null
+++ b/requirements-ci.txt
@@ -0,0 +1,5 @@
+# 仅用于校验 examples.yml,不是给业务应用装的依赖。
+# pcre2:PCRE2 绑定(比标准库 re、比 regex 库更接近本文默认引擎)。
+# PyYAML:解析 examples.yml。
+pcre2==0.7.1
+PyYAML==6.0.2
diff --git a/scripts/check_examples.py b/scripts/check_examples.py
new file mode 100755
index 0000000..f723bc1
--- /dev/null
+++ b/scripts/check_examples.py
@@ -0,0 +1,301 @@
+#!/usr/bin/env python3
+"""校验 examples.yml 是否与 PCRE2 行为一致。
+
+本仓库正文按通用语法 / PCRE 风格讲解。CI 使用 Python 包 pcre2
+(捆绑 libpcre2),并默认加上 ASCII,使 \\w / \\d / \\b 接近文中的
+PCRE 默认(不含汉字)。这仍不是 regex101 上每一个开关的完整复刻:
+变长且含捕获的后行断言、.NET 平衡组、故意的灾难性回溯模式不会执行。
+
+依赖:见仓库根目录 requirements-ci.txt。
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+from typing import Any, Mapping
+
+try:
+ import pcre2
+ import yaml
+except ImportError as exc: # pragma: no cover - 环境问题在启动时就能发现
+ sys.stderr.write(
+ "缺少依赖:{}\n请先执行: python3 -m pip install -r requirements-ci.txt\n".format(
+ exc
+ )
+ )
+ sys.exit(2)
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_EXAMPLES = REPO_ROOT / "examples.yml"
+
+# YAML 1.1 会把未加引号的键 yes/no 读成 true/false。
+YES_KEYS = ("yes", True)
+NO_KEYS = ("no", False)
+
+FLAG_MAP = {
+ "i": pcre2.IGNORECASE,
+ "m": pcre2.MULTILINE,
+ "s": pcre2.DOTALL,
+ "x": pcre2.VERBOSE,
+ "a": pcre2.ASCII,
+ "u": pcre2.UNICODE,
+}
+
+ALLOWED_FLAVORS = {"pcre", "pcre2"}
+
+
+class CheckError(Exception):
+ """单条用例失败(继续跑其余用例,最后汇总退出码)。"""
+
+
+class SchemaError(Exception):
+ """清单格式错误(应立即停)。"""
+
+
+def _first_present(case: Mapping[str, Any], keys: tuple[Any, ...]) -> Any:
+ for key in keys:
+ if key in case:
+ return case[key]
+ return None
+
+
+def _as_str_list(value: Any, *, field: str, case_id: str) -> list[str]:
+ if value is None:
+ return []
+ if not isinstance(value, list):
+ raise SchemaError(
+ "{}: 字段 {} 必须是字符串列表,实际是 {}".format(
+ case_id, field, type(value).__name__
+ )
+ )
+ out: list[str] = []
+ for i, item in enumerate(value):
+ if not isinstance(item, str):
+ raise SchemaError(
+ "{}: {}[{}] 必须是加引号的字符串,避免 YAML 把数字/yes/no "
+ "变成别的类型;实际是 {}: {!r}".format(
+ case_id, field, i, type(item).__name__, item
+ )
+ )
+ out.append(item)
+ return out
+
+
+def _flags_from_str(flags_str: Any, *, case_id: str) -> int:
+ """默认 ASCII(PCRE 默认 \\w)。flags 里写 u 则改走 Unicode。"""
+ if flags_str is None:
+ flags_str = ""
+ if not isinstance(flags_str, str):
+ raise SchemaError(
+ "{}: flags 必须是字符串,例如 i 或 im".format(case_id)
+ )
+ flags = pcre2.ASCII
+ for ch in flags_str:
+ if ch == "u":
+ flags &= ~pcre2.ASCII
+ flags |= pcre2.UNICODE
+ continue
+ if ch not in FLAG_MAP:
+ raise SchemaError("{}: 不认识的 flags 字符 {!r}".format(case_id, ch))
+ flags |= FLAG_MAP[ch]
+ return flags
+
+
+def _compile(pattern: str, flags: int, *, case_id: str) -> pcre2.Pattern:
+ try:
+ return pcre2.compile(pattern, flags)
+ except pcre2.PatternError as exc:
+ raise CheckError("{}: 模式编译失败: {}".format(case_id, exc)) from exc
+
+
+def _search(compiled: pcre2.Pattern, text: str) -> Any:
+ return compiled.search(text)
+
+
+def _matched(compiled: pcre2.Pattern, text: str, fullmatch: bool) -> bool:
+ if fullmatch:
+ return compiled.fullmatch(text) is not None
+ return _search(compiled, text) is not None
+
+
+def _all_matches(compiled: pcre2.Pattern, text: str) -> list[str]:
+ return [m.group(0) for m in compiled.finditer(text)]
+
+
+def check_case(case: Mapping[str, Any], index: int) -> str:
+ """成功返回 'pass' 或 'skip';失败抛 CheckError / SchemaError。"""
+ if not isinstance(case, Mapping):
+ raise SchemaError("cases[{}] 必须是映射".format(index))
+
+ case_id = case.get("id")
+ if not isinstance(case_id, str) or not case_id.strip():
+ raise SchemaError("cases[{}] 缺少字符串 id".format(index))
+
+ if case.get("skip"):
+ return "skip"
+
+ pattern = case.get("pattern")
+ if not isinstance(pattern, str) or pattern == "":
+ raise SchemaError("{}: 缺少非空 pattern".format(case_id))
+
+ flavor = case.get("flavor", "pcre")
+ if flavor not in ALLOWED_FLAVORS:
+ raise SchemaError(
+ "{}: flavor={!r} 不是 CI 会执行的 pcre/pcre2;"
+ "请改 flavor 或设 skip: true".format(case_id, flavor)
+ )
+
+ yes = _as_str_list(_first_present(case, YES_KEYS), field="yes", case_id=case_id)
+ no = _as_str_list(_first_present(case, NO_KEYS), field="no", case_id=case_id)
+ if "first" in case and not isinstance(case.get("first"), str):
+ raise SchemaError("{}: first 必须是字符串".format(case_id))
+ if "all" in case and (
+ not isinstance(case.get("all"), list)
+ or any(not isinstance(x, str) for x in case["all"])
+ ):
+ raise SchemaError("{}: all 必须是字符串列表".format(case_id))
+
+ if not yes and not no and "first" not in case and "all" not in case:
+ raise SchemaError("{}: yes/no/first/all 至少要有一项".format(case_id))
+
+ fullmatch = bool(case.get("fullmatch", False))
+ compiled = _compile(
+ pattern, _flags_from_str(case.get("flags"), case_id=case_id), case_id=case_id
+ )
+
+ for sample in yes:
+ if not _matched(compiled, sample, fullmatch):
+ kind = "整串匹配" if fullmatch else "查找"
+ raise CheckError(
+ "{}: 应为匹配({}): {!r}".format(case_id, kind, sample)
+ )
+
+ for sample in no:
+ if _matched(compiled, sample, fullmatch):
+ kind = "整串匹配" if fullmatch else "查找"
+ raise CheckError(
+ "{}: 应为不匹配({}): {!r}".format(case_id, kind, sample)
+ )
+
+ if "first" in case:
+ if not yes:
+ raise SchemaError("{}: 写了 first 时 yes 不能为空".format(case_id))
+ got = _search(compiled, yes[0])
+ if got is None:
+ raise CheckError(
+ "{}: 无法取第一次匹配,yes[0]={!r} 没有命中".format(
+ case_id, yes[0]
+ )
+ )
+ actual = got.group(0)
+ expected = case["first"]
+ if actual != expected:
+ raise CheckError(
+ "{}: 第一次匹配应为 {!r},实际 {!r}(在 {!r} 上)".format(
+ case_id, expected, actual, yes[0]
+ )
+ )
+
+ if "all" in case:
+ if not yes:
+ raise SchemaError("{}: 写了 all 时 yes 不能为空".format(case_id))
+ actual_all = _all_matches(compiled, yes[0])
+ expected_all = case["all"]
+ if actual_all != expected_all:
+ raise CheckError(
+ "{}: 全部非重叠命中应为 {},实际 {}(在 {!r} 上)".format(
+ case_id, expected_all, actual_all, yes[0]
+ )
+ )
+
+ return "pass"
+
+
+def load_cases(path: Path) -> list[Mapping[str, Any]]:
+ try:
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
+ except OSError as exc:
+ raise SchemaError("无法读取 {}: {}".format(path, exc)) from exc
+ except yaml.YAMLError as exc:
+ raise SchemaError("YAML 解析失败: {}".format(exc)) from exc
+
+ if not isinstance(raw, Mapping) or "cases" not in raw:
+ raise SchemaError("{} 的顶层必须是含 cases 列表的映射".format(path))
+ cases = raw["cases"]
+ if not isinstance(cases, list) or not cases:
+ raise SchemaError("cases 必须是非空列表")
+ return cases
+
+
+def run(path: Path) -> int:
+ print(
+ "engine: pcre2 {} (libpcre2 {}) default flags: ASCII".format(
+ pcre2.__version__, pcre2.__libpcre2_version__
+ )
+ )
+ print("file: {}".format(path))
+
+ try:
+ cases = load_cases(path)
+ except SchemaError as exc:
+ print("SCHEMA:", exc, file=sys.stderr)
+ return 2
+
+ passed = skipped = failed = 0
+ seen_ids: set[str] = set()
+
+ for index, case in enumerate(cases):
+ try:
+ if isinstance(case, Mapping):
+ cid = case.get("id")
+ if isinstance(cid, str):
+ if cid in seen_ids:
+ raise SchemaError("重复的 id: {}".format(cid))
+ seen_ids.add(cid)
+ result = check_case(case, index)
+ except SchemaError as exc:
+ print("SCHEMA:", exc, file=sys.stderr)
+ return 2
+ except CheckError as exc:
+ print("FAIL:", exc)
+ failed += 1
+ continue
+
+ case_id = case.get("id", "?")
+ if result == "skip":
+ skipped += 1
+ note = case.get("note") or "skip: true"
+ print("SKIP: {} ({})".format(case_id, note))
+ else:
+ passed += 1
+
+ print(
+ "passed: {} skipped: {} failed: {} total: {}".format(
+ passed, skipped, failed, len(cases)
+ )
+ )
+ return 1 if failed else 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description="用 PCRE2 校验 examples.yml 中的教学用例。"
+ )
+ parser.add_argument(
+ "examples",
+ nargs="?",
+ default=str(DEFAULT_EXAMPLES),
+ help="用例文件(默认:仓库根目录 examples.yml)",
+ )
+ args = parser.parse_args(argv)
+ path = Path(args.examples)
+ if not path.is_file():
+ print("找不到文件: {}".format(path), file=sys.stderr)
+ return 2
+ return run(path)
+
+
+if __name__ == "__main__":
+ sys.exit(main())