From 9e922ca39df8a88ac1176295752218c75c9f99e8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 10:46:32 +0000 Subject: [PATCH 1/2] Add verifiable examples.yml, PCRE2 checker, and GitHub Actions. Phase 4 keeps the repo documentation-first: teaching cases from the README cookbook and core sections are listed in examples.yml and checked with the pcre2 Python package (real PCRE2, ASCII \w default). ReDoS and unbounded capturing lookbehind are documented but skipped in CI. Co-authored-by: Elven_xu <799835984@qq.com> --- .github/workflows/ci.yml | 29 ++ .gitignore | 7 + README.md | 24 +- examples.yml | 593 ++++++++++++++++++++++++++++++++++++++ requirements-ci.txt | 5 + scripts/check_examples.py | 301 +++++++++++++++++++ 6 files changed, 958 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 examples.yml create mode 100644 requirements-ci.txt create mode 100755 scripts/check_examples.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..60bde88 --- /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@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + 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()) From 31fa2d8a5d603c7f84455393ad25c8f6add448b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 10:47:19 +0000 Subject: [PATCH 2/2] Bump Actions to checkout v5 and setup-python v6. Avoid the Node.js 20 deprecation warning on ubuntu-latest. Co-authored-by: Elven_xu <799835984@qq.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60bde88..663ee18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" cache: pip