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+)>).*?(?=)`)同样跳过:CI 用的 PCRE2 绑定会拒绝无限长后行;请用旁边那条不依赖后行的 `<(\w+)>(.*?)` 来对拍,或到 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: '(?(.*?)' + flavor: pcre + first: "bold" + note: 正文提供的不依赖变长后行的写法;内容在第 2 组 + "yes": + - "bold" + "no": + - "bold" + - "
y
" + + - id: lookaround-var-lookbehind-html + pattern: '(?<=<(\w+)>).*?(?=)' + 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())