diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..489d4da --- /dev/null +++ b/TODO.md @@ -0,0 +1,9 @@ +# TODO + +- Revisit bare metavariable kind inference. A bare name used in both an + identifier-only position and an expression position is currently inferred as + `id` for every occurrence. For example, `$obj = $obj + $_` looks as though + `$obj` captures an arbitrary expression, but the assignment target makes it + an identifier capture. This inference may be too broad. Consider requiring + an explicit kind for cross-position reuse, improving the diagnostic, or + adding a storage-path metavariable kind. diff --git a/cli/cli_args.mbt b/cli/cli_args.mbt index 77fefe1..3cdbb19 100644 --- a/cli/cli_args.mbt +++ b/cli/cli_args.mbt @@ -291,13 +291,18 @@ fn parse_scan_pattern_specs( exit_code=2, ) } - patterns.push({ shape: argv[index + 1], guards: Map([]) }) + patterns.push({ + shape: argv[index + 1], + guards: Map([]), + match_mode: Default, + }) has_guard.push(false) index += 2 } else if arg.has_prefix("--pattern=") { patterns.push({ shape: arg["--pattern=".length():].to_owned(), guards: Map([]), + match_mode: Default, }) has_guard.push(false) index += 1 @@ -336,7 +341,7 @@ fn attach_scan_guard( index -= 1 if !has_guard[index] { let shape = patterns[index].shape - patterns[index] = { shape, guards } + patterns[index] = { shape, guards, match_mode: Default } has_guard[index] = true return } @@ -354,7 +359,7 @@ fn parse_cli_guard_map(source : String) -> Map[String, String] raise { let docs = @yaml.Yaml::load_from_string(source) catch { err => raise CliError::Usage( - message="invalid --guard YAML: \{to_repr(err)}", + message="invalid --guard YAML: \{Repr(err)}", exit_code=2, ) } diff --git a/cli/dump_command.mbt b/cli/dump_command.mbt index b3fbccb..35be2dc 100644 --- a/cli/dump_command.mbt +++ b/cli/dump_command.mbt @@ -11,12 +11,12 @@ pub fn render_dump_command( ///| fn render_dump_impl_debug(source : String) -> String raise { - "\{to_repr(@untyped_ast.from_impl(parse_dump_impl(source)))}" + "\{Repr(@untyped_ast.from_impl(parse_dump_impl(source)))}" } ///| fn render_dump_expr_debug(source : String) -> String raise { - "\{to_repr(@untyped_ast.from_expr(parse_dump_expr(source)))}" + "\{Repr(@untyped_ast.from_expr(parse_dump_expr(source)))}" } ///| diff --git a/cli/scan.mbt b/cli/scan.mbt index 54f5176..d6fc985 100644 --- a/cli/scan.mbt +++ b/cli/scan.mbt @@ -336,8 +336,8 @@ fn anonymous_pattern_rule( rule_id: pattern.shape, description: "Anonymous CLI pattern.", definition: Structural({ - inside_expr: None, - inside_toplevel: None, + inside_expr: [], + inside_toplevel: [], patterns: [pattern], patterns_not: [], patterns_not_mode: PruneOnNegative, diff --git a/cli/scan_wbtest.mbt b/cli/scan_wbtest.mbt index 41217cd..0b1fc15 100644 --- a/cli/scan_wbtest.mbt +++ b/cli/scan_wbtest.mbt @@ -93,6 +93,7 @@ fn guarded_cli_pattern_scan_options() -> CliOptions { { shape: "$(callee:id)($(value:const))", guards: { "$callee": "^@html\\.render$", "$value": "raw" }, + match_mode: Default, }, ], scan_root: "testdata/guard", diff --git a/docs/RuleSpec.md b/docs/RuleSpec.md index 5f864fd..dcca246 100644 --- a/docs/RuleSpec.md +++ b/docs/RuleSpec.md @@ -66,11 +66,12 @@ Only these top-level keys are accepted: - `patterns` (optional for structural rules): non-empty YAML array - `patterns-not` (optional for structural rules): non-empty YAML array using the same object schema as `patterns` -- `inside-expr` (optional for structural rules): YAML mapping using the same - `shape` and optional `guard` schema as `patterns`, used as an outer context -- `inside-toplevel` (optional for structural rules): YAML mapping using the - same `shape` and optional `guard` schema as `inside-expr`; its shape is parsed - as one MoonBit top-level item +- `inside-expr` (optional for structural rules): non-empty YAML array using the + same `shape` and optional `guard` object schema as `patterns`; entries are + ordered alternative outer expression contexts +- `inside-toplevel` (optional for structural rules): non-empty YAML array using + the same `shape` and optional `guard` keys as `inside-expr`, plus optional + `match-mode`; each shape is parsed as one MoonBit top-level item - `taint` (required for taint rules): YAML mapping Unknown top-level keys are rejected. @@ -94,23 +95,27 @@ including trailing newlines produced by block scalars. ### Pattern Objects -Structural entries in `patterns`, `patterns-not`, `inside-expr`, and -`inside-toplevel` use this object schema. +Structural entries in `patterns`, `patterns-not`, and `inside-expr` use this +object schema. `inside-toplevel` adds the `match-mode` key described below. Only these keys are accepted: -- `shape` (required): YAML string containing one MoonBit expression snippet +- `shape` (required): YAML string containing one MoonBit expression snippet, + or one top-level item for `inside-toplevel` - `guard` (optional): YAML mapping from `$`-prefixed capture name to regex string +- `match-mode` (optional, `inside-toplevel` only): `exact` or `partial` Unknown keys inside a pattern object are rejected. +`match-mode` is rejected in `patterns`, `patterns-not`, `inside-expr`, and +taint clauses. Taint `sources`, `sinks`, and `sanitizers` use the same `shape` key. A `guard` is invalid in these entries. ## Shapes -An ordinary `shape` must be a single MoonBit expression snippet. -`inside-toplevel.shape` must be exactly one MoonBit top-level item. +An ordinary `shape` must be a single MoonBit expression snippet. Each +`inside-toplevel` entry's `shape` must be exactly one MoonBit top-level item. Valid expression shapes include calls, method calls, field accesses, operators, blocks, conditionals, loops, matches, lambdas, collection literals, @@ -118,9 +123,9 @@ record expressions, and other expression-sized MoonBit syntax. Ordinary `patterns`, `patterns-not`, and `inside-expr` shapes are not a whole file, top-level declaration, package fragment, or import list. -`inside-toplevel.shape` is parsed as one top-level item, such as a function, -top-level `let`, `test`, method `impl`, view, or top-level expression. It is -one item and cannot represent a whole file or import list. +Each `inside-toplevel` shape is parsed as one top-level item, such as a +function, top-level `let`, `test`, method `impl`, view, or top-level expression. +It is one item and cannot represent a whole file or import list. Shapes are structural: @@ -129,7 +134,9 @@ Shapes are structural: - operators match literally - call and method-call argument kinds, labels, order, and arity must match - type annotations and type names in matched syntax must match where present -- source locations, formatting, and comments do not participate in matching +- source locations and formatting do not participate in matching; top-level + documentation is an AST field and follows the `inside-toplevel` matching + mode The scanner does not type-check shapes and does not resolve names semantically. For example, two imported names that refer to the same definition compare as @@ -714,47 +721,56 @@ context. It may be used with `patterns`, with `patterns-not`, or with both. ```yaml id: wrapped-target description: | - Match a target call only inside wrapper(...). + Match a target call inside either supported context. inside-expr: - shape: wrapper($(prefix:exp), __TARGET__) + - shape: wrapper($(prefix:exp), __TARGET__) + - shape: container($(prefix:exp), __TARGET__) patterns: - shape: target.call($(prefix:exp)) ``` -`inside-expr` is a YAML mapping. Its `shape` is parsed as one MoonBit -expression snippet, and its optional `guard` filters `id` and `const` captures -declared by that outer shape. +`inside-expr` is a non-empty YAML array of pattern objects. Each `shape` is +parsed as one MoonBit expression snippet, and its optional `guard` filters `id` +and `const` captures declared by that outer shape. Entries are ordered +alternatives. Additional rules: -- `inside-expr` must contain exactly one `__TARGET__` occurrence in a - binding-capable position. +- Every `inside-expr` entry must contain exactly one `__TARGET__` occurrence in + a binding-capable position. - `__TARGET__` must occupy a whole expression position, such as a whole call argument, receiver, or block expression. If it appears only as a label or other non-expression value, no target subtree can be searched. - `__TARGET__` is reserved and must not be used as an metavar name. - Entries in `patterns` and `patterns-not` must not contain `__TARGET__` in a binding-capable position. -- Metavars declared by `inside-expr` remain visible when matching the - inner pattern entries; inner shapes reference them by repeating the same - metavar form. -- Inner `patterns` and `patterns-not` must not use a visible `inside-expr` - metavar name with a different kind. +- Captures declared by the selected `inside-expr` entry remain visible when + matching the inner pattern entries; inner shapes reference them by repeating + the same metavar form. +- Any capture reused by an inner `patterns` or `patterns-not` entry must be + declared by every `inside-expr` alternative with the same kind. This includes + named ellipsis captures and their ellipsis kinds. Outer captures that are not + referenced by an inner entry may differ between alternatives. Runtime behavior: -- the current expression is first matched against `inside-expr` -- if it matches, the subtree captured by `__TARGET__` is searched +- the current expression tries eligible `inside-expr` entries in YAML order +- an entry whose shape does not match, or whose guard fails, falls through to + the next entry +- the first entry whose shape and guard both match selects the captured + `__TARGET__` subtree and bindings +- once an entry is selected, later alternatives are not tried even if inner + matching produces no finding - when `patterns` is present, each expression in the captured subtree first tries the ordered positive patterns using the bindings established by - `inside-expr`; a positive hit is recorded and its matched subtree covers any - nested negative matches + the selected outer entry; a positive hit is recorded and its matched subtree + covers any nested negative matches - when `patterns` and `patterns-not` are both present, a candidate that fails all positive patterns is then checked against `patterns-not` using the - `inside-expr` bindings; a negative match outside a positive-hit subtree + the selected outer bindings; a negative match outside a positive-hit subtree rejects the whole outer match - when `patterns` is absent, every expression in the captured subtree is - checked against `patterns-not` using the `inside-expr` bindings; if none of + checked against `patterns-not` using the selected outer bindings; if none of them match, the outer expression produces one hit - if an inner positive or negative pattern references an inherited `id` capture with the same inline `$(name:id)` form, that candidate is skipped when the @@ -769,39 +785,79 @@ outer expression do not produce additional findings. With only `patterns-not`, ### `inside-toplevel` -`inside-toplevel` restricts a structural rule to matches inside one MoonBit -top-level item. It uses the same object schema and target-subtree semantics as -`inside-expr`. Its `shape` is parsed as exactly one top-level item, not as an -expression. +`inside-toplevel` restricts a structural rule to matches inside selected +MoonBit top-level items. It is a non-empty ordered array using the same object +schema and target-subtree semantics as `inside-expr`. Each entry's `shape` is +parsed as exactly one top-level item, not as an expression. ```yaml id: safe-function-target description: | Match calls only in selected top-level functions. inside-toplevel: - shape: | - fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } - guard: - $name: "^safe_" + - shape: | + fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } + guard: + $name: "^safe_" patterns: - shape: call($(param:id)) ``` +`match-mode` is resolved independently for every ordered alternative: + +| `match-mode` | Shape item | Effective matching | +| --- | --- | --- | +| omitted | function definition | `partial` | +| omitted | any other top-level item | `exact` | +| `exact` | any top-level item | `exact` | +| `partial` | function definition | `partial` | +| `partial` | any other top-level item | compile error | + +Exact matching compares the complete parsed top-level AST, preserving the +behavior used before function shapes became partial by default: + +```yaml +inside-toplevel: + - shape: | + fn $(name:id) { __TARGET__ } + match-mode: exact +``` + +Partial matching is limited to function definitions. It always matches the +function name, body, and `__TARGET__` exactly. The following function-header +fields are ignored only when the shape leaves them in their default form: + +- type qualifier, `async`, parameter list, type parameters, return type, error + type, visibility, attributes, and documentation +- the top-level `where` clause + +Writing any such field keeps it exact. For example, `fn f()` requires an +explicit empty parameter list, `pub fn` requires public visibility, and +`async fn`, a return type, `noraise`, type parameters, documentation, +attributes, or a `where` clause constrain the candidate exactly. + +Migration note: an older rule that depended on an omitted function-header +field being absent must add `match-mode: exact`. Broad function-context rules +can remain unmarked and use the new partial default. + Additional rules: - `inside-toplevel` and `inside-expr` are mutually exclusive. -- `inside-toplevel` must contain exactly one `__TARGET__` occurrence in a - binding-capable expression position within the top-level item. +- Every `inside-toplevel` entry must contain exactly one `__TARGET__` + occurrence in a binding-capable expression position within the top-level + item. - The top-level item itself may declare `id` and `const` captures, and its optional `guard` may filter those captures. -- Metavars declared by `inside-toplevel` remain visible to inner `patterns` and - `patterns-not`, using the same inherited binding and kind-consistency rules as - `inside-expr`. +- Captures declared by the selected `inside-toplevel` entry remain visible to + inner `patterns` and `patterns-not`, using the same all-alternatives + declaration and kind-consistency rules as `inside-expr`. - `inside-toplevel` is not supported on taint rules. -The candidate top-level item is first matched against `inside-toplevel`; if it -matches, the expression subtree captured by `__TARGET__` is searched with the -same inherited-binding and negative-coverage behavior as `inside-expr`. +The candidate top-level item tries eligible `inside-toplevel` entries in YAML +order. The first entry whose shape and guard both match selects the target and +bindings; later alternatives are not tried after selection. The expression +subtree captured by `__TARGET__` is searched with the same inherited-binding +and negative-coverage behavior as `inside-expr`. Reporting differs: with `patterns`, every inner positive hit produces a finding whose `loc` is the inner match location. With only `patterns-not`, one finding is produced at the matched top-level item location. @@ -933,8 +989,11 @@ A rule set or rule file is rejected when any of these conditions occurs: - `inside-expr` appears on a taint rule - `inside-toplevel` appears on a taint rule - both `inside-expr` and `inside-toplevel` appear -- `inside-expr` has a non-mapping value -- `inside-toplevel` has a non-mapping value +- `inside-expr` or `inside-toplevel` is not an array or is empty +- an `inside-expr` or `inside-toplevel` entry is not a mapping +- `match-mode` appears outside an `inside-toplevel` entry +- `match-mode` is not `exact` or `partial` +- `match-mode: partial` is used with a non-function top-level shape - `inside-expr` is present without `patterns` or `patterns-not` - `inside-toplevel` is present without `patterns` or `patterns-not` - `patterns` is not an array or is empty @@ -953,7 +1012,8 @@ A rule set or rule file is rejected when any of these conditions occurs: - `guard` appears in any taint clause - `metavars` appears in any pattern object - `shape` is not valid as one MoonBit expression -- `inside-toplevel.shape` is not exactly one valid MoonBit top-level item +- an `inside-toplevel` entry's `shape` is not exactly one valid MoonBit + top-level item - a shape uses an unsupported metavar kind - a shape uses the same metavar name across multiple metavar kinds - a bare `$name` cannot be inferred to one compatible kind @@ -970,12 +1030,16 @@ A rule set or rule file is rejected when any of these conditions occurs: - an ellipsis kind is incompatible with its list position, conflicts with another typed occurrence, or shares a name with a normal metavar - a guard regex is invalid -- `inside-expr` does not contain exactly one binding-capable `__TARGET__` -- `inside-toplevel` does not contain exactly one binding-capable `__TARGET__` +- an `inside-expr` entry does not contain exactly one binding-capable + `__TARGET__` +- an `inside-toplevel` entry does not contain exactly one binding-capable + `__TARGET__` - a structural `patterns` or `patterns-not` entry contains binding-capable `__TARGET__` - a structural `patterns` or `patterns-not` entry uses an inherited `inside-expr` or `inside-toplevel` metavar name with a different kind +- a capture reused by `patterns` or `patterns-not` is missing from any outer + alternative, or a named ellipsis is declared with a different ellipsis kind - a taint source contains binding-capable `__SOURCE__` - a taint sink or sanitizer does not contain exactly one binding-capable `__SOURCE__` @@ -1022,7 +1086,7 @@ id: unsafe-wrapper description: | Match a sink only under an unsafe wrapper. inside-expr: - shape: unsafe(__TARGET__) + - shape: unsafe(__TARGET__) patterns: - shape: sink($_) ``` @@ -1056,7 +1120,7 @@ id: wrapper-without-danger description: | Match wrappers whose payload contains no danger call. inside-expr: - shape: wrapper(__TARGET__) + - shape: wrapper(__TARGET__) patterns-not: - shape: danger() ``` diff --git a/docs/RuleSpec_CN.md b/docs/RuleSpec_CN.md index 44cb727..323ab4d 100644 --- a/docs/RuleSpec_CN.md +++ b/docs/RuleSpec_CN.md @@ -53,10 +53,11 @@ rules/security/nested/raw.yml with id: unsafe-html -> security/nested/unsafe-htm - `description`(必需):YAML 字符串 - `patterns`(结构规则可选):非空 YAML 数组 - `patterns-not`(结构规则可选):与 `patterns` 使用相同条目 schema 的非空 YAML 数组 -- `inside-expr`(结构规则可选):YAML 映射,使用与 `patterns` 相同的 - `shape` 和可选 `guard` schema,作为外层上下文 -- `inside-toplevel`(结构规则可选):YAML 映射,使用与 `inside-expr` - 相同的 `shape` 和可选 `guard` schema;它的 shape 解析为一个 MoonBit 顶层项 +- `inside-expr`(结构规则可选):非空 YAML 数组,使用与 `patterns` + 相同的 `shape` 和可选 `guard` 对象 schema;条目是有序的外层表达式备选项 +- `inside-toplevel`(结构规则可选):非空 YAML 数组,使用与 + `inside-expr` 相同的 `shape` 和可选 `guard`,并额外支持可选的 + `match-mode`;每个 shape 解析为一个 MoonBit 顶层项 - `taint`(污点规则必需):YAML 映射 未知顶层键会被拒绝。 @@ -78,24 +79,28 @@ rules/security/nested/raw.yml with id: unsafe-html -> security/nested/unsafe-htm ### Pattern Objects -结构规则中的 `patterns`、`patterns-not` 条目以及 `inside-expr`、 -`inside-toplevel` 使用以下对象 schema。 +结构规则中的 `patterns`、`patterns-not` 和 `inside-expr` 使用以下对象 +schema。`inside-toplevel` 额外支持下文说明的 `match-mode`。 只接受这些键: -- `shape`(必需):包含一个 MoonBit 表达式片段的 YAML 字符串 +- `shape`(必需):包含一个 MoonBit 表达式片段的 YAML 字符串; + `inside-toplevel` 中则包含一个顶层项 - `guard`(可选):从 `$` 前缀捕获名到正则字符串的 YAML 映射 +- `match-mode`(可选,仅限 `inside-toplevel`):`exact` 或 `partial` pattern object 中的未知键会被拒绝。 +`patterns`、`patterns-not`、`inside-expr` 和 taint 子句中出现 +`match-mode` 会被拒绝。 Taint `sources`、`sinks` 和 `sanitizers` 同样使用 `shape` 键。这些字段不接受 `guard`。 ## Shapes -普通 `shape` 必须是一个单独的 MoonBit 表达式片段。 -`inside-toplevel.shape` 必须且只能是一个 MoonBit 顶层项。 +普通 `shape` 必须是一个单独的 MoonBit 表达式片段。每个 +`inside-toplevel` 条目的 `shape` 必须且只能是一个 MoonBit 顶层项。 -有效的表达式 shape 包括调用、方法调用、字段访问、操作符、块、条件表达式、循环、match、lambda、集合字面量、记录表达式,以及其他表达式大小的 MoonBit 语法。普通 `patterns`、`patterns-not` 和 `inside-expr` shape 不表示整个文件、顶层声明、包片段或 import 列表。`inside-toplevel.shape` 可以是一个函数、顶层 `let`、`test`、方法 `impl`、view 或顶层表达式。它只能表示一个顶层项,不能表示整个文件或 import 列表。 +有效的表达式 shape 包括调用、方法调用、字段访问、操作符、块、条件表达式、循环、match、lambda、集合字面量、记录表达式,以及其他表达式大小的 MoonBit 语法。普通 `patterns`、`patterns-not` 和 `inside-expr` shape 不表示整个文件、顶层声明、包片段或 import 列表。每个 `inside-toplevel` shape 可以是一个函数、顶层 `let`、`test`、方法 `impl`、view 或顶层表达式。它只能表示一个顶层项,不能表示整个文件或 import 列表。 shape 是结构性的: @@ -104,7 +109,8 @@ shape 是结构性的: - 操作符按字面匹配 - 调用和方法调用的参数种类、标签、顺序和数量必须匹配 - 匹配语法中出现的类型注解和类型名必须匹配 -- 源码位置、格式和注释不参与匹配 +- 源码位置和格式不参与匹配;顶层文档是 AST 字段,遵循 + `inside-toplevel` 的匹配模式 扫描器不会对 shape 做类型检查,也不会按语义解析名称。例如,两个指向同一定义的导入名称只在解析后的源码拼写一致或被元变量捕获时视为相同。 @@ -571,35 +577,41 @@ patterns-not: ```yaml id: wrapped-target description: | - Match a target call only inside wrapper(...). + Match a target call inside either supported context. inside-expr: - shape: wrapper($(prefix:exp), __TARGET__) + - shape: wrapper($(prefix:exp), __TARGET__) + - shape: container($(prefix:exp), __TARGET__) patterns: - shape: target.call($(prefix:exp)) ``` -`inside-expr` 是 YAML 映射。它的 `shape` 会作为一个 MoonBit 表达式片段解析; -可选 `guard` 会过滤这个外层 shape 声明的 `id` 和 `const` 捕获。 +`inside-expr` 是由 pattern object 组成的非空 YAML 数组。每个 `shape` +都会作为一个 MoonBit 表达式片段解析;可选 `guard` 会过滤该外层 shape +声明的 `id` 和 `const` 捕获。数组条目是有序备选项。 额外规则: -- `inside-expr` 必须在可绑定位置包含且只包含一个 `__TARGET__`。 +- 每个 `inside-expr` 条目都必须在可绑定位置包含且只包含一个 + `__TARGET__`。 - `__TARGET__` 必须占据一个完整表达式位置,例如完整调用参数、receiver 或块表达式。如果它只作为标签或其他非表达式值出现,就没有目标子树可供搜索。 - `__TARGET__` 是保留名称,不能用作内联元变量名。 - `patterns` 和 `patterns-not` 条目不能在可绑定位置包含 `__TARGET__`。 -- `inside-expr` 声明的内联元变量在匹配内部 `patterns` 和 +- 选中的 `inside-expr` 条目所声明的捕获在匹配内部 `patterns` 和 `patterns-not` 时可见;内部 shape 通过重复相同的内联元变量形式引用它们。 -- 内部 `patterns` 和 `patterns-not` 不能用不同 kind 使用已经从 - `inside-expr` 可见的元变量名。 +- 内部 `patterns` 或 `patterns-not` 复用的每个捕获,都必须由所有 + `inside-expr` 备选项以相同 kind 声明;这也适用于命名 ellipsis + 捕获及其 ellipsis kind。未被内部条目引用的额外外层捕获可以因备选项而异。 运行时行为: -- 当前表达式首先与 `inside-expr` 匹配 -- 如果匹配成功,会搜索由 `__TARGET__` 捕获的子树 +- 当前表达式按 YAML 顺序尝试可用的 `inside-expr` 条目 +- shape 不匹配或 guard 失败时继续尝试下一项 +- 首个同时通过 shape 和 guard 的条目会选定 `__TARGET__` 子树和绑定 +- 一旦选定条目,即使内部匹配没有产生 finding,也不会尝试后续外层备选项 - 当存在 `patterns` 时,捕获子树中的每个表达式会先用 - `inside-expr` 建立的绑定运行有序正向 pattern;正向命中会被记录,其命中子树会覆盖嵌套的负向匹配 -- 当同时存在 `patterns` 和 `patterns-not` 时,只有正向 pattern 全部失败的候选才会用 `inside-expr` 绑定检查 `patterns-not`;正向命中子树之外的负向命中会拒绝整个外层匹配 -- 当不存在 `patterns` 时,捕获子树中的每个表达式都会用 `inside-expr` 绑定检查 `patterns-not`;如果没有任何负向 pattern 匹配,外层表达式产生一个命中 + 选中条目建立的绑定运行有序正向 pattern;正向命中会被记录,其命中子树会覆盖嵌套的负向匹配 +- 当同时存在 `patterns` 和 `patterns-not` 时,只有正向 pattern 全部失败的候选才会用选中的外层绑定检查 `patterns-not`;正向命中子树之外的负向命中会拒绝整个外层匹配 +- 当不存在 `patterns` 时,捕获子树中的每个表达式都会用选中的外层绑定检查 `patterns-not`;如果没有任何负向 pattern 匹配,外层表达式产生一个命中 - 如果内部 pattern 通过相同的 `$(name:id)` inline 形式引用了继承来的 `id` 捕获,并且从 `__TARGET__` 到候选表达式的路径上出现了同名(按规范化后的 identifier 名称计算)的词法绑定,则跳过该候选 每个成功匹配的外层表达式最多产生一个 finding,其 `loc` 是外层表达式位置。 @@ -609,38 +621,74 @@ patterns: ### `inside-toplevel` -`inside-toplevel` 将结构规则限制在某个 MoonBit 顶层项内部匹配。它使用与 -`inside-expr` 相同的对象 schema 和 target 子树语义。它的 `shape` -解析为且只能解析为一个顶层项,不解析为表达式。 +`inside-toplevel` 将结构规则限制在选定的 MoonBit 顶层项内部匹配。它是 +非空有序数组,使用与 `inside-expr` 相同的对象 schema 和 target 子树语义。 +每个条目的 `shape` 解析为且只能解析为一个顶层项,不解析为表达式。 ```yaml id: safe-function-target description: | Match calls only in selected top-level functions. inside-toplevel: - shape: | - fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } - guard: - $name: "^safe_" + - shape: | + fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } + guard: + $name: "^safe_" patterns: - shape: call($(param:id)) ``` +每个有序备选项会独立解析 `match-mode`: + +| `match-mode` | shape 顶层项 | 实际匹配模式 | +| --- | --- | --- | +| 省略 | 函数定义 | `partial` | +| 省略 | 其他顶层项 | `exact` | +| `exact` | 任意顶层项 | `exact` | +| `partial` | 函数定义 | `partial` | +| `partial` | 其他顶层项 | 编译错误 | + +Exact 会比较完整的顶层 AST,保留函数 shape 改为默认 partial 之前的行为: + +```yaml +inside-toplevel: + - shape: | + fn $(name:id) { __TARGET__ } + match-mode: exact +``` + +Partial 第一版只支持函数定义。函数名、函数体和 `__TARGET__` 始终精确 +匹配。只有当 shape 将以下函数头字段保持为缺省形态时,才会忽略它们: + +- 类型限定、`async`、参数列表、类型参数、返回类型、错误类型、可见性、 + attribute 和文档 +- 顶层 `where` 子句 + +一旦写出字段,它仍然精确匹配。例如,`fn f()` 要求显式空参数列表, +`pub fn` 要求 public 可见性;`async fn`、返回类型、`noraise`、类型参数、 +文档、attribute 或 `where` 子句也都会约束候选项。 + +迁移提示:旧规则如果依赖“函数头中省略的字段必须不存在”,需要添加 +`match-mode: exact`。宽泛的函数上下文规则可以继续省略标注,使用新的 +partial 默认值。 + 额外规则: - `inside-toplevel` 和 `inside-expr` 互斥。 -- `inside-toplevel` 必须在顶层项内的可绑定表达式位置包含且只包含一个 - `__TARGET__`。 +- 每个 `inside-toplevel` 条目都必须在顶层项内的可绑定表达式位置包含且 + 只包含一个 `__TARGET__`。 - 顶层项本身可以声明 `id` 和 `const` 捕获,可选 `guard` 可以过滤这些捕获。 -- `inside-toplevel` 声明的内联元变量在内部 `patterns` 和 `patterns-not` - 中保持可见,并使用与 `inside-expr` 相同的继承绑定和 kind 一致性规则。 +- 选中的 `inside-toplevel` 条目声明的捕获在内部 `patterns` 和 + `patterns-not` 中保持可见,并使用与 `inside-expr` 相同的所有备选项 + 声明及 kind 一致性规则。 - taint 规则不支持 `inside-toplevel`。 -候选顶层项先与 `inside-toplevel` 匹配;如果匹配成功,会在 -`__TARGET__` 捕获到的表达式子树中继续搜索,并沿用 `inside-expr` -的继承绑定和负向覆盖行为。报告方式不同:带 `patterns` 时,每个内部正向 -命中都会产生一个 finding,其 `loc` 是内部匹配位置;只有 -`patterns-not` 时,会在匹配到的顶层项位置产生一个 finding。 +候选顶层项按 YAML 顺序尝试可用的 `inside-toplevel` 条目。首个同时通过 +shape 和 guard 的条目会选定 target 与绑定;选定后不会再尝试后续条目。 +随后会在 `__TARGET__` 捕获到的表达式子树中继续搜索,并沿用 +`inside-expr` 的继承绑定和负向覆盖行为。报告方式不同:带 `patterns` +时,每个内部正向命中都会产生一个 finding,其 `loc` 是内部匹配位置; +只有 `patterns-not` 时,会在匹配到的顶层项位置产生一个 finding。 ## 污点规则 @@ -753,8 +801,11 @@ taint 命中报告的 pattern index 是匹配 sink 条目的零基索引。 - taint 规则中出现 `inside-expr` - taint 规则中出现 `inside-toplevel` - 同时出现 `inside-expr` 和 `inside-toplevel` -- `inside-expr` 的值不是映射 -- `inside-toplevel` 的值不是映射 +- `inside-expr` 或 `inside-toplevel` 不是数组或为空 +- `inside-expr` 或 `inside-toplevel` 条目不是映射 +- `match-mode` 出现在 `inside-toplevel` 条目之外 +- `match-mode` 的值不是 `exact` 或 `partial` +- 非函数顶层 shape 使用 `match-mode: partial` - `inside-expr` 出现时没有 `patterns` 或 `patterns-not` - `inside-toplevel` 出现时没有 `patterns` 或 `patterns-not` - `patterns` 不是数组或为空 @@ -773,7 +824,7 @@ taint 命中报告的 pattern index 是匹配 sink 条目的零基索引。 - taint 子句中出现 `guard` - 任何 pattern object 中出现 `metavars` - `shape` 不是一个有效的 MoonBit 表达式 -- `inside-toplevel.shape` 不是且只有一个有效的 MoonBit 顶层项 +- `inside-toplevel` 条目的 `shape` 不是且只有一个有效的 MoonBit 顶层项 - shape 使用不支持的内联元变量 kind - shape 跨多个元变量 kind 使用同一个内联元变量名 - 裸 `$name` 无法推导为一个兼容的 kind @@ -788,11 +839,13 @@ taint 命中报告的 pattern index 是匹配 sink 条目的零基索引。 - ellipsis 没有占据完整的无字段名有序列表项 - ellipsis kind 与列表位置不兼容、与另一个 typed occurrence 冲突,或和普通元变量共用名称 - guard 正则无效 -- `inside-expr` 没有且只有一个可绑定的 `__TARGET__` -- `inside-toplevel` 没有且只有一个可绑定的 `__TARGET__` +- `inside-expr` 条目没有且只有一个可绑定的 `__TARGET__` +- `inside-toplevel` 条目没有且只有一个可绑定的 `__TARGET__` - 结构规则的 `patterns` 或 `patterns-not` 条目包含可绑定的 `__TARGET__` - 结构规则的 `patterns` 或 `patterns-not` 条目用不同 kind 使用了继承自 `inside-expr` 或 `inside-toplevel` 的元变量名 +- `patterns` 或 `patterns-not` 复用的捕获在某个外层备选项中缺失,或命名 + ellipsis 在不同备选项中使用了不同 ellipsis kind - taint source 包含可绑定的 `__SOURCE__` - taint sink 或 sanitizer 没有且只有一个可绑定的 `__SOURCE__` - taint sink 或 sanitizer 没有将 `__SOURCE__` 放在整个 receiver 或整个参数值的位置 @@ -835,7 +888,7 @@ id: unsafe-wrapper description: | Match a sink only under an unsafe wrapper. inside-expr: - shape: unsafe(__TARGET__) + - shape: unsafe(__TARGET__) patterns: - shape: sink($_) ``` diff --git a/docs/WritingRules.md b/docs/WritingRules.md index 423a4cc..6e26b61 100644 --- a/docs/WritingRules.md +++ b/docs/WritingRules.md @@ -41,10 +41,12 @@ contains no negative match. Unknown keys are rejected at every schema level: top-level rule keys, `taint` keys, and rule clause keys. -Structural rules may also add one optional outer context. `inside-expr` filters -an outer expression. `inside-toplevel` filters one MoonBit top-level item. Both -bind outer inline captures and then search the captured `__TARGET__` -expression subtree with the inner `patterns` or `patterns-not`. +Structural rules may also add one optional outer-context field. `inside-expr` +filters outer expressions. `inside-toplevel` filters MoonBit top-level items. +Each field is a non-empty array of ordered alternative pattern objects. Both +bind captures from the first matching outer alternative and then search its +captured `__TARGET__` expression subtree with the inner `patterns` or +`patterns-not`. ## Mental Model @@ -67,25 +69,28 @@ bodies, and applies structural rules to those expression subtrees. - All patterns in one rule share the same rule id and `description`. - Structural pattern objects may use `guard` to regex-filter `id` and `const` captures after shape matching. -- If `inside-expr` or `inside-toplevel` is present, it runs first on the - current outer candidate. If it captures `__TARGET__` as an expression and - `patterns` are present, each candidate in that target subtree tries positives - first, then `patterns-not` only after a positive miss. If there are no - `patterns`, the outer context is reported only when no negative pattern - matches anywhere in the target subtree. +- If `inside-expr` or `inside-toplevel` is present, eligible outer entries are + tried in YAML order on the current candidate. The first entry whose shape and + guard match selects `__TARGET__` and its bindings. A guard failure falls + through, but after selection an inner miss does not try later outer entries. + If `patterns` are present, each candidate in the selected target subtree + tries positives first, then `patterns-not` only after a positive miss. If + there are no `patterns`, the outer context is reported only when no negative + pattern matches anywhere in the target subtree. - When an outer-context rule has both positive and negative patterns, positive hits cover all nested negative-shaped uses by default. Any uncovered negative match rejects the outer context. -- Inline captures declared by `inside-expr` or `inside-toplevel` stay visible - to inner `patterns` and `patterns-not`; `__TARGET__` only - selects the expression subtree to traverse and must not be used by inner - pattern entries. +- Inline captures declared by the selected `inside-expr` or + `inside-toplevel` entry stay visible to inner `patterns` and `patterns-not`; + `__TARGET__` only selects the expression subtree to traverse and must not be + used by inner pattern entries. - Inherited `id` captures follow lexical shadowing. If an inner pattern refers to an outer `id` capture and the path to a candidate expression crosses a local binder with the same normalized identifier, that candidate is skipped. - Inner positive and negative patterns reuse names from the outer context by - repeating the same metavar form; the same name with a different kind is - rejected. + repeating the same metavar form. Every outer alternative must declare each + reused name with the same kind, including named ellipsis captures. Extra + outer captures that no inner entry references may differ by alternative. - An `inside-expr` outer match reports at most one hit. Its `loc` is the outer expression location; when inner positive patterns match, the first hit in traversal order determines `pattern_index`. @@ -410,10 +415,12 @@ captures. ```yaml id: wrapped-target description: | - Match a call only when it appears inside a specific wrapper. + Match a call only when it appears inside a supported context. inside-expr: - shape: | - wrapper($(prefix:exp), __TARGET__) + - shape: | + wrapper($(prefix:exp), __TARGET__) + - shape: | + container($(prefix:exp), __TARGET__) patterns: - shape: | target.call($(prefix:exp)) @@ -421,10 +428,11 @@ patterns: Rules for `inside-expr`: -- it uses the same `shape` and optional `guard` schema as one structural - pattern -- it must place exactly one supported `__TARGET__`; place it where a whole - expression is expected so runtime traversal can search that subtree +- it is a non-empty array using the same `shape` and optional `guard` object + schema as `patterns` +- entries are ordered alternatives; the first shape and guard match is selected +- every entry must place exactly one supported `__TARGET__`; place it where a + whole expression is expected so runtime traversal can search that subtree - `__TARGET__` is reserved and must not be used as an metavar name - inner `patterns` and `patterns-not` must not contain `__TARGET__`; the target placeholder selects the subtree to search and creates no binding for inner @@ -432,8 +440,12 @@ Rules for `inside-expr`: - inherited `id` captures observe lexical shadowing inside the searched target subtree - inner `patterns` and `patterns-not` reference outer captures by repeating the - same metavar form, such as `$(prefix:exp)`; using the same name with a - different kind is rejected + same metavar form, such as `$(prefix:exp)`; every outer alternative must + declare a reused capture with the same kind +- branch-local outer captures are allowed when inner entries do not reference + them +- a failed outer guard tries the next entry, but a selected entry is not + replaced when its target subtree produces no finding - each matching outer expression reports at most one hit at the outer expression location; if multiple inner positive patterns match, the first hit in traversal order determines `pattern_index` @@ -446,19 +458,48 @@ id: safe-function-target description: | Match a call only in selected top-level functions. inside-toplevel: - shape: | - fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } - guard: - $name: "^safe_" + - shape: | + fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } + guard: + $name: "^safe_" patterns: - shape: call($(param:id)) ``` -`inside-toplevel` and `inside-expr` are mutually exclusive. Its `shape` must be -exactly one MoonBit top-level item and must place exactly one supported -`__TARGET__` in an expression position within that item. Positive hits report -their inner match locations. With only `patterns-not`, `loc` is the top-level -item location. +`inside-toplevel` and `inside-expr` are mutually exclusive. +`inside-toplevel` is also a non-empty ordered array. Every entry's `shape` must +be exactly one MoonBit top-level item and must place exactly one supported +`__TARGET__` in an expression position within that item. It uses the same +selection and shared-capture rules as `inside-expr`. Positive hits report their +inner match locations. With only `patterns-not`, `loc` is the top-level item +location. + +Function shapes use partial matching by default; every other top-level item +uses exact matching by default: + +| `match-mode` | Function shape | Other top-level shape | +| --- | --- | --- | +| omitted | partial | exact | +| `exact` | exact | exact | +| `partial` | partial | invalid | + +In partial mode, omitted function-header details are unconstrained: type +qualifier, `async`, parameters, type parameters, return and error types, +visibility, attributes, documentation, and `where`. Any detail written in the +shape remains exact. The function name, body, and `__TARGET__` are never +relaxed. + +Use explicit exact matching when the complete header matters: + +```yaml +inside-toplevel: + - shape: | + fn $(name:id) { __TARGET__ } + match-mode: exact +``` + +When migrating an older rule, add `match-mode: exact` if it relied on an +omitted function field being absent. Leave broad function contexts unmarked. ### 3.6 Use `patterns-not` to prune blocked branches @@ -491,7 +532,7 @@ id: wrapper-without-danger description: | Wrapper payload contains no danger call. inside-expr: - shape: wrapper(__TARGET__) + - shape: wrapper(__TARGET__) patterns-not: - shape: danger() ``` @@ -505,7 +546,7 @@ covered positive subtrees rejects the outer context. ```yaml inside-expr: - shape: wrapper($(counter:id), __TARGET__) + - shape: wrapper($(counter:id), __TARGET__) patterns: - shape: arr[$(counter:id)] patterns-not: @@ -640,6 +681,10 @@ ordinary `patterns`, `patterns-not`, and `inside-expr`, reduce it to one valid expression-sized shape, then build back up carefully. For `inside-toplevel`, reduce it to exactly one valid top-level item. +If a function context now matches more declarations than expected, check +whether the rule relied on the old complete-header behavior. Add +`match-mode: exact`, or write only the header fields that should remain exact. + ### Rule compilation rejects an metavar Check, in order: diff --git a/docs/WritingRules_CN.md b/docs/WritingRules_CN.md index 710c60b..04cea61 100644 --- a/docs/WritingRules_CN.md +++ b/docs/WritingRules_CN.md @@ -22,8 +22,10 @@ YAML 规则文件是扫描器的输入。规则根目录可以是通过 `--rules `patterns` 必须是非空数组。未知键会在每个 schema 层级被拒绝:顶层规则键、`taint` 键和规则子句键。 -结构规则还可以添加一个可选外层上下文:`inside-expr` 过滤外层表达式, -`inside-toplevel` 过滤一个 MoonBit 顶层项。两者都会绑定外层内联捕获,然后使用内部 `patterns` 或 `patterns-not` 搜索捕获到的 `__TARGET__` 表达式子树。 +结构规则还可以添加一个可选的外层上下文字段:`inside-expr` 过滤外层表达式, +`inside-toplevel` 过滤 MoonBit 顶层项。每个字段都是由 pattern object +组成的非空有序备选数组。两者都会使用首个匹配的外层条目绑定捕获,然后使用 +内部 `patterns` 或 `patterns-not` 搜索其 `__TARGET__` 表达式子树。 ## 心智模型 @@ -39,12 +41,12 @@ YAML 规则文件是扫描器的输入。规则根目录可以是通过 `--rules - 同一规则中的所有 pattern 共享相同的规则 id 和 `description`。 - 结构规则的 pattern object 可以使用 `guard`,在 shape 匹配后用正则过滤 `id` 和 `const` 捕获。 -- 如果存在 `inside-expr` 或 `inside-toplevel`,它会先在当前外层候选上运行。如果它将 `__TARGET__` 捕获为表达式且存在 `patterns`,目标子树中的每个候选都会先运行正向 pattern,只有正向失败后才检查 `patterns-not`。如果没有 `patterns`,只有整个目标子树没有负向匹配时才报告外层上下文。 +- 如果存在 `inside-expr` 或 `inside-toplevel`,当前候选会按 YAML 顺序尝试可用的外层条目。首个同时通过 shape 和 guard 的条目会选定 `__TARGET__` 与绑定。guard 失败会继续尝试下一项,但一旦选中,即使内部没有命中也不会回退到后续外层条目。存在 `patterns` 时,选中目标子树中的每个候选都会先运行正向 pattern,只有正向失败后才检查 `patterns-not`。如果没有 `patterns`,只有整个目标子树没有负向匹配时才报告外层上下文。 - 当外层上下文规则同时有正向和负向 pattern 时,正向命中会默认覆盖其中嵌套的负向形状用法;任何未被覆盖的负向命中都会拒绝外层上下文。 -- `inside-expr` 或 `inside-toplevel` 声明的内联捕获对内部 +- 选中的 `inside-expr` 或 `inside-toplevel` 条目所声明的内联捕获对内部 `patterns` 和 `patterns-not` 保持可见;`__TARGET__` 只选择要遍历的表达式子树,不能被内部 pattern 使用。 - 继承来的 `id` 捕获遵守词法遮蔽;如果内部 pattern 引用了外层 `id` 捕获,并且通向候选表达式的路径上有同名(规范化后)的局部绑定,则跳过该候选。 -- 内部正向和负向 pattern 通过重复相同的内联元变量形式复用来自外层上下文的名称。同名且 kind 不同的形式会被拒绝。 +- 内部正向和负向 pattern 通过重复相同的内联元变量形式复用来自外层上下文的名称。每个外层备选项都必须以相同 kind 声明被复用的名称,包括命名 ellipsis 捕获。内部未引用的额外外层捕获可以因备选项而异。 - 每个成功匹配的 `inside-expr` 外层表达式最多报告一个命中,`loc` 是外层表达式位置;当内部正向 pattern 命中时,遍历顺序中的第一个命中决定 `pattern_index`。 - `inside-toplevel` 会在内部正向匹配位置分别报告命中;只有 `patterns-not` 时,报告匹配到的顶层项位置。 @@ -299,10 +301,12 @@ patterns: ```yaml id: wrapped-target description: | - Match a call only when it appears inside a specific wrapper. + Match a call only when it appears inside a supported context. inside-expr: - shape: | - wrapper($(prefix:exp), __TARGET__) + - shape: | + wrapper($(prefix:exp), __TARGET__) + - shape: | + container($(prefix:exp), __TARGET__) patterns: - shape: | target.call($(prefix:exp)) @@ -310,12 +314,16 @@ patterns: `inside-expr` 的规则: -- 它使用与一个结构 pattern 相同的 `shape` 和可选 `guard` schema -- 它必须放置且只放置一个支持的 `__TARGET__`;请将其放在期望完整表达式的位置,使运行时遍历可以搜索该子树 +- 它是非空数组,使用与 `patterns` 相同的 `shape` 和可选 `guard` + 对象 schema +- 条目是有序备选项,首个同时匹配 shape 和 guard 的条目会被选中 +- 每个条目都必须放置且只放置一个支持的 `__TARGET__`;请将其放在期望完整表达式的位置,使运行时遍历可以搜索该子树 - `__TARGET__` 是保留名称,不能用作内联元变量名 - 内部 `patterns` 和 `patterns-not` 不能包含 `__TARGET__`;target placeholder 选择要搜索的子树,不为内部 shape 创建绑定 - 继承来的 `id` 捕获在被搜索的 target 子树内遵守词法遮蔽 -- 内部 `patterns` 和 `patterns-not` 通过重复相同的内联元变量形式引用外层捕获,例如 `$(prefix:exp)`;同名且 kind 不同的形式会被拒绝 +- 内部 `patterns` 和 `patterns-not` 通过重复相同的内联元变量形式引用外层捕获,例如 `$(prefix:exp)`;每个外层备选项都必须以相同 kind 声明被复用的捕获 +- 内部条目未引用的分支局部外层捕获允许不同 +- 外层 guard 失败时会尝试下一项;条目一旦选中,即使 target 子树没有产生 finding,也不会替换成后续项 - 每个匹配成功的外层表达式最多在该外层表达式位置报告一个命中;如果有多个内部正向 pattern 命中,遍历顺序中的第一个命中决定 `pattern_index` 当上下文是顶层项时,使用 `inside-toplevel`: @@ -325,18 +333,44 @@ id: safe-function-target description: | Match a call only in selected top-level functions. inside-toplevel: - shape: | - fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } - guard: - $name: "^safe_" + - shape: | + fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } + guard: + $name: "^safe_" patterns: - shape: call($(param:id)) ``` -`inside-toplevel` 和 `inside-expr` 互斥。它的 `shape` 必须且只能是一个 -MoonBit 顶层项,并且必须在该顶层项内部的表达式位置放置且只放置一个支持的 -`__TARGET__`。带正向 `patterns` 的命中报告内部匹配位置;只有 -`patterns-not` 时,`loc` 是顶层项位置。 +`inside-toplevel` 和 `inside-expr` 互斥。`inside-toplevel` 同样是非空 +有序数组。每个条目的 `shape` 必须且只能是一个 MoonBit 顶层项,并且必须在 +该顶层项内部的表达式位置放置且只放置一个支持的 `__TARGET__`。它使用与 +`inside-expr` 相同的选择和共享捕获规则。带正向 `patterns` 的命中报告内部 +匹配位置;只有 `patterns-not` 时,`loc` 是顶层项位置。 + +函数 shape 默认使用 partial;其他顶层项默认使用 exact: + +| `match-mode` | 函数 shape | 其他顶层 shape | +| --- | --- | --- | +| 省略 | partial | exact | +| `exact` | exact | exact | +| `partial` | partial | 非法 | + +在 partial 模式中,函数头里省略的类型限定、`async`、参数、类型参数、 +返回类型、错误类型、可见性、attribute、文档和 `where` 都不约束候选项。 +shape 中一旦写出某个字段,该字段仍然精确匹配。函数名、函数体和 +`__TARGET__` 永远不会自动放宽。 + +完整函数头确实重要时,显式使用 exact: + +```yaml +inside-toplevel: + - shape: | + fn $(name:id) { __TARGET__ } + match-mode: exact +``` + +迁移旧规则时,如果规则依赖“省略的函数字段必须不存在”,请添加 +`match-mode: exact`。宽泛的函数上下文保持不标注即可。 ### 3.6 使用 `patterns-not` 剪枝禁止的子树 @@ -362,7 +396,7 @@ id: wrapper-without-danger description: | Wrapper payload contains no danger call. inside-expr: - shape: wrapper(__TARGET__) + - shape: wrapper(__TARGET__) patterns-not: - shape: danger() ``` @@ -374,7 +408,7 @@ patterns-not: ```yaml inside-expr: - shape: wrapper($(counter:id), __TARGET__) + - shape: wrapper($(counter:id), __TARGET__) patterns: - shape: arr[$(counter:id)] patterns-not: @@ -498,6 +532,9 @@ patterns: `patterns-not` 和 `inside-expr`,先缩减到一个有效的表达式大小 shape,然后小心地逐步补回结构。对 `inside-toplevel`,先缩减到且仅有一个合法顶层项。 +如果函数上下文现在命中了更多声明,请检查旧规则是否依赖完整函数头匹配。 +可以添加 `match-mode: exact`,或只写出确实需要精确匹配的函数头字段。 + ### 规则编译提示元变量语法无效 按顺序检查: diff --git a/docs/rule_spec.mbt b/docs/rule_spec.mbt index 5fa8842..e2a3bc7 100644 --- a/docs/rule_spec.mbt +++ b/docs/rule_spec.mbt @@ -70,11 +70,12 @@ let _embed_rulespec_md : String = #|- `patterns` (optional for structural rules): non-empty YAML array #|- `patterns-not` (optional for structural rules): non-empty YAML array using #| the same object schema as `patterns` - #|- `inside-expr` (optional for structural rules): YAML mapping using the same - #| `shape` and optional `guard` schema as `patterns`, used as an outer context - #|- `inside-toplevel` (optional for structural rules): YAML mapping using the - #| same `shape` and optional `guard` schema as `inside-expr`; its shape is parsed - #| as one MoonBit top-level item + #|- `inside-expr` (optional for structural rules): non-empty YAML array using the + #| same `shape` and optional `guard` object schema as `patterns`; entries are + #| ordered alternative outer expression contexts + #|- `inside-toplevel` (optional for structural rules): non-empty YAML array using + #| the same `shape` and optional `guard` keys as `inside-expr`, plus optional + #| `match-mode`; each shape is parsed as one MoonBit top-level item #|- `taint` (required for taint rules): YAML mapping #| #|Unknown top-level keys are rejected. @@ -98,23 +99,27 @@ let _embed_rulespec_md : String = #| #|### Pattern Objects #| - #|Structural entries in `patterns`, `patterns-not`, `inside-expr`, and - #|`inside-toplevel` use this object schema. + #|Structural entries in `patterns`, `patterns-not`, and `inside-expr` use this + #|object schema. `inside-toplevel` adds the `match-mode` key described below. #| #|Only these keys are accepted: #| - #|- `shape` (required): YAML string containing one MoonBit expression snippet + #|- `shape` (required): YAML string containing one MoonBit expression snippet, + #| or one top-level item for `inside-toplevel` #|- `guard` (optional): YAML mapping from `$`-prefixed capture name to regex string + #|- `match-mode` (optional, `inside-toplevel` only): `exact` or `partial` #| #|Unknown keys inside a pattern object are rejected. + #|`match-mode` is rejected in `patterns`, `patterns-not`, `inside-expr`, and + #|taint clauses. #| #|Taint `sources`, `sinks`, and `sanitizers` use the same `shape` key. A `guard` #|is invalid in these entries. #| #|## Shapes #| - #|An ordinary `shape` must be a single MoonBit expression snippet. - #|`inside-toplevel.shape` must be exactly one MoonBit top-level item. + #|An ordinary `shape` must be a single MoonBit expression snippet. Each + #|`inside-toplevel` entry's `shape` must be exactly one MoonBit top-level item. #| #|Valid expression shapes include calls, method calls, field accesses, #|operators, blocks, conditionals, loops, matches, lambdas, collection literals, @@ -122,9 +127,9 @@ let _embed_rulespec_md : String = #|`patterns`, `patterns-not`, and `inside-expr` shapes are not a whole file, #|top-level declaration, package fragment, or import list. #| - #|`inside-toplevel.shape` is parsed as one top-level item, such as a function, - #|top-level `let`, `test`, method `impl`, view, or top-level expression. It is - #|one item and cannot represent a whole file or import list. + #|Each `inside-toplevel` shape is parsed as one top-level item, such as a + #|function, top-level `let`, `test`, method `impl`, view, or top-level expression. + #|It is one item and cannot represent a whole file or import list. #| #|Shapes are structural: #| @@ -133,7 +138,9 @@ let _embed_rulespec_md : String = #|- operators match literally #|- call and method-call argument kinds, labels, order, and arity must match #|- type annotations and type names in matched syntax must match where present - #|- source locations, formatting, and comments do not participate in matching + #|- source locations and formatting do not participate in matching; top-level + #| documentation is an AST field and follows the `inside-toplevel` matching + #| mode #| #|The scanner does not type-check shapes and does not resolve names semantically. #|For example, two imported names that refer to the same definition compare as @@ -718,47 +725,56 @@ let _embed_rulespec_md : String = #|```yaml #|id: wrapped-target #|description: | - #| Match a target call only inside wrapper(...). + #| Match a target call inside either supported context. #|inside-expr: - #| shape: wrapper($(prefix:exp), __TARGET__) + #| - shape: wrapper($(prefix:exp), __TARGET__) + #| - shape: container($(prefix:exp), __TARGET__) #|patterns: #| - shape: target.call($(prefix:exp)) #|``` #| - #|`inside-expr` is a YAML mapping. Its `shape` is parsed as one MoonBit - #|expression snippet, and its optional `guard` filters `id` and `const` captures - #|declared by that outer shape. + #|`inside-expr` is a non-empty YAML array of pattern objects. Each `shape` is + #|parsed as one MoonBit expression snippet, and its optional `guard` filters `id` + #|and `const` captures declared by that outer shape. Entries are ordered + #|alternatives. #| #|Additional rules: #| - #|- `inside-expr` must contain exactly one `__TARGET__` occurrence in a - #| binding-capable position. + #|- Every `inside-expr` entry must contain exactly one `__TARGET__` occurrence in + #| a binding-capable position. #|- `__TARGET__` must occupy a whole expression position, such as a whole call #| argument, receiver, or block expression. If it appears only as a label or #| other non-expression value, no target subtree can be searched. #|- `__TARGET__` is reserved and must not be used as an metavar name. #|- Entries in `patterns` and `patterns-not` must not contain `__TARGET__` in a #| binding-capable position. - #|- Metavars declared by `inside-expr` remain visible when matching the - #| inner pattern entries; inner shapes reference them by repeating the same - #| metavar form. - #|- Inner `patterns` and `patterns-not` must not use a visible `inside-expr` - #| metavar name with a different kind. + #|- Captures declared by the selected `inside-expr` entry remain visible when + #| matching the inner pattern entries; inner shapes reference them by repeating + #| the same metavar form. + #|- Any capture reused by an inner `patterns` or `patterns-not` entry must be + #| declared by every `inside-expr` alternative with the same kind. This includes + #| named ellipsis captures and their ellipsis kinds. Outer captures that are not + #| referenced by an inner entry may differ between alternatives. #| #|Runtime behavior: #| - #|- the current expression is first matched against `inside-expr` - #|- if it matches, the subtree captured by `__TARGET__` is searched + #|- the current expression tries eligible `inside-expr` entries in YAML order + #|- an entry whose shape does not match, or whose guard fails, falls through to + #| the next entry + #|- the first entry whose shape and guard both match selects the captured + #| `__TARGET__` subtree and bindings + #|- once an entry is selected, later alternatives are not tried even if inner + #| matching produces no finding #|- when `patterns` is present, each expression in the captured subtree first #| tries the ordered positive patterns using the bindings established by - #| `inside-expr`; a positive hit is recorded and its matched subtree covers any - #| nested negative matches + #| the selected outer entry; a positive hit is recorded and its matched subtree + #| covers any nested negative matches #|- when `patterns` and `patterns-not` are both present, a candidate that fails #| all positive patterns is then checked against `patterns-not` using the - #| `inside-expr` bindings; a negative match outside a positive-hit subtree + #| the selected outer bindings; a negative match outside a positive-hit subtree #| rejects the whole outer match #|- when `patterns` is absent, every expression in the captured subtree is - #| checked against `patterns-not` using the `inside-expr` bindings; if none of + #| checked against `patterns-not` using the selected outer bindings; if none of #| them match, the outer expression produces one hit #|- if an inner positive or negative pattern references an inherited `id` capture #| with the same inline `$(name:id)` form, that candidate is skipped when the @@ -773,39 +789,79 @@ let _embed_rulespec_md : String = #| #|### `inside-toplevel` #| - #|`inside-toplevel` restricts a structural rule to matches inside one MoonBit - #|top-level item. It uses the same object schema and target-subtree semantics as - #|`inside-expr`. Its `shape` is parsed as exactly one top-level item, not as an - #|expression. + #|`inside-toplevel` restricts a structural rule to matches inside selected + #|MoonBit top-level items. It is a non-empty ordered array using the same object + #|schema and target-subtree semantics as `inside-expr`. Each entry's `shape` is + #|parsed as exactly one top-level item, not as an expression. #| #|```yaml #|id: safe-function-target #|description: | #| Match calls only in selected top-level functions. #|inside-toplevel: - #| shape: | - #| fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } - #| guard: - #| $name: "^safe_" + #| - shape: | + #| fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } + #| guard: + #| $name: "^safe_" #|patterns: #| - shape: call($(param:id)) #|``` #| + #|`match-mode` is resolved independently for every ordered alternative: + #| + #|| `match-mode` | Shape item | Effective matching | + #|| --- | --- | --- | + #|| omitted | function definition | `partial` | + #|| omitted | any other top-level item | `exact` | + #|| `exact` | any top-level item | `exact` | + #|| `partial` | function definition | `partial` | + #|| `partial` | any other top-level item | compile error | + #| + #|Exact matching compares the complete parsed top-level AST, preserving the + #|behavior used before function shapes became partial by default: + #| + #|```yaml + #|inside-toplevel: + #| - shape: | + #| fn $(name:id) { __TARGET__ } + #| match-mode: exact + #|``` + #| + #|Partial matching is limited to function definitions. It always matches the + #|function name, body, and `__TARGET__` exactly. The following function-header + #|fields are ignored only when the shape leaves them in their default form: + #| + #|- type qualifier, `async`, parameter list, type parameters, return type, error + #| type, visibility, attributes, and documentation + #|- the top-level `where` clause + #| + #|Writing any such field keeps it exact. For example, `fn f()` requires an + #|explicit empty parameter list, `pub fn` requires public visibility, and + #|`async fn`, a return type, `noraise`, type parameters, documentation, + #|attributes, or a `where` clause constrain the candidate exactly. + #| + #|Migration note: an older rule that depended on an omitted function-header + #|field being absent must add `match-mode: exact`. Broad function-context rules + #|can remain unmarked and use the new partial default. + #| #|Additional rules: #| #|- `inside-toplevel` and `inside-expr` are mutually exclusive. - #|- `inside-toplevel` must contain exactly one `__TARGET__` occurrence in a - #| binding-capable expression position within the top-level item. + #|- Every `inside-toplevel` entry must contain exactly one `__TARGET__` + #| occurrence in a binding-capable expression position within the top-level + #| item. #|- The top-level item itself may declare `id` and `const` captures, and its #| optional `guard` may filter those captures. - #|- Metavars declared by `inside-toplevel` remain visible to inner `patterns` and - #| `patterns-not`, using the same inherited binding and kind-consistency rules as - #| `inside-expr`. + #|- Captures declared by the selected `inside-toplevel` entry remain visible to + #| inner `patterns` and `patterns-not`, using the same all-alternatives + #| declaration and kind-consistency rules as `inside-expr`. #|- `inside-toplevel` is not supported on taint rules. #| - #|The candidate top-level item is first matched against `inside-toplevel`; if it - #|matches, the expression subtree captured by `__TARGET__` is searched with the - #|same inherited-binding and negative-coverage behavior as `inside-expr`. + #|The candidate top-level item tries eligible `inside-toplevel` entries in YAML + #|order. The first entry whose shape and guard both match selects the target and + #|bindings; later alternatives are not tried after selection. The expression + #|subtree captured by `__TARGET__` is searched with the same inherited-binding + #|and negative-coverage behavior as `inside-expr`. #|Reporting differs: with `patterns`, every inner positive hit produces a #|finding whose `loc` is the inner match location. With only `patterns-not`, one #|finding is produced at the matched top-level item location. @@ -937,8 +993,11 @@ let _embed_rulespec_md : String = #|- `inside-expr` appears on a taint rule #|- `inside-toplevel` appears on a taint rule #|- both `inside-expr` and `inside-toplevel` appear - #|- `inside-expr` has a non-mapping value - #|- `inside-toplevel` has a non-mapping value + #|- `inside-expr` or `inside-toplevel` is not an array or is empty + #|- an `inside-expr` or `inside-toplevel` entry is not a mapping + #|- `match-mode` appears outside an `inside-toplevel` entry + #|- `match-mode` is not `exact` or `partial` + #|- `match-mode: partial` is used with a non-function top-level shape #|- `inside-expr` is present without `patterns` or `patterns-not` #|- `inside-toplevel` is present without `patterns` or `patterns-not` #|- `patterns` is not an array or is empty @@ -957,7 +1016,8 @@ let _embed_rulespec_md : String = #|- `guard` appears in any taint clause #|- `metavars` appears in any pattern object #|- `shape` is not valid as one MoonBit expression - #|- `inside-toplevel.shape` is not exactly one valid MoonBit top-level item + #|- an `inside-toplevel` entry's `shape` is not exactly one valid MoonBit + #| top-level item #|- a shape uses an unsupported metavar kind #|- a shape uses the same metavar name across multiple metavar kinds #|- a bare `$name` cannot be inferred to one compatible kind @@ -974,12 +1034,16 @@ let _embed_rulespec_md : String = #|- an ellipsis kind is incompatible with its list position, conflicts with #| another typed occurrence, or shares a name with a normal metavar #|- a guard regex is invalid - #|- `inside-expr` does not contain exactly one binding-capable `__TARGET__` - #|- `inside-toplevel` does not contain exactly one binding-capable `__TARGET__` + #|- an `inside-expr` entry does not contain exactly one binding-capable + #| `__TARGET__` + #|- an `inside-toplevel` entry does not contain exactly one binding-capable + #| `__TARGET__` #|- a structural `patterns` or `patterns-not` entry contains binding-capable #| `__TARGET__` #|- a structural `patterns` or `patterns-not` entry uses an inherited #| `inside-expr` or `inside-toplevel` metavar name with a different kind + #|- a capture reused by `patterns` or `patterns-not` is missing from any outer + #| alternative, or a named ellipsis is declared with a different ellipsis kind #|- a taint source contains binding-capable `__SOURCE__` #|- a taint sink or sanitizer does not contain exactly one binding-capable #| `__SOURCE__` @@ -1026,7 +1090,7 @@ let _embed_rulespec_md : String = #|description: | #| Match a sink only under an unsafe wrapper. #|inside-expr: - #| shape: unsafe(__TARGET__) + #| - shape: unsafe(__TARGET__) #|patterns: #| - shape: sink($_) #|``` @@ -1060,7 +1124,7 @@ let _embed_rulespec_md : String = #|description: | #| Match wrappers whose payload contains no danger call. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns-not: #| - shape: danger() #|``` diff --git a/docs/writing_rules.mbt b/docs/writing_rules.mbt index d1dfaeb..ec6466b 100644 --- a/docs/writing_rules.mbt +++ b/docs/writing_rules.mbt @@ -45,10 +45,12 @@ let _embed_writingrules_md : String = #|Unknown keys are rejected at every schema level: top-level rule keys, `taint` #|keys, and rule clause keys. #| - #|Structural rules may also add one optional outer context. `inside-expr` filters - #|an outer expression. `inside-toplevel` filters one MoonBit top-level item. Both - #|bind outer inline captures and then search the captured `__TARGET__` - #|expression subtree with the inner `patterns` or `patterns-not`. + #|Structural rules may also add one optional outer-context field. `inside-expr` + #|filters outer expressions. `inside-toplevel` filters MoonBit top-level items. + #|Each field is a non-empty array of ordered alternative pattern objects. Both + #|bind captures from the first matching outer alternative and then search its + #|captured `__TARGET__` expression subtree with the inner `patterns` or + #|`patterns-not`. #| #|## Mental Model #| @@ -71,25 +73,28 @@ let _embed_writingrules_md : String = #|- All patterns in one rule share the same rule id and `description`. #|- Structural pattern objects may use `guard` to regex-filter `id` and `const` #| captures after shape matching. - #|- If `inside-expr` or `inside-toplevel` is present, it runs first on the - #| current outer candidate. If it captures `__TARGET__` as an expression and - #| `patterns` are present, each candidate in that target subtree tries positives - #| first, then `patterns-not` only after a positive miss. If there are no - #| `patterns`, the outer context is reported only when no negative pattern - #| matches anywhere in the target subtree. + #|- If `inside-expr` or `inside-toplevel` is present, eligible outer entries are + #| tried in YAML order on the current candidate. The first entry whose shape and + #| guard match selects `__TARGET__` and its bindings. A guard failure falls + #| through, but after selection an inner miss does not try later outer entries. + #| If `patterns` are present, each candidate in the selected target subtree + #| tries positives first, then `patterns-not` only after a positive miss. If + #| there are no `patterns`, the outer context is reported only when no negative + #| pattern matches anywhere in the target subtree. #|- When an outer-context rule has both positive and negative patterns, positive #| hits cover all nested negative-shaped uses by default. Any uncovered negative #| match rejects the outer context. - #|- Inline captures declared by `inside-expr` or `inside-toplevel` stay visible - #| to inner `patterns` and `patterns-not`; `__TARGET__` only - #| selects the expression subtree to traverse and must not be used by inner - #| pattern entries. + #|- Inline captures declared by the selected `inside-expr` or + #| `inside-toplevel` entry stay visible to inner `patterns` and `patterns-not`; + #| `__TARGET__` only selects the expression subtree to traverse and must not be + #| used by inner pattern entries. #|- Inherited `id` captures follow lexical shadowing. If an inner pattern refers #| to an outer `id` capture and the path to a candidate expression crosses a #| local binder with the same normalized identifier, that candidate is skipped. #|- Inner positive and negative patterns reuse names from the outer context by - #| repeating the same metavar form; the same name with a different kind is - #| rejected. + #| repeating the same metavar form. Every outer alternative must declare each + #| reused name with the same kind, including named ellipsis captures. Extra + #| outer captures that no inner entry references may differ by alternative. #|- An `inside-expr` outer match reports at most one hit. Its `loc` is the outer #| expression location; when inner positive patterns match, the first hit in #| traversal order determines `pattern_index`. @@ -414,10 +419,12 @@ let _embed_writingrules_md : String = #|```yaml #|id: wrapped-target #|description: | - #| Match a call only when it appears inside a specific wrapper. + #| Match a call only when it appears inside a supported context. #|inside-expr: - #| shape: | - #| wrapper($(prefix:exp), __TARGET__) + #| - shape: | + #| wrapper($(prefix:exp), __TARGET__) + #| - shape: | + #| container($(prefix:exp), __TARGET__) #|patterns: #| - shape: | #| target.call($(prefix:exp)) @@ -425,10 +432,11 @@ let _embed_writingrules_md : String = #| #|Rules for `inside-expr`: #| - #|- it uses the same `shape` and optional `guard` schema as one structural - #| pattern - #|- it must place exactly one supported `__TARGET__`; place it where a whole - #| expression is expected so runtime traversal can search that subtree + #|- it is a non-empty array using the same `shape` and optional `guard` object + #| schema as `patterns` + #|- entries are ordered alternatives; the first shape and guard match is selected + #|- every entry must place exactly one supported `__TARGET__`; place it where a + #| whole expression is expected so runtime traversal can search that subtree #|- `__TARGET__` is reserved and must not be used as an metavar name #|- inner `patterns` and `patterns-not` must not contain `__TARGET__`; the target #| placeholder selects the subtree to search and creates no binding for inner @@ -436,8 +444,12 @@ let _embed_writingrules_md : String = #|- inherited `id` captures observe lexical shadowing inside the searched target #| subtree #|- inner `patterns` and `patterns-not` reference outer captures by repeating the - #| same metavar form, such as `$(prefix:exp)`; using the same name with a - #| different kind is rejected + #| same metavar form, such as `$(prefix:exp)`; every outer alternative must + #| declare a reused capture with the same kind + #|- branch-local outer captures are allowed when inner entries do not reference + #| them + #|- a failed outer guard tries the next entry, but a selected entry is not + #| replaced when its target subtree produces no finding #|- each matching outer expression reports at most one hit at the outer #| expression location; if multiple inner positive patterns match, the first #| hit in traversal order determines `pattern_index` @@ -450,19 +462,48 @@ let _embed_writingrules_md : String = #|description: | #| Match a call only in selected top-level functions. #|inside-toplevel: - #| shape: | - #| fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } - #| guard: - #| $name: "^safe_" + #| - shape: | + #| fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } + #| guard: + #| $name: "^safe_" #|patterns: #| - shape: call($(param:id)) #|``` #| - #|`inside-toplevel` and `inside-expr` are mutually exclusive. Its `shape` must be - #|exactly one MoonBit top-level item and must place exactly one supported - #|`__TARGET__` in an expression position within that item. Positive hits report - #|their inner match locations. With only `patterns-not`, `loc` is the top-level - #|item location. + #|`inside-toplevel` and `inside-expr` are mutually exclusive. + #|`inside-toplevel` is also a non-empty ordered array. Every entry's `shape` must + #|be exactly one MoonBit top-level item and must place exactly one supported + #|`__TARGET__` in an expression position within that item. It uses the same + #|selection and shared-capture rules as `inside-expr`. Positive hits report their + #|inner match locations. With only `patterns-not`, `loc` is the top-level item + #|location. + #| + #|Function shapes use partial matching by default; every other top-level item + #|uses exact matching by default: + #| + #|| `match-mode` | Function shape | Other top-level shape | + #|| --- | --- | --- | + #|| omitted | partial | exact | + #|| `exact` | exact | exact | + #|| `partial` | partial | invalid | + #| + #|In partial mode, omitted function-header details are unconstrained: type + #|qualifier, `async`, parameters, type parameters, return and error types, + #|visibility, attributes, documentation, and `where`. Any detail written in the + #|shape remains exact. The function name, body, and `__TARGET__` are never + #|relaxed. + #| + #|Use explicit exact matching when the complete header matters: + #| + #|```yaml + #|inside-toplevel: + #| - shape: | + #| fn $(name:id) { __TARGET__ } + #| match-mode: exact + #|``` + #| + #|When migrating an older rule, add `match-mode: exact` if it relied on an + #|omitted function field being absent. Leave broad function contexts unmarked. #| #|### 3.6 Use `patterns-not` to prune blocked branches #| @@ -495,7 +536,7 @@ let _embed_writingrules_md : String = #|description: | #| Wrapper payload contains no danger call. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns-not: #| - shape: danger() #|``` @@ -509,7 +550,7 @@ let _embed_writingrules_md : String = #| #|```yaml #|inside-expr: - #| shape: wrapper($(counter:id), __TARGET__) + #| - shape: wrapper($(counter:id), __TARGET__) #|patterns: #| - shape: arr[$(counter:id)] #|patterns-not: @@ -644,6 +685,10 @@ let _embed_writingrules_md : String = #|expression-sized shape, then build back up carefully. For `inside-toplevel`, #|reduce it to exactly one valid top-level item. #| + #|If a function context now matches more declarations than expected, check + #|whether the rule relied on the old complete-header behavior. Add + #|`match-mode: exact`, or write only the header fields that should remain exact. + #| #|### Rule compilation rejects an metavar #| #|Check, in order: diff --git a/e2etests/BUILTIN_RULES.md b/e2etests/BUILTIN_RULES.md index f39c24b..e4e89bc 100644 --- a/e2etests/BUILTIN_RULES.md +++ b/e2etests/BUILTIN_RULES.md @@ -47,6 +47,21 @@ source: 3 > inspect(1, content="1") 4 | } +testdata/builtin-rules-all/unnessary_else.mbt:3:3-6:12 +rule: moonbitlang/unnessary_else +description: + Found an if expression whose else branch is empty or only returns (). + Prefer omitting the unnecessary else branch. +source: +1 | ///| +2 | fn unnecessary_empty_else(flag : Bool) -> Unit { +3 > if flag { +4 > prepare() +5 > finish() +6 > } else {} +7 | } +8 | ///| + testdata/builtin-rules-all/inspect_boolean.mbt:3:3-3:32 rule: moonbitlang/inspect_boolean description: @@ -118,9 +133,23 @@ $ cd "$TESTDIR"/.. && moonrun "$TESTDIR"/moongrep.wasm -- scan --output-json --e {"file":"testdata/builtin-rules-all/catch_all.mbt","rule_id":"moonbitlang/catch_all","description":"Single catch arm handles every error, which can hide unexpected failures.\nPrefer matching only the specific error cases that can be recovered from.","range":{"start":{"line":3,"column":3},"end":{"line":5,"column":4}},"matched_source":"try risky() catch {\n _ => recover()\n }","source_context":[{"line":1,"text":"///|","is_match":false},{"line":2,"text":"async fn catches_everything(_) -> Unit {","is_match":false},{"line":3,"text":" try risky() catch {","is_match":true},{"line":4,"text":" _ => recover()","is_match":true},{"line":5,"text":" }","is_match":true},{"line":6,"text":"}","is_match":false}]} {"file":"testdata/builtin-rules-all/match_option.mbt","rule_id":"moonbitlang/match_option","description":"Found an Option value handled with match over Some and None.\nPrefer if + is for simple Option checks.","range":{"start":{"line":3,"column":3},"end":{"line":11,"column":4}},"matched_source":"match value {\n Some(inner) => {\n let prepared = prepare(inner)\n let validated = validate(prepared)\n let normalized = normalize(validated)\n finish(normalized)\n }\n None => false\n }","source_context":[{"line":1,"text":"///|","is_match":false},{"line":2,"text":"fn option_match(value : Int?) -> Bool {","is_match":false},{"line":3,"text":" match value {","is_match":true},{"line":4,"text":" Some(inner) => {","is_match":true},{"line":5,"text":" let prepared = prepare(inner)","is_match":true},{"line":6,"text":" let validated = validate(prepared)","is_match":true},{"line":7,"text":" let normalized = normalize(validated)","is_match":true},{"line":8,"text":" finish(normalized)","is_match":true},{"line":9,"text":" }","is_match":true},{"line":10,"text":" None => false","is_match":true},{"line":11,"text":" }","is_match":true},{"line":12,"text":"}","is_match":false}]} {"file":"testdata/builtin-rules-all/inspect_number.mbt","rule_id":"moonbitlang/inspect_number","description":"Found inspect() snapshots whose expected value is a plain number.\nPrefer numeric assertions for numeric checks.","range":{"start":{"line":3,"column":3},"end":{"line":3,"column":26}},"matched_source":"inspect(1, content=\"1\")","source_context":[{"line":1,"text":"///|","is_match":false},{"line":2,"text":"fn number_snapshot() -> Unit {","is_match":false},{"line":3,"text":" inspect(1, content=\"1\")","is_match":true},{"line":4,"text":"}","is_match":false}]} +{"file":"testdata/builtin-rules-all/unnessary_else.mbt","rule_id":"moonbitlang/unnessary_else","description":"Found an if expression whose else branch is empty or only returns ().\nPrefer omitting the unnecessary else branch.","range":{"start":{"line":3,"column":3},"end":{"line":6,"column":12}},"matched_source":"if flag {\n prepare()\n finish()\n } else {}","source_context":[{"line":1,"text":"///|","is_match":false},{"line":2,"text":"fn unnecessary_empty_else(flag : Bool) -> Unit {","is_match":false},{"line":3,"text":" if flag {","is_match":true},{"line":4,"text":" prepare()","is_match":true},{"line":5,"text":" finish()","is_match":true},{"line":6,"text":" } else {}","is_match":true},{"line":7,"text":"}","is_match":false},{"line":8,"text":"///|","is_match":false}]} {"file":"testdata/builtin-rules-all/inspect_boolean.mbt","rule_id":"moonbitlang/inspect_boolean","description":"Found inspect(), debug_inspect(), or json_inspect() snapshots whose expected value is true or false.\nPrefer assert_true(...) or assert_false(...) for boolean checks.","range":{"start":{"line":3,"column":3},"end":{"line":3,"column":32}},"matched_source":"inspect(flag, content=\"true\")","source_context":[{"line":1,"text":"///|","is_match":false},{"line":2,"text":"fn boolean_snapshot(flag : Bool) -> Unit {","is_match":false},{"line":3,"text":" inspect(flag, content=\"true\")","is_match":true},{"line":4,"text":"}","is_match":false}]} {"file":"testdata/builtin-rules-all/cstyle_forward_simple_forloop.mbt","rule_id":"moonbitlang/cstyle_forward_simple_forloop","description":"C-style forward for loops that can be rewritten as simple for-in loops.","range":{"start":{"line":3,"column":3},"end":{"line":5,"column":4}},"matched_source":"for i = 0; i < limit; i = i + 1 {\n tick()\n }","source_context":[{"line":1,"text":"///|","is_match":false},{"line":2,"text":"fn forward_simple_loop(limit : Int) -> Unit {","is_match":false},{"line":3,"text":" for i = 0; i < limit; i = i + 1 {","is_match":true},{"line":4,"text":" tick()","is_match":true},{"line":5,"text":" }","is_match":true},{"line":6,"text":"}","is_match":false}]} {"file":"testdata/builtin-rules-all/cstyle_backward_simple_forloop.mbt","rule_id":"moonbitlang/cstyle_backward_simple_forloop","description":"C-style backward for loops that can be rewritten as simple for-in loops.","range":{"start":{"line":3,"column":3},"end":{"line":5,"column":4}},"matched_source":"for i = limit; i > 0; i = i - 1 {\n tick_back()\n }","source_context":[{"line":1,"text":"///|","is_match":false},{"line":2,"text":"fn backward_simple_loop(limit : Int) -> Unit {","is_match":false},{"line":3,"text":" for i = limit; i > 0; i = i - 1 {","is_match":true},{"line":4,"text":" tick_back()","is_match":true},{"line":5,"text":" }","is_match":true},{"line":6,"text":"}","is_match":false}]} {"file":"testdata/builtin-rules-all/cstyle_forward_array_iteration.mbt","rule_id":"moonbitlang/cstyle_forward_array_iteration","description":"C-style forward array iteration that can be rewritten as simple for-in loops.","range":{"start":{"line":3,"column":3},"end":{"line":5,"column":4}},"matched_source":"for i = 0; i < items.length(); i = i + 1 {\n consume(items[i])\n }","source_context":[{"line":1,"text":"///|","is_match":false},{"line":2,"text":"fn forward_array_loop(items : Array[Int]) -> Unit {","is_match":false},{"line":3,"text":" for i = 0; i < items.length(); i = i + 1 {","is_match":true},{"line":4,"text":" consume(items[i])","is_match":true},{"line":5,"text":" }","is_match":true},{"line":6,"text":"}","is_match":false}]} {"file":"testdata/builtin-rules-all/cstyle_backward_array_iteration.mbt","rule_id":"moonbitlang/cstyle_backward_array_iteration","description":"C-style backward array iteration that can be rewritten as simple for-in loops.","range":{"start":{"line":3,"column":3},"end":{"line":5,"column":4}},"matched_source":"for i = items.length() - 1; i >= 0; i = i - 1 {\n consume_reverse(items[i])\n }","source_context":[{"line":1,"text":"///|","is_match":false},{"line":2,"text":"fn backward_array_loop(items : Array[Int]) -> Unit {","is_match":false},{"line":3,"text":" for i = items.length() - 1; i >= 0; i = i - 1 {","is_match":true},{"line":4,"text":" consume_reverse(items[i])","is_match":true},{"line":5,"text":" }","is_match":true},{"line":6,"text":"}","is_match":false}]} ``` + +## Catch-all async function variants + +This focused fixture checks several async signatures and includes a synchronous +function with the same catch expression to make sure it is not reported. + +```mooncram +$ cd "$TESTDIR"/.. && moonrun "$TESTDIR"/moongrep.wasm -- scan --output-json --enable-builtin-rules testdata/builtin-catch-all-variants | sed -n 's/.*"rule_id":"\([^"]*\)".*"range":{"start":{"line":\([0-9][0-9]*\),"column":\([0-9][0-9]*\)}.*/\1 \2:\3/p' +moonbitlang/catch_all 3:3 +moonbitlang/catch_all 7:3 +moonbitlang/catch_all 11:3 +moonbitlang/catch_all 17:3 +``` diff --git a/e2etests/ELLIPSIS.md b/e2etests/ELLIPSIS.md index 0cbb212..73c4137 100644 --- a/e2etests/ELLIPSIS.md +++ b/e2etests/ELLIPSIS.md @@ -452,7 +452,7 @@ capture contract and is rejected while loading the rule. ```mooncram $ cd "$TESTDIR"/.. && moonrun "$TESTDIR"/moongrep.wasm -- scan --rule testdata/ellipsis/invalid/inside-kind.yaml testdata/ellipsis/sample.mbt -testdata/ellipsis/invalid/inside-kind.yaml: patterns[0] cannot use inherited inside-expr ellipsis metavar items with a different kind +testdata/ellipsis/invalid/inside-kind.yaml: patterns[0] cannot use inherited inside-expr[0] ellipsis metavar items with a different kind [1] ``` @@ -461,6 +461,6 @@ the target pattern. ```mooncram $ cd "$TESTDIR"/.. && moonrun "$TESTDIR"/moongrep.wasm -- scan --rule testdata/ellipsis/invalid/inside-single.yaml testdata/ellipsis/sample.mbt -testdata/ellipsis/invalid/inside-single.yaml: patterns[0] cannot use inherited inside-expr ellipsis metavar items as exp +testdata/ellipsis/invalid/inside-single.yaml: patterns[0] cannot use inherited inside-expr[0] ellipsis metavar items as exp [1] ``` diff --git a/e2etests/INTEGRATION.md b/e2etests/INTEGRATION.md index 5e5e104..87fe585 100644 --- a/e2etests/INTEGRATION.md +++ b/e2etests/INTEGRATION.md @@ -17,6 +17,51 @@ source: 4 | } ``` +Ordered outer alternatives work for both expression and top-level contexts in +one rule set. Inner captures are shared across every alternative. + +```mooncram +$ cd "$TESTDIR"/.. && moonrun "$TESTDIR"/moongrep.wasm -- scan --rules testdata/inside-alternatives/rules testdata/inside-alternatives/src +testdata/inside-alternatives/src/expr.mbt:2:3-2:32 +rule: expr +description: + Multiple expression contexts share one inner pattern. +source: +1 | fn sample { +2 > wrapper(alpha, target(alpha)); +3 | container(beta, target(beta)) +4 | } + +testdata/inside-alternatives/src/expr.mbt:3:3-3:32 +rule: expr +description: + Multiple expression contexts share one inner pattern. +source: +1 | fn sample { +2 | wrapper(alpha, target(alpha)); +3 > container(beta, target(beta)) +4 | } + +testdata/inside-alternatives/src/toplevel.mbt:2:3-2:15 +rule: toplevel +description: + Multiple top-level contexts share one inner pattern. +source: +1 | fn run { +2 > consume(run) +3 | } +4 | let value = box(consume(value)) + +testdata/inside-alternatives/src/toplevel.mbt:4:17-4:31 +rule: toplevel +description: + Multiple top-level contexts share one inner pattern. +source: +2 | consume(run) +3 | } +4 > let value = box(consume(value)) +``` + An HTML builder chain is a taint source whose result is stored in a local variable and passed into the `attrs` argument of a sink. The reported range is the tainted local use, confirming propagation through the assignment. diff --git a/matching/context.mbt b/matching/context.mbt index f729957..9de4cb7 100644 --- a/matching/context.mbt +++ b/matching/context.mbt @@ -1,3 +1,14 @@ +///| +/// One named AST field that a compiled pattern deliberately omits from +/// structural comparison. +/// +/// Both the parent node kind and child name are recorded so skipping stays +/// local to the intended AST field. +pub(all) struct IgnoredPatternField { + parent_kind : @untyped_ast.NodeKind + child_name : String +} + ///| /// A compiled structural pattern. /// @@ -13,7 +24,8 @@ /// sequence captures. Anonymous ellipses are represented only in `ast`. /// `target_metavar` and `source_metavar` are reserved bare-expression /// placeholders injected by the rules package for `__TARGET__` and -/// `__SOURCE__`. +/// `__SOURCE__`. `ignored_fields` names parent/child field pairs that are +/// skipped by both structural matching and source prefilter extraction. pub(all) struct CompiledExprPattern { ast : @untyped_ast.Node expr_metavars : Array[String] @@ -24,6 +36,7 @@ pub(all) struct CompiledExprPattern { ellipsis_metavars : Array[EllipsisMetavar] target_metavar : String? source_metavar : String? + ignored_fields : Array[IgnoredPatternField] } ///| diff --git a/matching/matching.mbt b/matching/matching.mbt index 0075f97..d14ea41 100644 --- a/matching/matching.mbt +++ b/matching/matching.mbt @@ -17,6 +17,7 @@ pub fn expr_matches( ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } match_expr_pattern(compiled, candidate) is Some(_) } @@ -267,22 +268,30 @@ fn structural_match( (Leaf(_), Leaf(_)) => pattern.kind == candidate.kind _ => pattern.kind == candidate.kind && - match_children(pattern.children, candidate.children, compiled, bindings) + match_children( + pattern.kind, + pattern.children, + candidate.children, + compiled, + bindings, + ) } } ///| fn match_children( + parent_kind : @untyped_ast.NodeKind, pattern : Array[(String?, @untyped_ast.Node)], candidate : Array[(String?, @untyped_ast.Node)], compiled : CompiledExprPattern, bindings : @hashmap.HashMap[String, BoundValue], ) -> Bool { - match_children_from(pattern, candidate, 0, 0, compiled, bindings) + match_children_from(parent_kind, pattern, candidate, 0, 0, compiled, bindings) } ///| fn match_children_from( + parent_kind : @untyped_ast.NodeKind, pattern : Array[(String?, @untyped_ast.Node)], candidate : Array[(String?, @untyped_ast.Node)], pattern_index : Int, @@ -294,6 +303,23 @@ fn match_children_from( return candidate_index == candidate.length() } let (pattern_name, pattern_value) = pattern[pattern_index] + if pattern_name is Some(child_name) && + compiled_ignores_field(compiled, parent_kind, child_name) { + if candidate_index >= candidate.length() { + return false + } + let (candidate_name, _) = candidate[candidate_index] + return pattern_name == candidate_name && + match_children_from( + parent_kind, + pattern, + candidate, + pattern_index + 1, + candidate_index + 1, + compiled, + bindings, + ) + } if pattern_name is None && ellipsis_marker(pattern_value, compiled) is Some(marker) { let fixed_remaining = minimum_fixed_children( @@ -328,6 +354,7 @@ fn match_children_from( bind_multiple(branch, marker.name, captured) if bound && match_children_from( + parent_kind, pattern, candidate, pattern_index + 1, @@ -349,6 +376,7 @@ fn match_children_from( pattern_name == candidate_name && match_node(pattern_value, candidate_value, compiled, bindings) && match_children_from( + parent_kind, pattern, candidate, pattern_index + 1, @@ -359,6 +387,20 @@ fn match_children_from( } } +///| +fn compiled_ignores_field( + compiled : CompiledExprPattern, + parent_kind : @untyped_ast.NodeKind, + child_name : String, +) -> Bool { + for field in compiled.ignored_fields { + if field.parent_kind == parent_kind && field.child_name == child_name { + return true + } + } + false +} + ///| fn minimum_fixed_children( pattern : Array[(String?, @untyped_ast.Node)], diff --git a/matching/matching_test.mbt b/matching/matching_test.mbt index e8a0e9c..52207ca 100644 --- a/matching/matching_test.mbt +++ b/matching/matching_test.mbt @@ -186,6 +186,7 @@ test "declared expression metavar requires structural equality" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true(match_expr_pattern(compiled, parse_expr("a + a")) is Some(_)) assert_false(match_expr_pattern(compiled, parse_expr("a + b")) is Some(_)) @@ -206,6 +207,7 @@ test "identifier metavar compares binder and uses by normalized name" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern( @@ -238,6 +240,7 @@ test "identifier metavar matches loop counter" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern( @@ -268,6 +271,7 @@ test "expression metavar matches repeated call expression" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern(compiled, parse_expr("make() + make()")) is Some(_), @@ -290,6 +294,7 @@ test "expression metavar keeps structural equality" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_false( match_expr_pattern(compiled, parse_expr("item + make()")) is Some(_), @@ -312,6 +317,7 @@ test "constant metavar requires repeated constant equality" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true(match_expr_pattern(compiled, parse_expr("1 + 1")) is Some(_)) assert_false(match_expr_pattern(compiled, parse_expr("1 + 2")) is Some(_)) @@ -331,6 +337,7 @@ test "constant metavar matches only constant patterns" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern(compiled, parse_expr("match value { 1 => 1 }")) @@ -358,6 +365,7 @@ test "argument metavar captures complete argument nodes" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } for source in [ @@ -390,6 +398,7 @@ test "argument metavar requires repeated argument equality" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern(compiled, parse_expr("sink(value, value)")) is Some(_), @@ -419,6 +428,7 @@ test "pattern metavar captures whole pattern AST" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } if match_expr_pattern( compiled, @@ -456,6 +466,7 @@ test "pattern metavar requires repeated pattern equality" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern( @@ -486,6 +497,7 @@ test "identifier metavar matches pattern vars" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern(compiled, parse_expr("match value { actual => actual }")) @@ -510,6 +522,7 @@ test "identifier metavar matches labels" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern(compiled, parse_expr("item.name + item.name")) is Some(_), @@ -533,6 +546,7 @@ test "identifier metavar matches qualified function names" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern(compiled, parse_expr("@int.abs(@int.abs)")) is Some(_), @@ -555,6 +569,7 @@ test "qualified identifier metavar keeps literal package" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern(compiled, parse_expr("@int.abs(@int.abs)")) is Some(_), @@ -578,6 +593,7 @@ test "identifier metavar matches constructor identity with extra info" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern(compiled, parse_expr("@pkg.Actual(@pkg.Actual)")) @@ -605,6 +621,7 @@ test "identifier metavar matches constructor type extra info" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern( @@ -632,6 +649,7 @@ test "identifier metavar matches constructor patterns" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } assert_true( match_expr_pattern( diff --git a/matching/pkg.generated.mbti b/matching/pkg.generated.mbti index 3dc18c6..1b4c2a8 100644 --- a/matching/pkg.generated.mbti +++ b/matching/pkg.generated.mbti @@ -31,6 +31,7 @@ pub(all) struct CompiledExprPattern { ellipsis_metavars : Array[EllipsisMetavar] target_metavar : String? source_metavar : String? + ignored_fields : Array[IgnoredPatternField] } pub(all) struct EllipsisMetavar { @@ -53,6 +54,11 @@ pub(all) struct ExprMatch { bindings : @hashmap.HashMap[String, BoundValue] } +pub(all) struct IgnoredPatternField { + parent_kind : @untyped_ast.NodeKind + child_name : String +} + // Type aliases // Traits diff --git a/matching/untyped_matching_test.mbt b/matching/untyped_matching_test.mbt index f1645aa..87efe25 100644 --- a/matching/untyped_matching_test.mbt +++ b/matching/untyped_matching_test.mbt @@ -47,6 +47,7 @@ fn untyped_compiled( ellipsis_metavars? : Array[EllipsisMetavar] = [], target_metavar? : String? = None, source_metavar? : String? = None, + ignored_fields? : Array[IgnoredPatternField] = [], ) -> CompiledExprPattern { { ast, @@ -58,6 +59,7 @@ fn untyped_compiled( ellipsis_metavars, target_metavar, source_metavar, + ignored_fields, } } @@ -66,6 +68,24 @@ fn untyped_test_loc() -> @basic.Location { @syntax.no_location } +///| +fn untyped_named_string_node( + kind : @untyped_ast.NodeKind, + fields : Array[(String, String)], +) -> @untyped_ast.Node { + let children : Array[(String?, @untyped_ast.Node)] = [] + for field in fields { + let (name, value) = field + children.push( + ( + Some(name), + { kind: Leaf(PString(value)), loc: untyped_test_loc(), children: [] }, + ), + ) + } + { kind, loc: untyped_test_loc(), children } +} + ///| fn untyped_test_binder(name : String) -> @syntax.Binder { { name, loc: untyped_test_loc() } @@ -132,6 +152,35 @@ test "default matcher compares untyped structural equality" { ) } +///| +test "matcher skips ignored fields only for the recorded parent and child" { + let pattern = untyped_named_string_node(FunDecl, [ + ("name", "sample"), + ("vis", "default"), + ]) + let candidate = untyped_named_string_node(FunDecl, [ + ("name", "sample"), + ("vis", "public"), + ]) + let ignored = untyped_compiled(pattern, ignored_fields=[ + { parent_kind: FunDecl, child_name: "vis" }, + ]) + assert_true(match_expr_pattern(ignored, candidate) is Some(_)) + let literal_name_differs = untyped_named_string_node(FunDecl, [ + ("name", "other"), + ("vis", "public"), + ]) + assert_false(match_expr_pattern(ignored, literal_name_differs) is Some(_)) + let wrong_parent = untyped_compiled(pattern, ignored_fields=[ + { parent_kind: Impl_TopFuncDef, child_name: "vis" }, + ]) + assert_false(match_expr_pattern(wrong_parent, candidate) is Some(_)) + let wrong_child = untyped_compiled(pattern, ignored_fields=[ + { parent_kind: FunDecl, child_name: "name" }, + ]) + assert_false(match_expr_pattern(wrong_child, candidate) is Some(_)) +} + ///| test "default matcher binds repeated expression metavars" { let compiled = untyped_compiled(parse_untyped_test_node("value + value"), expr_metavars=[ @@ -326,6 +375,7 @@ test "node matcher accepts nodes and captures constant nodes" { ellipsis_metavars: [], target_metavar: None, source_metavar: None, + ignored_fields: [], } if match_expr_pattern( compiled, diff --git a/moon.mod b/moon.mod index ddf5d93..c594937 100644 --- a/moon.mod +++ b/moon.mod @@ -1,6 +1,6 @@ name = "moonbit-community/moongrep" -version = "0.1.16" +version = "0.1.17" preferred_target = "wasm" diff --git a/query/query.mbt b/query/query.mbt index 227aebe..efac1fd 100644 --- a/query/query.mbt +++ b/query/query.mbt @@ -77,9 +77,9 @@ fn anonymous_pattern_rule(pattern : String) -> RawRuleSpec { rule_id: pattern, description: "Anonymous query pattern.", definition: Structural({ - inside_expr: None, - inside_toplevel: None, - patterns: [{ shape: pattern, guards: Map([]) }], + inside_expr: [], + inside_toplevel: [], + patterns: [{ shape: pattern, guards: Map([]), match_mode: Default }], patterns_not: [], patterns_not_mode: PruneOnNegative, }), diff --git a/query/query_test.mbt b/query/query_test.mbt index 86e1c3e..72497d5 100644 --- a/query/query_test.mbt +++ b/query/query_test.mbt @@ -225,7 +225,7 @@ test "query raises parse error when relevant source is invalid" { #|} #| try query.captures(source_name="bad.mbt", source) catch { - err => inspect("\{to_repr(err)}".contains("parse"), content="true") + err => inspect("\{Repr(err)}".contains("parse"), content="true") } noraise { _ => fail("expected parse error") } @@ -234,7 +234,7 @@ test "query raises parse error when relevant source is invalid" { ///| test "query raises when pattern is invalid" { try ExprQuery::ExprQuery("target(") catch { - err => inspect("\{to_repr(err)}".contains("InvalidRule"), content="true") + err => inspect("\{Repr(err)}".contains("InvalidRule"), content="true") } noraise { _ => fail("expected invalid pattern") } diff --git a/rule/apply/apply.mbt b/rule/apply/apply.mbt index 71688e7..f863b02 100644 --- a/rule/apply/apply.mbt +++ b/rule/apply/apply.mbt @@ -30,7 +30,7 @@ priv struct AstStructuralRuleEntry { structural : CompiledStructuralRule pattern_buckets : ExprPatternBuckets negative_pattern_buckets : ExprPatternBuckets - inside_root_key : @untyped_ast.NodeKind? + inside_pattern_buckets : ExprPatternBuckets } ///| @@ -150,12 +150,10 @@ fn ast_structural_rule_entry( rule : CompiledRule, structural : CompiledStructuralRule, ) -> AstStructuralRuleEntry { - let inside_root_key = if structural.inside_expr is Some(inside) { - compiled_pattern_root_key(inside) - } else if structural.inside_toplevel is Some(inside) { - compiled_pattern_root_key(inside) + let inside_patterns = if !structural.inside_expr.is_empty() { + structural.inside_expr } else { - None + structural.inside_toplevel } { rule, @@ -164,7 +162,7 @@ fn ast_structural_rule_entry( negative_pattern_buckets: ExprPatternBuckets::from_patterns( structural.patterns_not, ), - inside_root_key, + inside_pattern_buckets: ExprPatternBuckets::from_patterns(inside_patterns), } } @@ -194,13 +192,12 @@ fn apply_toplevel_structural_rule_entries_to_node( | Impl_TopUsing | Impl_TopView => for entry in entries { - if entry.structural.inside_toplevel is Some(inside) { + if !entry.structural.inside_toplevel.is_empty() { apply_inside_toplevel_ast_bucketed( file, entry.rule, entry.structural, - inside, - entry.inside_root_key, + entry.inside_pattern_buckets, entry.pattern_buckets, entry.negative_pattern_buckets, node, @@ -250,20 +247,19 @@ fn apply_ast_structural_rule_entry_to_scoped_expr( scoped_expr : @untyped_ast.ScopedExprNode, hits : Array[RuleFinding], ) -> RuleTraversalAction { - if entry.structural.inside_expr is Some(inside) { + if !entry.structural.inside_expr.is_empty() { apply_inside_expr_ast_bucketed( file, entry.rule, entry.structural, - inside, - entry.inside_root_key, + entry.inside_pattern_buckets, entry.pattern_buckets, entry.negative_pattern_buckets, scoped_expr.expr, hits, ) RuleContinue - } else if entry.structural.inside_toplevel is Some(_) { + } else if !entry.structural.inside_toplevel.is_empty() { RulePrune } else { apply_ast_patterns_at_node( @@ -330,15 +326,14 @@ fn apply_inside_toplevel_ast_bucketed( file : String, rule : CompiledRule, structural : CompiledStructuralRule, - inside : CompiledRulePattern, - inside_root_key : @untyped_ast.NodeKind?, + inside_pattern_buckets : ExprPatternBuckets, pattern_buckets : ExprPatternBuckets, negative_pattern_buckets : ExprPatternBuckets, toplevel : @untyped_ast.Node, hits : Array[RuleFinding], ) -> Unit { apply_inside_context_ast_bucketed( - file, rule, structural, inside, inside_root_key, pattern_buckets, negative_pattern_buckets, + file, rule, structural, inside_pattern_buckets, pattern_buckets, negative_pattern_buckets, toplevel, hits, ) } @@ -348,8 +343,7 @@ fn apply_inside_expr_ast_bucketed( file : String, rule : CompiledRule, structural : CompiledStructuralRule, - inside : CompiledRulePattern, - inside_root_key : @untyped_ast.NodeKind?, + inside_pattern_buckets : ExprPatternBuckets, pattern_buckets : ExprPatternBuckets, negative_pattern_buckets : ExprPatternBuckets, expr : @untyped_ast.Node, @@ -357,7 +351,7 @@ fn apply_inside_expr_ast_bucketed( ) -> Unit { let context_hits : Array[RuleFinding] = [] apply_inside_context_ast_bucketed( - file, rule, structural, inside, inside_root_key, pattern_buckets, negative_pattern_buckets, + file, rule, structural, inside_pattern_buckets, pattern_buckets, negative_pattern_buckets, expr, context_hits, ) if context_hits.get(0) is Some(first) { @@ -376,57 +370,59 @@ fn apply_inside_context_ast_bucketed( file : String, rule : CompiledRule, structural : CompiledStructuralRule, - inside : CompiledRulePattern, - inside_root_key : @untyped_ast.NodeKind?, + inside_pattern_buckets : ExprPatternBuckets, pattern_buckets : ExprPatternBuckets, negative_pattern_buckets : ExprPatternBuckets, context_node : @untyped_ast.Node, hits : Array[RuleFinding], ) -> Unit { - if !node_root_key_matches(inside_root_key, context_node) { - return - } - if match_node_rule_pattern(inside, context_node, None) is Some(result) { - if result.bindings.get("__TARGET__") is Some(Single(target)) { - if structural.patterns.is_empty() { - let target_exprs : Array[@untyped_ast.ScopedExprNode] = [] - @untyped_ast.collect_node_scoped_exprs(target, target_exprs) - apply_inside_context_only_ast_bucketed( - file, - rule, - negative_pattern_buckets, - target_exprs, - context_node.loc, - result.bindings, - inside.compiled.identifier_metavars, - hits, - ) - } else { - match structural.patterns_not_mode { - RejectUncoveredNegative => - apply_ast_bucketed_covered_patterns_in_target_subtree( - file, - rule, - pattern_buckets, - negative_pattern_buckets, - target, - result.bindings, - inside.compiled.identifier_metavars, - hits, - ) - PruneOnNegative => - apply_ast_bucketed_patterns_in_target_subtree( - file, - rule, - pattern_buckets, - negative_pattern_buckets, - target, - result.bindings, - inside.compiled.identifier_metavars, - hits, - ) + for indexed in inside_pattern_buckets.patterns_for_node(context_node) { + let inside = indexed.pattern + if match_node_rule_pattern(inside, context_node, None) is Some(result) { + if result.bindings.get("__TARGET__") is Some(Single(target)) { + if structural.patterns.is_empty() { + let target_exprs : Array[@untyped_ast.ScopedExprNode] = [] + @untyped_ast.collect_node_scoped_exprs(target, target_exprs) + apply_inside_context_only_ast_bucketed( + file, + rule, + negative_pattern_buckets, + target_exprs, + context_node.loc, + result.bindings, + inside.compiled.identifier_metavars, + hits, + ) + } else { + match structural.patterns_not_mode { + RejectUncoveredNegative => + apply_ast_bucketed_covered_patterns_in_target_subtree( + file, + rule, + pattern_buckets, + negative_pattern_buckets, + target, + result.bindings, + inside.compiled.identifier_metavars, + hits, + ) + PruneOnNegative => + apply_ast_bucketed_patterns_in_target_subtree( + file, + rule, + pattern_buckets, + negative_pattern_buckets, + target, + result.bindings, + inside.compiled.identifier_metavars, + hits, + ) + } } } + // Outer alternatives are ordered. Once shape and guards select one, + // later alternatives are not considered even if the target has no hit. + return } } } diff --git a/rule/apply/apply_test.mbt b/rule/apply/apply_test.mbt index e544573..5e5c631 100644 --- a/rule/apply/apply_test.mbt +++ b/rule/apply/apply_test.mbt @@ -246,6 +246,31 @@ test "apply structural rule supports bare expression metavars" { inspect(hits[0].rule_id, content="example") } +///| +test "apply structural rule rewrites field access metavars recursively" { + let rule_source = + #|id: example + #|description: | + #| Repeated field mutation target. + #|patterns: + #| - shape: $object.$field = $object.$field + $_ + #| + let source = + #|fn sample { + #| foo.bar.baz = foo.bar.baz + 1; + #| foo.bar.baz = foo.bar.other + 1; + #| foo.bar.baz = other.bar.baz + 1 + #|} + #| + let hits = apply_structural_test_rules( + "sample.mbt", + parse_root_node(source), + compile_rule_source(rule_source), + ) + assert_eq(hits.length(), 1) + inspect(hits[0].rule_id, content="example") +} + ///| test "apply structural rule treats $_ as independent ignore placeholder" { let rule_source = @@ -532,7 +557,7 @@ test "apply inside expr and inner guards filter inherited bindings" { #|description: | #| Guarded wrapper. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) + #| - shape: wrapper($(name:id), __TARGET__) #|patterns: #| - shape: call($(name:id)) #| guard: @@ -560,9 +585,9 @@ test "apply inside expr guard filters outer context matches" { #|description: | #| Guarded outer wrapper. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) - #| guard: - #| $name: "^safe_" + #| - shape: wrapper($(name:id), __TARGET__) + #| guard: + #| $name: "^safe_" #|patterns: #| - shape: call() #| @@ -588,7 +613,7 @@ test "apply structural rule matches guards inside expr" { #|description: | #| Guarded wrapper. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) + #| - shape: wrapper($(name:id), __TARGET__) #|patterns: #| - shape: call($(name:id), $(value:const)) #| guard: @@ -616,7 +641,7 @@ test "inside expr reports the outer context location" { #|description: | #| Nested target call. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns: #| - shape: target(secret) #| @@ -639,7 +664,7 @@ test "inside expr merges inner hits and keeps the first pattern index" { #|description: | #| Wrapped calls. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns: #| - shape: second() #| - shape: first() @@ -803,7 +828,7 @@ test "inside expr positive root ignores descendant patterns-not" { #|description: | #| Wrapped holder positive wins before descendant negative. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns: #| - shape: holder($(value:exp)) #|patterns-not: @@ -826,7 +851,7 @@ test "inside expr positive root wins over same-root patterns-not" { #|description: | #| Wrapped target roots match before negative patterns run. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns: #| - shape: target($(value:exp)) #|patterns-not: @@ -851,7 +876,7 @@ test "inside expr patterns-not rejects outer on uncovered negative" { #|description: | #| Wrapped targets outside blocked branches. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns: #| - shape: target() #|patterns-not: @@ -883,7 +908,7 @@ test "inside expr positive array access wins over patterns-not identifier" { #|description: | #| Wrapped array access. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns: #| - shape: $(array:exp)[$(index:exp)] #|patterns-not: @@ -906,7 +931,7 @@ test "inside expr rejects only uncovered negative and accepts covered array acce #|description: | #| Covered array index use. #|inside-expr: - #| shape: wrapper($(counter:id), __TARGET__) + #| - shape: wrapper($(counter:id), __TARGET__) #|patterns: #| - shape: arr[$(counter:id)] #|patterns-not: @@ -929,7 +954,7 @@ test "inside expr rejects uncovered inherited id outside positive coverage" { #|description: | #| Uncovered index use rejects the wrapper. #|inside-expr: - #| shape: wrapper($(counter:id), __TARGET__) + #| - shape: wrapper($(counter:id), __TARGET__) #|patterns: #| - shape: arr[$(counter:id)] #|patterns-not: @@ -954,7 +979,7 @@ test "inside expr merges multiple covered positive hits" { #|description: | #| Multiple covered index uses. #|inside-expr: - #| shape: wrapper($(counter:id), __TARGET__) + #| - shape: wrapper($(counter:id), __TARGET__) #|patterns: #| - shape: arr[$(counter:id)] #|patterns-not: @@ -980,7 +1005,7 @@ test "inside expr reject-uncovered-negative respects shadowed inherited identifi #|description: | #| Shadowed index use belongs to inner binding. #|inside-expr: - #| shape: wrapper($(counter:id), __TARGET__) + #| - shape: wrapper($(counter:id), __TARGET__) #|patterns: #| - shape: arr[$(counter:id)] #|patterns-not: @@ -1007,10 +1032,10 @@ test "inside expr accepts only loop counter uses covered by array access" { #|description: | #| C-style forward array iteration. #|inside-expr: - #| shape: | - #| for $(counter:id) = 0; $(counter:id) < $(arr:id).length(); $(counter:id) = $(counter:id) + 1 { - #| __TARGET__ - #| } + #| - shape: | + #| for $(counter:id) = 0; $(counter:id) < $(arr:id).length(); $(counter:id) = $(counter:id) + 1 { + #| __TARGET__ + #| } #|patterns: #| - shape: | #| $(arr:id)[$(counter:id)] @@ -1040,7 +1065,7 @@ test "inside expr with only patterns-not reports outer when target subtree is cl #|description: | #| Wrapped target without danger. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns-not: #| - shape: danger() #| @@ -1063,7 +1088,7 @@ test "inside expr only patterns-not respects shadowed inherited identifiers" { #|description: | #| Wrapped target without same-name target call. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) + #| - shape: wrapper($(name:id), __TARGET__) #|patterns-not: #| - shape: target($(name:id)) #| @@ -1088,7 +1113,7 @@ test "inside expr condition binders shadow inherited identifiers" { #|description: | #| Wrapped target call. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) + #| - shape: wrapper($(name:id), __TARGET__) #|patterns: #| - shape: target($(name:id)) #| @@ -1123,7 +1148,7 @@ test "inside expr patterns-not ignores condition-shadowed inherited identifiers" #|description: | #| Wrapped target without same-name target call. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) + #| - shape: wrapper($(name:id), __TARGET__) #|patterns-not: #| - shape: target($(name:id)) #| @@ -1185,7 +1210,7 @@ test "inside expr preserves source order of outer locations" { #|description: | #| Wrapped target call. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns: #| - shape: target() #| @@ -1216,10 +1241,10 @@ test "inside toplevel matches functions and inherits bindings" { #|description: | #| Guarded top-level function. #|inside-toplevel: - #| shape: | - #| fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } - #| guard: - #| $name: "^safe_" + #| - shape: | + #| fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } + #| guard: + #| $name: "^safe_" #|patterns: #| - shape: call($(param:id)) #| @@ -1238,6 +1263,160 @@ test "inside toplevel matches functions and inherits bindings" { inspect(location_signature(hits[0].loc), content="1:35-1:46") } +///| +test "inside toplevel defaults function shapes to partial matching" { + let rule_source = + #|id: example + #|description: Broad function context. + #|inside-toplevel: + #| - shape: fn $_ { __TARGET__ } + #|patterns: + #| - shape: marker() + #| + let source = + #|fn plain { marker() } + #|pub fn parameters(value : Int) -> String { marker() } + #|priv async fn[T] generic(value : T) -> T noraise { marker() } + #|/// Documented function. + #|#custom.attribute + #|fn documented { marker() } + #|fn[T] Box::map(value : T) -> T { marker() } + #|fn fallible(value : Int) -> Int raise { marker() } + #|fn constrained where { requirement: required() } { marker() } + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 7) +} + +///| +test "inside toplevel partial matching keeps explicit function fields exact" { + let rule_source = + #|id: example + #|description: Explicit function header. + #|inside-toplevel: + #| - shape: | + #| /// Required documentation. + #| #custom.attribute + #| pub async fn[T] Box::run() -> Int noraise { __TARGET__ } + #|patterns: + #| - shape: marker() + #| + let source = + #|/// Required documentation. + #|#custom.attribute + #|pub async fn[T] Box::run() -> Int noraise { marker() } + #|#custom.attribute + #|pub async fn[T] Box::run() -> Int noraise { marker() } + #|/// Required documentation. + #|pub async fn[T] Box::run() -> Int noraise { marker() } + #|/// Required documentation. + #|#custom.attribute + #|priv async fn[T] Box::run() -> Int noraise { marker() } + #|/// Required documentation. + #|#custom.attribute + #|pub fn[T] Box::run() -> Int noraise { marker() } + #|/// Required documentation. + #|#custom.attribute + #|pub async fn[U] Box::run() -> Int noraise { marker() } + #|/// Required documentation. + #|#custom.attribute + #|pub async fn[T] Other::run() -> Int noraise { marker() } + #|/// Required documentation. + #|#custom.attribute + #|pub async fn[T] Box::run -> Int noraise { marker() } + #|/// Required documentation. + #|#custom.attribute + #|pub async fn[T] Box::run() -> String noraise { marker() } + #|/// Required documentation. + #|#custom.attribute + #|pub async fn[T] Box::run() -> Int { marker() } + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 1) +} + +///| +test "inside toplevel partial matching keeps an explicit where clause exact" { + let rule_source = + #|id: example + #|description: Explicit function where clause. + #|inside-toplevel: + #| - shape: | + #| fn $_ where { requirement: required() } { __TARGET__ } + #|patterns: + #| - shape: marker() + #| + let source = + #|fn exact where { requirement: required() } { marker() } + #|fn missing { marker() } + #|fn different where { requirement: other() } { marker() } + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 1) + assert_eq(hits[0].loc.start.lnum, 1) +} + +///| +test "inside toplevel explicit async excludes synchronous functions" { + let rule_source = + #|id: example + #|description: Async function context. + #|inside-toplevel: + #| - shape: async fn $_ { __TARGET__ } + #|patterns: + #| - shape: marker() + #| + let source = + #|async fn asynchronous(value : Int) -> Int { marker() } + #|fn synchronous(value : Int) -> Int { marker() } + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 1) + assert_eq(hits[0].loc.start.lnum, 1) +} + +///| +test "inside toplevel exact mode preserves complete function matching" { + let rule_source = + #|id: example + #|description: Exact function context. + #|inside-toplevel: + #| - shape: fn $_ { __TARGET__ } + #| match-mode: exact + #|patterns: + #| - shape: marker() + #| + let source = + #|fn exact { marker() } + #|pub fn public { marker() } + #|fn parameters() { marker() } + #|async fn asynchronous { marker() } + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 1) + assert_eq(hits[0].loc.start.lnum, 1) +} + +///| +test "inside toplevel keeps non-function defaults exact" { + let rule_source = + #|id: example + #|description: Exact top-level let context. + #|inside-toplevel: + #| - shape: let value = __TARGET__ + #|patterns: + #| - shape: marker() + #| + let source = + #|let value = marker() + #|pub let value = marker() + #|let value : Int = marker() + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 1) + assert_eq(hits[0].loc.start.lnum, 1) +} + ///| test "inside toplevel with only patterns-not reports clean top-level let" { let rule_source = @@ -1245,7 +1424,7 @@ test "inside toplevel with only patterns-not reports clean top-level let" { #|description: | #| Clean top-level binding. #|inside-toplevel: - #| shape: let $(name:id) = wrapper(__TARGET__) + #| - shape: let $(name:id) = wrapper(__TARGET__) #|patterns-not: #| - shape: forbidden($(name:id)) #| @@ -1265,7 +1444,7 @@ test "inside toplevel patterns-not rejects uncovered negative" { #|description: | #| Only covered names are allowed. #|inside-toplevel: - #| shape: fn $(name:id) { __TARGET__ } + #| - shape: fn $(name:id) { __TARGET__ } #|patterns: #| - shape: arr[$(name:id)] #|patterns-not: @@ -1293,7 +1472,7 @@ test "apply structural rule supports inside expr inherited bindings" { #|description: | #| Wrapped target call. #|inside-expr: - #| shape: wrapper($(prefix:exp), __TARGET__) + #| - shape: wrapper($(prefix:exp), __TARGET__) #|patterns: #| - shape: target.call($(prefix:exp)) #| @@ -1318,7 +1497,7 @@ test "apply inside expr inherits ellipsis sequence bindings" { #|description: | #| Reuse an outer argument prefix inside the target. #|inside-expr: - #| shape: wrapper($$$args, __TARGET__) + #| - shape: wrapper($$$args, __TARGET__) #|patterns: #| - shape: target($$$args) #| @@ -1343,7 +1522,7 @@ test "inside expr plain names do not reference inherited bindings" { #|description: | #| Plain names remain literal. #|inside-expr: - #| shape: wrapper($(prefix:exp), __TARGET__) + #| - shape: wrapper($(prefix:exp), __TARGET__) #|patterns: #| - shape: target.call(prefix) #| @@ -1367,7 +1546,7 @@ test "apply structural rule inherits inside expr identifier bindings" { #|description: | #| Wrapped target call. #|inside-expr: - #| shape: wrapper($(prefix:id), __TARGET__) + #| - shape: wrapper($(prefix:id), __TARGET__) #|patterns: #| - shape: target.call($(prefix:id)) #| @@ -1392,7 +1571,7 @@ test "inside expr inherited id is blocked after lexical let shadow" { #|description: | #| Wrapped target call. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) + #| - shape: wrapper($(name:id), __TARGET__) #|patterns: #| - shape: target($(name:id)) #| @@ -1415,7 +1594,7 @@ test "inside expr inherited id still matches without shadow" { #|description: | #| Wrapped target call. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) + #| - shape: wrapper($(name:id), __TARGET__) #|patterns: #| - shape: target($(name:id)) #| @@ -1435,7 +1614,7 @@ test "inside expr later shadow does not block earlier candidate" { #|description: | #| Wrapped target call. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) + #| - shape: wrapper($(name:id), __TARGET__) #|patterns: #| - shape: target($(name:id)) #| @@ -1459,7 +1638,7 @@ test "inside expr only blocks patterns referencing shadowed outer id" { #|description: | #| Wrapped target call. #|inside-expr: - #| shape: wrapper($(left:id), $(right:id), __TARGET__) + #| - shape: wrapper($(left:id), $(right:id), __TARGET__) #|patterns: #| - shape: target($(right:id)) #| - shape: target($(left:id)) @@ -1485,7 +1664,7 @@ test "inside expr traverses guard target children" { #|description: | #| Guard target call. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns: #| - shape: target() #| @@ -1512,7 +1691,7 @@ test "inside expr traverses reverse pipe target children" { #|description: | #| Reverse pipe target call. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns: #| - shape: target() #| @@ -1536,7 +1715,7 @@ test "inside expr traverses list comprehension target children" { #|description: | #| List comprehension target call. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns: #| - shape: target(item) #| @@ -1622,7 +1801,7 @@ test "default structural matcher carries inside target bindings" { #|description: | #| Wrapped target using same expression. #|inside-expr: - #| shape: wrapper($(prefix:exp), __TARGET__) + #| - shape: wrapper($(prefix:exp), __TARGET__) #|patterns: #| - shape: target($(prefix:exp)) #| @@ -1643,7 +1822,7 @@ test "default structural matcher respects shadowed inherited identifiers" { #|description: | #| Wrapped target call. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) + #| - shape: wrapper($(name:id), __TARGET__) #|patterns: #| - shape: target($(name:id)) #| @@ -1933,3 +2112,165 @@ test "apply taint rule reports receiver and root call targets with sink indexes" assert_eq(hits[1].pattern_index, 1) assert_eq(hits[2].pattern_index, 2) } + +///| +test "inside expr tries multiple outer shapes in yaml order" { + let rule_source = + #|id: example + #|description: Multiple expression contexts. + #|inside-expr: + #| - shape: wrapper(__TARGET__) + #| - shape: container(__TARGET__) + #|patterns: + #| - shape: target() + #| + let source = + #|fn sample { + #| wrapper(target()); + #| container(target()) + #|} + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 2) + assert_eq(hits[0].loc.start.lnum, 2) + assert_eq(hits[1].loc.start.lnum, 3) +} + +///| +test "inside expr selects the first overlapping outer alternative" { + let rule_source = + #|id: example + #|description: Ordered overlapping contexts. + #|inside-expr: + #| - shape: wrapper($(name:id), $_, __TARGET__) + #| - shape: wrapper($_, $(name:id), __TARGET__) + #|patterns: + #| - shape: target($(name:id)) + #| + let source = + #|fn sample { + #| wrapper(first, second, target(first)); + #| wrapper(first, second, target(second)) + #|} + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 1) + assert_eq(hits[0].loc.start.lnum, 2) +} + +///| +test "inside expr falls through when an earlier outer guard fails" { + let rule_source = + #|id: example + #|description: Guarded ordered contexts. + #|inside-expr: + #| - shape: wrapper($(name:id), __TARGET__) + #| guard: + #| $name: "^primary$" + #| - shape: wrapper($(name:id), __TARGET__) + #| guard: + #| $name: "^fallback$" + #|patterns: + #| - shape: target($(name:id)) + #| + let source = + #|fn sample { + #| wrapper(fallback, target(fallback)) + #|} + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 1) + assert_eq(hits[0].loc.start.lnum, 2) +} + +///| +test "inside expr does not try later alternatives after inner matching fails" { + let rule_source = + #|id: example + #|description: No fallback after outer selection. + #|inside-expr: + #| - shape: wrapper($(name:id), $_, __TARGET__) + #| - shape: wrapper($_, $(name:id), __TARGET__) + #|patterns: + #| - shape: target($(name:id)) + #| + let source = + #|fn sample { + #| wrapper(first, second, target(second)) + #|} + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 0) +} + +///| +test "inside expr alternatives support patterns-not only" { + let rule_source = + #|id: example + #|description: Clean ordered contexts. + #|inside-expr: + #| - shape: wrapper(__TARGET__) + #| - shape: container(__TARGET__) + #|patterns-not: + #| - shape: danger() + #| + let source = + #|fn sample { + #| wrapper(safe()); + #| container(safe()); + #| wrapper(danger()); + #| container(holder(danger())) + #|} + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 2) + assert_eq(hits[0].loc.start.lnum, 2) + assert_eq(hits[1].loc.start.lnum, 3) +} + +///| +test "inside expr alternatives preserve outer locations and inner pattern indexes" { + let rule_source = + #|id: example + #|description: Ordered context metadata. + #|inside-expr: + #| - shape: wrapper(__TARGET__) + #| - shape: container(__TARGET__) + #|patterns: + #| - shape: first() + #| - shape: second() + #| + let source = + #|fn sample { + #| container(holder(second())) + #|} + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 1) + assert_eq(hits[0].pattern_index, 1) + assert_eq(hits[0].loc.start.lnum, 2) + assert_eq(hits[0].loc.start.column(), 3) +} + +///| +test "inside toplevel tries multiple outer shapes and inherits bindings" { + let rule_source = + #|id: example + #|description: Multiple top-level contexts. + #|inside-toplevel: + #| - shape: fn $(name:id) { __TARGET__ } + #| - shape: let $(name:id) = wrapper(__TARGET__) + #|patterns: + #| - shape: target($(name:id)) + #| + let source = + #|fn run { target(run) } + #|let value = wrapper(target(value)) + #| + let hits = apply_structural_rule_findings(source, rule_source) + assert_eq(hits.length(), 2) + assert_eq(hits[0].loc.start.lnum, 1) + assert_eq(hits[1].loc.start.lnum, 2) + assert_eq(hits[0].pattern_index, 0) + assert_eq(hits[1].pattern_index, 0) +} diff --git a/rule/apply/ast_bucket.mbt b/rule/apply/ast_bucket.mbt index f0b0dea..d91c417 100644 --- a/rule/apply/ast_bucket.mbt +++ b/rule/apply/ast_bucket.mbt @@ -20,10 +20,11 @@ // pattern in the YAML `patterns` array still wins and is reported as the same // `pattern_index`. // -// `inside-expr` and `inside-toplevel` use the same root-key idea only as a fast -// outer-context filter. Once the outer pattern binds `__TARGET__`, matching -// inside the target subtree still uses the inner pattern buckets and inherited -// bindings from the outer match. +// Ordered `inside-expr` and `inside-toplevel` alternatives use these buckets as +// a fast outer-context filter too. Wildcard and matching keyed alternatives are +// merged by YAML index before matching, so the first successful shape and guard +// still selects `__TARGET__`. Matching inside that target subtree then uses the +// inner pattern buckets and inherited bindings from the selected outer match. ///| priv struct IndexedCompiledRulePattern { @@ -110,18 +111,6 @@ fn compiled_pattern_root_key( } } -///| -// Used by inside-context rules before running the full outer pattern matcher. -fn node_root_key_matches( - pattern_key : @untyped_ast.NodeKind?, - node : @untyped_ast.Node, -) -> Bool { - match pattern_key { - None => true - Some(key) => key == node_root_key(node) - } -} - ///| // Precomputes pattern buckets once per structural rule. Arrays are appended in // source order, so each individual bucket is sorted by `pattern_index`. diff --git a/rule/apply/ast_bucket_wbtest.mbt b/rule/apply/ast_bucket_wbtest.mbt index 4f6d342..fda72af 100644 --- a/rule/apply/ast_bucket_wbtest.mbt +++ b/rule/apply/ast_bucket_wbtest.mbt @@ -43,7 +43,7 @@ fn compile_bucket_structural_patterns( fn compile_bucket_inside_pattern(source : String) -> CompiledRulePattern raise { match compile_bucket_rule_source(source).definition { Structural(structural) => - if structural.inside_expr is Some(pattern) { + if structural.inside_expr.get(0) is Some(pattern) { pattern } else { fail("expected inside-expr") @@ -64,8 +64,13 @@ fn manual_source_pattern(shape : String) -> CompiledRulePattern raise { ellipsis_metavars: [], target_metavar: None, source_metavar: Some("__SOURCE__"), + ignored_fields: [], + } + { + source: { shape, guards: Map([]), match_mode: Default }, + compiled, + guards: [], } - { source: { shape, guards: Map([]) }, compiled, guards: [] } } ///| @@ -115,7 +120,7 @@ test "ast bucket treats broad root placeholders as wildcard" { #|id: example #|description: Target root. #|inside-expr: - #| shape: __TARGET__ + #| - shape: __TARGET__ #|patterns: #| - shape: target() #| diff --git a/rule/builtin/builtin.mbt b/rule/builtin/builtin.mbt index 0ec8080..e8ce110 100644 --- a/rule/builtin/builtin.mbt +++ b/rule/builtin/builtin.mbt @@ -41,6 +41,10 @@ fn builtin_rule_sources() -> Array[BuiltinRuleSource] { path: "builtin/moonbitlang/match_option.yaml", yaml: @moonbitlang_rules.match_option_yaml, }, + { + path: "builtin/moonbitlang/unnessary_else.yaml", + yaml: @moonbitlang_rules.unnessary_else_yaml, + }, { path: "builtin/moonbitlang/cstyle_forward_simple_forloop.yaml", yaml: @moonbitlang_rules.cstyle_forward_simple_forloop_yaml, diff --git a/rule/builtin/builtin_test.mbt b/rule/builtin/builtin_test.mbt index 8d15a18..7be2c2f 100644 --- a/rule/builtin/builtin_test.mbt +++ b/rule/builtin/builtin_test.mbt @@ -13,6 +13,7 @@ test "builtin rules load moonbitlang rules" { #| "moonbitlang/inspect_boolean", #| "moonbitlang/inspect_number", #| "moonbitlang/match_option", + #| "moonbitlang/unnessary_else", #| "moonbitlang/cstyle_forward_simple_forloop", #| "moonbitlang/cstyle_backward_simple_forloop", #| "moonbitlang/cstyle_forward_array_iteration", @@ -21,3 +22,84 @@ test "builtin rules load moonbitlang rules" { ), ) } + +///| +test "builtin catch_all matches async function signature variants" { + let source = + #|async fn no_parameters { + #| try risky() catch { _ => () } + #|} + #| + #|async fn varied_parameters( + #| positional : Int, + #| labelled~ : String, + #| optional? : Bool = false, + #|) { + #| try risky() catch { error => () } + #|} + #| + #|async fn no_parameters_with_return -> Array[Int] { + #| try risky() catch { _ => [] } + #|} + #| + #|async fn parameters_with_return(_, value : Int) -> Int { + #| try risky() catch { error => value } + #|} + #| + #|async fn no_parameters_noraise noraise { + #| try risky() catch { _ => () } + #|} + #| + #|async fn parameters_with_return_noraise(value : Int) -> Int noraise { + #| try risky() catch { error => value } + #|} + #| + #|pub async fn public_no_parameters { + #| try risky() catch { _ => () } + #|} + #| + #|pub async fn public_parameters_with_return(value : Int) -> Int { + #| try risky() catch { error => value } + #|} + #| + #|pub async fn public_noraise(value : Int) noraise { + #| try risky() catch { _ => () } + #|} + #| + #|priv async fn private_no_parameters -> Unit { + #| try risky() catch { error => () } + #|} + #| + #|priv async fn private_parameters(value : Int) { + #| try risky() catch { _ => () } + #|} + #| + #|priv async fn private_noraise -> Unit noraise { + #| try risky() catch { error => () } + #|} + #| + #|/// Qualified generic function. + #|#custom.attribute + #|priv async fn[T] Box::qualified(value : T) -> T { + #| try risky() catch { error => value } + #|} + #| + #|fn synchronous_function(value : Int) -> Int { + #| try risky() catch { _ => value } + #|} + #| + let (impls, reports) = @parser.parse_string(source, name="catch_all test") + guard reports is [] else { fail("expected source to parse") } + let plan = @apply.ScanPlan::from_rules( + @compile.compile_rules(@builtin.load_rules()), + ) + let findings = @apply.apply_structural_scan_plan_to_node( + "sample.mbt", + @untyped_ast.from_impls(impls), + plan, + ) + assert_eq(findings.length(), 13) + for finding in findings { + assert_eq(finding.rule_id, "moonbitlang/catch_all") + } +} diff --git a/rule/builtin/moon.pkg b/rule/builtin/moon.pkg index 4eacb2f..3e00d32 100644 --- a/rule/builtin/moon.pkg +++ b/rule/builtin/moon.pkg @@ -2,3 +2,10 @@ import { "moonbit-community/moongrep/rule/internal/rules/moonbitlang" @moonbitlang_rules, "moonbit-community/moongrep/rule/model", } + +import { + "moonbit-community/moongrep/rule/apply", + "moonbit-community/moongrep/rule/compile", + "moonbit-community/moongrep/untyped_ast", + "moonbitlang/parser", +} for "test" diff --git a/rule/compile/INTERNAL.md b/rule/compile/INTERNAL.md index 87122c0..1d652eb 100644 --- a/rule/compile/INTERNAL.md +++ b/rule/compile/INTERNAL.md @@ -17,8 +17,8 @@ The package takes `RawRuleSpec` values from `rule/model` and produces Compilation is deliberately narrow. This package: - checks duplicate rule ids -- parses ordinary rule `shape` values as MoonBit expressions and - `inside-toplevel.shape` as one top-level item +- parses ordinary rule `shape` values as MoonBit expressions and each + `inside-toplevel` entry's shape as one top-level item - rewrites metavars into matcher-readable AST names - rejects unsupported placeholder positions and malformed guards - records taint sink and sanitizer target metadata @@ -54,12 +54,12 @@ enable_metavar = true ``` Then it parses the token stream with `parse_expr`. Ordinary `patterns`, -`patterns-not`, `inside-expr`, and taint clause shapes are therefore exactly one -MoonBit expression, not a file fragment or top-level declaration. +`patterns-not`, every `inside-expr` entry, and taint clause shapes are therefore +exactly one MoonBit expression, not a file fragment or top-level declaration. -`inside-toplevel.shape` uses the same lexer settings. It is parsed with -`parse_toplevel_shape`, which accepts exactly one MoonBit top-level item and -converts it with `@untyped_ast.from_impl`. +Each `inside-toplevel` entry's shape uses the same lexer settings. It is parsed +with `parse_toplevel_shape`, which accepts exactly one MoonBit top-level item +and converts it with `@untyped_ast.from_impl`. Lexing errors are reported before parse errors. `InvalidMetavarSyntax` gets a special diagnostic so legacy syntax such as `$exp:value` can point to the modern @@ -147,25 +147,28 @@ reserved. The exact built-ins listed above are reserved. ## Structural Rules -`compile_structural_rule` compiles `inside-expr` or `inside-toplevel` before -inner positive and negative patterns when an inside context exists. +`compile_structural_rule` compiles every ordered `inside-expr` or +`inside-toplevel` alternative before inner positive and negative patterns when +an inside context exists. -An inside-context shape is compiled as a normal pattern with: +Each inside-context shape is compiled as a normal pattern with: - the guards declared on its pattern object - `target_metavar = Some("__TARGET__")` - `source_metavar = None` -It must contain exactly one `__TARGET__` name as counted by +Every alternative must contain exactly one `__TARGET__` name as counted by `count_supported_name_in_node`. The runtime matcher only treats target/source metavars specially in whole expression positions, so changes to supported-name counting must be checked against `matching` behavior. -Metavars declared in the inside context are visible during target-expression -matching. Inner `patterns` and `patterns-not` must repeat the same inline form -to reuse the binding. `ensure_inherited_inside_context_metavar_forms` -rejects an inner pattern that redeclares an inherited name with a different -kind. +Metavars declared by the selected inside alternative are visible during +target-expression matching. Inner `patterns` and `patterns-not` must repeat the +same inline form to reuse the binding. Reused captures must be declared by +every outer alternative with the same kind, including named ellipsis captures. +`ensure_inherited_inside_context_metavar_forms_for_all` checks both +cross-alternative availability and kind consistency. Captures not referenced +by inner patterns remain branch-local and need not agree. Normal `patterns` and `patterns-not` are compiled independently after the inside context. They must not contain `__TARGET__`. @@ -253,9 +256,12 @@ When changing inside-context behavior: 1. keep the `__TARGET__` compile-time check in sync with matcher support 2. preserve inherited metavar kind checks for both `patterns` and `patterns-not` -3. test rules with positive patterns, negative patterns, and negative-only +3. preserve ordered first-match selection, including guard fallthrough but no + fallback after an outer alternative is selected +4. test rules with positive patterns, negative patterns, and negative-only `inside-expr` / `inside-toplevel` -4. check `rule/apply` traversal behavior +5. check `rule/apply` root buckets and `rule/prefilter` outer-by-inner + alternatives When changing taint target behavior: diff --git a/rule/compile/INTERNAL_CN.md b/rule/compile/INTERNAL_CN.md index 80f4cec..004b3b6 100644 --- a/rule/compile/INTERNAL_CN.md +++ b/rule/compile/INTERNAL_CN.md @@ -15,8 +15,8 @@ 编译职责有意保持收窄。这个包会: - 检查重复 rule id -- 把普通规则 `shape` 解析成 MoonBit 表达式,并把 `inside-toplevel.shape` - 解析成一个顶层项 +- 把普通规则 `shape` 解析成 MoonBit 表达式,并把每个 + `inside-toplevel` 条目的 shape 解析成一个顶层项 - 把 metavar 重写成 matcher 可读取的 AST 名称 - 拒绝不支持的占位符位置和格式错误的 guard - 记录 taint sink 和 sanitizer 的 target 元数据 @@ -49,10 +49,12 @@ enable_metavar = true ``` 随后用 `parse_expr` 解析 token 流。因此普通 `patterns`、`patterns-not`、 -`inside-expr` 和 taint 子句的 shape 正好是一个 MoonBit 表达式,不是文件片段或顶层声明。 +每个 `inside-expr` 条目和 taint 子句的 shape 正好是一个 MoonBit 表达式, +不是文件片段或顶层声明。 -`inside-toplevel.shape` 使用同样的 lexer 配置。它通过 `parse_toplevel_shape` -解析,接受且只接受一个 MoonBit 顶层项,并通过 `@untyped_ast.from_impl` 转换。 +每个 `inside-toplevel` 条目的 shape 使用同样的 lexer 配置。它通过 +`parse_toplevel_shape` 解析,接受且只接受一个 MoonBit 顶层项,并通过 +`@untyped_ast.from_impl` 转换。 词法错误会先于解析错误报告。`InvalidMetavarSyntax` 有专门诊断,因此 `$exp:value` 这样的旧语法可以提示迁移到现代的 `$(value:exp)` 形式。 @@ -130,23 +132,26 @@ Inline 声明不能使用: ## Structural Rule -`compile_structural_rule` 会在存在 inside context 时,先编译 `inside-expr` -或 `inside-toplevel`,再编译内部 positive / negative pattern。 +`compile_structural_rule` 会在存在 inside context 时,先编译 +`inside-expr` 或 `inside-toplevel` 的所有有序备选项,再编译内部 +positive / negative pattern。 -inside-context shape 会被编译成普通 pattern,并使用: +每个 inside-context shape 会被编译成普通 pattern,并使用: - 它的 pattern object 上声明的 guard - `target_metavar = Some("__TARGET__")` - `source_metavar = None` -它必须包含恰好一个由 `count_supported_name_in_node` 统计到的 `__TARGET__` 名称。 +每个备选项都必须包含恰好一个由 `count_supported_name_in_node` 统计到的 +`__TARGET__` 名称。 运行时 matcher 只会在完整表达式位置特殊处理 target/source metavar,因此修改 supported-name 统计时必须同时检查 `matching` 行为。 -inside context 中声明的 metavar 在匹配目标表达式时可见。内部的 `patterns` 和 -`patterns-not` 必须重复相同 inline 形式才能复用该绑定。 -`ensure_inherited_inside_context_metavar_forms` 会拒绝内部 pattern 用不同 kind -重新声明继承来的名称。 +选中的 inside 备选项中声明的 metavar 在匹配目标表达式时可见。内部的 +`patterns` 和 `patterns-not` 必须重复相同 inline 形式才能复用该绑定。 +被复用的捕获必须由每个外层备选项以相同 kind 声明,包括命名 ellipsis +捕获。`ensure_inherited_inside_context_metavar_forms_for_all` 同时检查跨备选项 +可用性和 kind 一致性。内部未引用的捕获保持分支局部,不要求一致。 普通 `patterns` 和 `patterns-not` 会在 inside context 之后独立编译。它们不能包含 `__TARGET__`。 @@ -224,9 +229,10 @@ call argument,并用 label 确认选中的是同一个 labelled slot。 1. 保持 `__TARGET__` 编译期检查与 matcher 支持一致 2. 对 `patterns` 和 `patterns-not` 都保留继承 metavar 的 kind 检查 -3. 测试含 positive pattern、negative pattern 和 negative-only +3. 保持有序首项选择语义:guard 失败可以继续,但外层条目一旦选中就不回退 +4. 测试含 positive pattern、negative pattern 和 negative-only `inside-expr` / `inside-toplevel` 的规则 -4. 检查 `rule/apply` 的遍历行为 +5. 检查 `rule/apply` root buckets,以及 `rule/prefilter` 的外层×内层备选项 修改 taint target 行为时: diff --git a/rule/compile/compile.mbt b/rule/compile/compile.mbt index 618d003..ff97744 100644 --- a/rule/compile/compile.mbt +++ b/rule/compile/compile.mbt @@ -9,6 +9,7 @@ using @model { type CompiledTaintSanitizer, type CompiledTaintSink, type CompiledTaintSource, + type InsideToplevelMatchMode, type RawRuleSpec, type RuleLoadError, type RuleGuardCaptureKind, @@ -60,96 +61,44 @@ fn compile_structural_rule( path : String, spec : StructuralRuleSpec, ) -> CompiledStructuralRule raise { - if spec.inside_expr is Some(_) && spec.inside_toplevel is Some(_) { + if !spec.inside_expr.is_empty() && !spec.inside_toplevel.is_empty() { raise RuleLoadError::InvalidRule( path~, info="inside-expr and inside-toplevel are mutually exclusive", ) } - let outer_pattern_names : Array[String] = [] - let inside_expr = if spec.inside_expr is Some(pattern) { - Some( - compile_inside_context_pattern( - path, pattern, "inside-expr", false, outer_pattern_names, - ), - ) - } else { - None - } - let inside_toplevel = if spec.inside_toplevel is Some(pattern) { - Some( - compile_inside_context_pattern( - path, pattern, "inside-toplevel", true, outer_pattern_names, - ), - ) - } else { - None - } + let (inside_expr, inside_expr_shapes) = compile_inside_context_patterns( + path, + spec.inside_expr, + "inside-expr", + is_toplevel=false, + ) + let (inside_toplevel, inside_toplevel_shapes) = compile_inside_context_patterns( + path, + spec.inside_toplevel, + "inside-toplevel", + is_toplevel=true, + ) // Metavars from the outer context remain visible while matching each target - // expression. Inner patterns reuse them by repeating the same inline kind. - let outer_context = if inside_toplevel is Some(_) { + // expression. A metavar reused by an inner pattern must be declared with the + // same kind by every ordered outer alternative. + let outer_context = if !inside_toplevel.is_empty() { "inside-toplevel" } else { "inside-expr" } - let outer_pattern = if inside_toplevel is Some(pattern) { - Some(pattern) - } else { - inside_expr - } - let outer_expr = if outer_pattern is Some(pattern) { - pattern.compiled.expr_metavars - } else { - [] - } - let outer_identifier = if outer_pattern is Some(pattern) { - pattern.compiled.identifier_metavars - } else { - [] - } - let outer_constant = if outer_pattern is Some(pattern) { - pattern.compiled.constant_metavars - } else { - [] - } - let outer_arg = if outer_pattern is Some(pattern) { - pattern.compiled.arg_metavars + let outer_shapes = if !inside_toplevel_shapes.is_empty() { + inside_toplevel_shapes } else { - [] - } - let outer_type = if outer_pattern is Some(pattern) { - pattern.compiled.type_metavars - } else { - [] - } - let outer_ellipsis = if outer_pattern is Some(pattern) { - pattern.compiled.ellipsis_metavars - } else { - [] + inside_expr_shapes } let patterns : Array[CompiledRulePattern] = [] for index in 0.. CompiledRulePattern raise { - let compiled_shape = if is_toplevel { - compile_toplevel_metavar_shape(path, context, pattern.shape) - } else { - compile_metavar_shape(path, context, pattern.shape) - } - let ast = compiled_shape.ast - let target_count = count_supported_name_in_node(ast, "__TARGET__") - if target_count != 1 { - raise RuleLoadError::InvalidRule( - path~, - info="\{context} must contain exactly one __TARGET__ placeholder in a supported expression position", + is_toplevel~ : Bool, +) -> (Array[CompiledRulePattern], Array[MetavarShape]) raise { + let compiled_patterns : Array[CompiledRulePattern] = [] + let compiled_shapes : Array[MetavarShape] = [] + for index in 0.. Array[@matching.IgnoredPatternField] raise { + let is_partial = match match_mode { + Default => ast.kind == Impl_TopFuncDef + Exact => false + Partial => + if ast.kind == Impl_TopFuncDef { + true + } else { + raise RuleLoadError::InvalidRule( + path~, + info="\{context}.match-mode partial is only supported for function definition shapes", + ) + } } - compile_pattern( - pattern, - compiled_shape, - path, - context, - target_metavar=Some("__TARGET__"), - source_metavar=None, + if !is_partial { + return [] + } + partial_top_func_ignored_fields(ast) +} + +///| +fn partial_top_func_ignored_fields( + ast : @untyped_ast.Node, +) -> Array[@matching.IgnoredPatternField] { + let ignored : Array[@matching.IgnoredPatternField] = [] + guard ast_child(ast, "fun_decl") is Some(fun_decl) else { return ignored } + add_ignored_field_if_default( + ignored, + FunDecl, + "type_name", + ast_child_is_null(fun_decl, "type_name"), + ) + add_ignored_field_if_default( + ignored, + FunDecl, + "is_async", + ast_child_is_null(fun_decl, "is_async"), + ) + add_ignored_field_if_default( + ignored, + FunDecl, + "decl_params", + ast_child_is_null(fun_decl, "decl_params"), + ) + add_ignored_field_if_default( + ignored, + FunDecl, + "quantifiers", + ast_child_is_empty_list(fun_decl, "quantifiers", FunDecl_QuantifierList), + ) + add_ignored_field_if_default( + ignored, + FunDecl, + "return_type", + ast_child_is_null(fun_decl, "return_type"), + ) + add_ignored_field_if_default( + ignored, + FunDecl, + "error_type", + ast_child_has_kind(fun_decl, "error_type", ErrorType_NoErrorType), + ) + add_ignored_field_if_default( + ignored, + FunDecl, + "vis", + ast_child_has_kind(fun_decl, "vis", Visibility_Default), + ) + add_ignored_field_if_default( + ignored, + FunDecl, + "attrs", + ast_child_is_empty_list(fun_decl, "attrs", FunDecl_AttrList), ) + add_ignored_field_if_default( + ignored, + FunDecl, + "doc", + ast_child_is_empty_string(fun_decl, "doc"), + ) + add_ignored_field_if_default( + ignored, + Impl_TopFuncDef, + "where_clause", + ast_child_is_null(ast, "where_clause"), + ) + ignored +} + +///| +fn add_ignored_field_if_default( + ignored : Array[@matching.IgnoredPatternField], + parent_kind : @untyped_ast.NodeKind, + child_name : String, + is_default : Bool, +) -> Unit { + if is_default { + ignored.push({ parent_kind, child_name }) + } +} + +///| +fn ast_child(node : @untyped_ast.Node, name : String) -> @untyped_ast.Node? { + for entry in node.children { + let (child_name, child) = entry + if child_name == Some(name) { + return Some(child) + } + } + None +} + +///| +fn ast_child_is_null(node : @untyped_ast.Node, name : String) -> Bool { + match ast_child(node, name) { + Some({ kind: Leaf(PNull), .. }) => true + _ => false + } +} + +///| +fn ast_child_is_empty_list( + node : @untyped_ast.Node, + name : String, + kind : @untyped_ast.NodeKind, +) -> Bool { + match ast_child(node, name) { + Some(child) => child.kind == kind && child.children.is_empty() + None => false + } +} + +///| +fn ast_child_has_kind( + node : @untyped_ast.Node, + name : String, + kind : @untyped_ast.NodeKind, +) -> Bool { + match ast_child(node, name) { + Some(child) => child.kind == kind + None => false + } +} + +///| +fn ast_child_is_empty_string(node : @untyped_ast.Node, name : String) -> Bool { + match ast_child(node, name) { + Some({ kind: Leaf(PString("")), .. }) => true + _ => false + } } ///| @@ -352,6 +460,7 @@ fn compile_pattern( context : String, target_metavar~ : String?, source_metavar~ : String?, + ignored_fields? : Array[@matching.IgnoredPatternField] = [], ) -> CompiledRulePattern raise { compile_pattern_with_effective_metavars( pattern, @@ -367,6 +476,7 @@ fn compile_pattern( compiled_shape.ellipsis_metavars, target_metavar~, source_metavar~, + ignored_fields~, ) } @@ -385,6 +495,7 @@ fn compile_pattern_with_effective_metavars( ellipsis_metavars : Array[@matching.EllipsisMetavar], target_metavar~ : String?, source_metavar~ : String?, + ignored_fields? : Array[@matching.IgnoredPatternField] = [], ) -> CompiledRulePattern raise { let guards = compile_guards( pattern.guards, @@ -408,6 +519,7 @@ fn compile_pattern_with_effective_metavars( ellipsis_metavars, target_metavar, source_metavar, + ignored_fields, } { source: pattern, compiled, guards } } @@ -427,7 +539,7 @@ fn compile_guards( ) -> Array[CompiledRuleGuard] raise { let guards : Array[CompiledRuleGuard] = [] for raw_name, regex_source in raw_guards { - let name = guard_metavar_name(path, raw_name) + let name = guard_metavar_name(path, context, raw_name) let capture_kind = guard_capture_kind( path, context, raw_name, name, expr_metavars, identifier_metavars, constant_metavars, arg_metavars, type_metavars, pattern_metavars, ellipsis_metavars, @@ -445,11 +557,15 @@ fn compile_guards( } ///| -fn guard_metavar_name(path : String, raw_name : String) -> String raise { +fn guard_metavar_name( + path : String, + context : String, + raw_name : String, +) -> String raise { if !raw_name.has_prefix("$") || raw_name.length() <= 1 { raise RuleLoadError::InvalidRule( path~, - info="guard key \{raw_name} must be a $-prefixed metavar name", + info="\{context}.guard.\{raw_name} must be a $-prefixed metavar name", ) } raw_name[1:].to_owned() @@ -545,6 +661,197 @@ fn validate_declared_metavar( } } +///| +fn ensure_inherited_inside_context_metavar_forms_for_all( + path : String, + context : String, + outer_context : String, + inner : MetavarShape, + outers : Array[MetavarShape], +) -> Unit raise { + for index in 0.. Unit raise { + for name in inner_names { + let mut inherited = false + for outer in outers { + if metavar_shape_declares_name(outer, name) { + inherited = true + break + } + } + if inherited { + for index in 0.. Unit raise { + for metavar in inner_metavars { + let mut inherited = false + for outer in outers { + if metavar_shape_declares_name(outer, metavar.name) { + inherited = true + break + } + } + if inherited { + for index in 0.. Bool { + array_contains(shape.expr_metavars, name) || + array_contains(shape.identifier_metavars, name) || + array_contains(shape.constant_metavars, name) || + array_contains(shape.arg_metavars, name) || + array_contains(shape.type_metavars, name) || + array_contains(shape.pattern_metavars, name) || + ellipsis_metavars_contain(shape.ellipsis_metavars, name) +} + +///| +fn metavar_shape_declares_single_kind( + shape : MetavarShape, + name : String, + kind : String, +) -> Bool { + match kind { + "exp" => array_contains(shape.expr_metavars, name) + "id" => array_contains(shape.identifier_metavars, name) + "const" => array_contains(shape.constant_metavars, name) + "arg" => array_contains(shape.arg_metavars, name) + "type" => array_contains(shape.type_metavars, name) + "pat" => array_contains(shape.pattern_metavars, name) + _ => false + } +} + +///| +fn ellipsis_metavar_kind( + metavars : Array[@matching.EllipsisMetavar], + name : String, +) -> @matching.EllipsisMetavarKind? { + for metavar in metavars { + if metavar.name == name { + return Some(metavar.kind) + } + } + None +} + ///| fn ensure_inherited_inside_context_metavar_forms( path : String, diff --git a/rule/compile/metavar_expr.mbt b/rule/compile/metavar_expr.mbt index 27f6dfa..60fa1b9 100644 --- a/rule/compile/metavar_expr.mbt +++ b/rule/compile/metavar_expr.mbt @@ -197,6 +197,12 @@ fn MetavarRewriteContext::rewrite_expr( Method(type_name~, method_name=ctx.rewrite_label(method_name), loc~) MultilineString(elems~, loc~) => MultilineString(elems=ctx.rewrite_multiline_string_elems(elems), loc~) + Field(record~, accessor~, loc~) => + Field( + record=ctx.rewrite_expr(record), + accessor=ctx.rewrite_accessor(accessor), + loc~, + ) Mutate(record~, accessor~, field~, augmented_by~, loc~) => Mutate( record=ctx.rewrite_expr(record), diff --git a/rule/compile/moon.pkg b/rule/compile/moon.pkg index 5ceab06..1aa2b57 100644 --- a/rule/compile/moon.pkg +++ b/rule/compile/moon.pkg @@ -5,6 +5,7 @@ import { "moonbit-community/moongrep/untyped_ast", "moonbitlang/lexer", "moonbitlang/lexer/basic", + "moonbitlang/lexer/tokens", "moonbitlang/parser/handrolled_parser", "moonbitlang/parser/syntax", "moonbitlang/core/list", diff --git a/rule/compile/parse_shape.mbt b/rule/compile/parse_shape.mbt index b75b6c0..8b6f54d 100644 --- a/rule/compile/parse_shape.mbt +++ b/rule/compile/parse_shape.mbt @@ -79,9 +79,71 @@ fn parse_toplevel_shape( ) } let items = impls.to_array() + attach_toplevel_shape_docstring(lex_result.docstrings, items[0]) items[0] } +///| +fn attach_toplevel_shape_docstring( + docstrings : Array[@list.List[(@basic.Location, @tokens.Comment)]], + item : @syntax.Impl, +) -> Unit { + let item_loc = item.loc() + let mut selected : @list.List[(@basic.Location, @tokens.Comment)]? = None + // Lexer docstring groups are stored in reverse source order. Iterating from + // the end toward the beginning leaves the nearest group before this single + // top-level shape selected. + let mut index = docstrings.length() + while index > 0 { + index -= 1 + let comments = docstrings[index] + if comments.head() is Some((loc, _)) && + loc.start.lnum <= item_loc.start.lnum { + selected = Some(comments) + } + } + let doc = match selected { + None => @syntax.DocString::empty() + Some(comments) => shape_docstring_from_comments(comments) + } + match item { + TopFuncDef(fun_decl~, ..) => fun_decl.doc = doc + TopLetDef(..) as let_def => let_def.doc = doc + TopImpl(..) as impl_def => impl_def.doc = doc + TopImplRelation(..) as relation => relation.doc = doc + TopTest(..) as test_def => test_def.doc = doc + TopTrait(trait_decl) => trait_decl.doc = doc + TopTypeDef(type_decl) => type_decl.doc = doc + TopUsing(..) as using_def => using_def.doc = doc + TopView(..) as view_def => view_def.doc = doc + TopExpr(..) => () + } +} + +///| +fn shape_docstring_from_comments( + comments : @list.List[(@basic.Location, @tokens.Comment)], +) -> @syntax.DocString { + let first_loc = comments.head().unwrap().0 + let last_loc = comments.last().unwrap().0 + { + content: comments.map(fn(entry) { + let content = entry.1.content + match content { + [.. "///|", .. remain] => + if remain.is_blank() { + "" + } else { + remain.to_owned() + } + [.. "///", .. remain] => remain.to_owned() + _ => "" + } + }), + loc: { start: first_loc.start, end: last_loc.end }, + } +} + ///| fn shape_context_name(context : String) -> String { if context == "inside-expr" || context == "inside-toplevel" { diff --git a/rule/compile/validate_test.mbt b/rule/compile/validate_test.mbt index a7d85f3..2cf3e24 100644 --- a/rule/compile/validate_test.mbt +++ b/rule/compile/validate_test.mbt @@ -20,6 +20,20 @@ fn expect_compile_invalid(source : String, expected : String) -> Unit raise { } } +///| +fn compiled_ignores_field( + compiled : @matching.CompiledExprPattern, + parent_kind : @untyped_ast.NodeKind, + child_name : String, +) -> Bool { + for field in compiled.ignored_fields { + if field.parent_kind == parent_kind && field.child_name == child_name { + return true + } + } + false +} + ///| test "validate compiles bare and typed ellipsis metavars" { let source = @@ -653,13 +667,13 @@ test "validate rejects inherited type metavar kind mismatch" { #|id: example #|description: Example. #|inside-expr: - #| shape: | - #| let value : $(T:type) = __TARGET__ + #| - shape: | + #| let value : $(T:type) = __TARGET__ #|patterns: #| - shape: target($T) #| expect_compile_invalid( - source, "patterns[0] cannot use inherited inside-expr metavar T as id because it was declared as type", + source, "patterns[0] cannot use inherited inside-expr[0] metavar T as id because it was declared as type", ) } @@ -858,7 +872,7 @@ test "validate allows inherited inside expr metavars with matching inline forms" #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(value:exp), $(name:id), $(lit:const), $(arg:arg), __TARGET__) + #| - shape: wrapper($(value:exp), $(name:id), $(lit:const), $(arg:arg), __TARGET__) #|patterns: #| - shape: target($(value:exp), $(name:id), $(lit:const), $(arg:arg)) #| @@ -889,19 +903,18 @@ test "validate compiles inside expr id and const guards" { #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(name:id), $(lit:const), __TARGET__) - #| guard: - #| $name: "^safe_" - #| $lit: "^ok$" + #| - shape: wrapper($(name:id), $(lit:const), __TARGET__) + #| guard: + #| $name: "^safe_" + #| $lit: "^ok$" #|patterns: #| - shape: call() #| let rules = compile_one(source) match rules[0].definition { Structural(rule) => { - guard rule.inside_expr is Some(inside_expr) else { - fail("expected inside-expr") - } + assert_eq(rule.inside_expr.length(), 1) + let inside_expr = rule.inside_expr[0] assert_eq(inside_expr.guards.length(), 2) debug_inspect( inside_expr.compiled.identifier_metavars, @@ -919,49 +932,104 @@ test "validate compiles inside toplevel function shape" { #|id: example #|description: Example. #|inside-toplevel: - #| shape: | - #| fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } - #| guard: - #| $name: "^safe_" + #| - shape: | + #| fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } + #| guard: + #| $name: "^safe_" #|patterns: #| - shape: call($(param:id)) #| let rules = compile_one(source) match rules[0].definition { Structural(rule) => { - guard rule.inside_toplevel is Some(inside_toplevel) else { - fail("expected inside-toplevel") - } + assert_eq(rule.inside_toplevel.length(), 1) + let inside_toplevel = rule.inside_toplevel[0] assert_eq(inside_toplevel.guards.length(), 1) debug_inspect( inside_toplevel.compiled.identifier_metavars, content="[\"param\", \"name\"]", ) - assert_true(rule.inside_expr is None) + assert_true(rule.inside_expr.is_empty()) } Taint(_) => fail("expected structural rule") } } +///| +test "validate resolves inside toplevel match modes per alternative" { + let source = + #|id: example + #|description: Per-alternative top-level matching modes. + #|inside-toplevel: + #| - shape: fn default_fn { __TARGET__ } + #| - shape: fn exact_fn { __TARGET__ } + #| match-mode: exact + #| - shape: async fn partial_fn() -> Int noraise { __TARGET__ } + #| match-mode: partial + #| - shape: let value = __TARGET__ + #|patterns: + #| - shape: call() + #| + let rules = compile_one(source) + match rules[0].definition { + Structural(rule) => { + assert_eq(rule.inside_toplevel.length(), 4) + let default_fn = rule.inside_toplevel[0].compiled + assert_eq(default_fn.ignored_fields.length(), 10) + assert_true(compiled_ignores_field(default_fn, FunDecl, "is_async")) + assert_true(compiled_ignores_field(default_fn, FunDecl, "decl_params")) + assert_true( + compiled_ignores_field(default_fn, Impl_TopFuncDef, "where_clause"), + ) + let exact_fn = rule.inside_toplevel[1].compiled + assert_true(exact_fn.ignored_fields.is_empty()) + let partial_fn = rule.inside_toplevel[2].compiled + assert_false(compiled_ignores_field(partial_fn, FunDecl, "is_async")) + assert_false(compiled_ignores_field(partial_fn, FunDecl, "decl_params")) + assert_false(compiled_ignores_field(partial_fn, FunDecl, "return_type")) + assert_false(compiled_ignores_field(partial_fn, FunDecl, "error_type")) + assert_true(compiled_ignores_field(partial_fn, FunDecl, "type_name")) + assert_true(compiled_ignores_field(partial_fn, FunDecl, "quantifiers")) + assert_true(rule.inside_toplevel[3].compiled.ignored_fields.is_empty()) + } + Taint(_) => fail("expected structural rule") + } +} + +///| +test "validate rejects partial mode for non-function top-level shapes" { + let source = + #|id: example + #|description: Invalid partial top-level shape. + #|inside-toplevel: + #| - shape: let value = __TARGET__ + #| match-mode: partial + #|patterns: + #| - shape: call() + #| + expect_compile_invalid( + source, "inside-toplevel[0].match-mode partial is only supported for function definition shapes", + ) +} + ///| test "validate compiles inside toplevel let guards" { let source = #|id: example #|description: Example. #|inside-toplevel: - #| shape: let $(name:id) = wrapper($(lit:const), __TARGET__) - #| guard: - #| $name: "^safe_" - #| $lit: "^ok$" + #| - shape: let $(name:id) = wrapper($(lit:const), __TARGET__) + #| guard: + #| $name: "^safe_" + #| $lit: "^ok$" #|patterns: #| - shape: call() #| let rules = compile_one(source) match rules[0].definition { Structural(rule) => { - guard rule.inside_toplevel is Some(inside_toplevel) else { - fail("expected inside-toplevel") - } + assert_eq(rule.inside_toplevel.length(), 1) + let inside_toplevel = rule.inside_toplevel[0] assert_eq(inside_toplevel.guards.length(), 2) debug_inspect( inside_toplevel.compiled.identifier_metavars, @@ -982,13 +1050,13 @@ test "validate compiles inside toplevel test shape" { #|id: example #|description: Example. #|inside-toplevel: - #| shape: test "outer" { __TARGET__ } + #| - shape: test "outer" { __TARGET__ } #|patterns: #| - shape: call() #| let rules = compile_one(source) match rules[0].definition { - Structural(rule) => assert_true(rule.inside_toplevel is Some(_)) + Structural(rule) => assert_eq(rule.inside_toplevel.length(), 1) Taint(_) => fail("expected structural rule") } } @@ -1031,7 +1099,7 @@ test "validate patterns-not inherits inside expr metavars without positive patte #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) + #| - shape: wrapper($(name:id), __TARGET__) #|patterns-not: #| - shape: blocked($(name:id)) #| @@ -1055,8 +1123,8 @@ test "validate patterns-not inherits inside toplevel metavars without positive p #|id: example #|description: Example. #|inside-toplevel: - #| shape: | - #| fn $(name:id) { __TARGET__ } + #| - shape: | + #| fn $(name:id) { __TARGET__ } #|patterns-not: #| - shape: blocked($(name:id)) #| @@ -1095,13 +1163,13 @@ test "validate rejects inherited inside toplevel kind mismatch" { #|id: example #|description: Example. #|inside-toplevel: - #| shape: | - #| fn $(name:id) { __TARGET__ } + #| - shape: | + #| fn $(name:id) { __TARGET__ } #|patterns: #| - shape: target($(name:exp)) #| expect_compile_invalid( - source, "patterns[0] cannot use inherited inside-toplevel metavar name as exp because it was declared as id", + source, "patterns[0] cannot use inherited inside-toplevel[0] metavar name as exp because it was declared as id", ) } @@ -1111,12 +1179,12 @@ test "validate rejects inherited inside expr kind mismatch in patterns-not" { #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(prefix:exp), __TARGET__) + #| - shape: wrapper($(prefix:exp), __TARGET__) #|patterns-not: #| - shape: target($(prefix:id)) #| expect_compile_invalid( - source, "patterns-not[0] cannot use inherited inside-expr metavar prefix as id because it was declared as exp", + source, "patterns-not[0] cannot use inherited inside-expr[0] metavar prefix as id because it was declared as exp", ) } @@ -1126,12 +1194,12 @@ test "validate rejects inherited inside expr metavars with different inline form #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(prefix:exp), __TARGET__) + #| - shape: wrapper($(prefix:exp), __TARGET__) #|patterns: #| - shape: target($(prefix:id)) #| expect_compile_invalid( - source, "patterns[0] cannot use inherited inside-expr metavar prefix as id because it was declared as exp", + source, "patterns[0] cannot use inherited inside-expr[0] metavar prefix as id because it was declared as exp", ) } @@ -1141,12 +1209,12 @@ test "validate rejects inherited inside expr argument kind mismatch" { #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(arg:arg), __TARGET__) + #| - shape: wrapper($(arg:arg), __TARGET__) #|patterns: #| - shape: target($(arg:exp)) #| expect_compile_invalid( - source, "patterns[0] cannot use inherited inside-expr metavar arg as exp because it was declared as arg", + source, "patterns[0] cannot use inherited inside-expr[0] metavar arg as exp because it was declared as arg", ) } @@ -1156,7 +1224,7 @@ test "validate inside expr constant metavars use matching inline forms" { #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(lit:const), __TARGET__) + #| - shape: wrapper($(lit:const), __TARGET__) #|patterns: #| - shape: target($(lit:const)) #| @@ -1177,12 +1245,12 @@ test "validate inside expr requires exactly one target" { #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper(x) + #| - shape: wrapper(x) #|patterns: #| - shape: x #| expect_compile_invalid( - source, "inside-expr must contain exactly one __TARGET__ placeholder in a supported expression position", + source, "inside-expr[0].shape must contain exactly one __TARGET__ placeholder in a supported expression position", ) } @@ -1192,23 +1260,23 @@ test "validate inside toplevel requires exactly one target" { #|id: example #|description: Example. #|inside-toplevel: - #| shape: fn sample { wrapper(x) } + #| - shape: fn sample { wrapper(x) } #|patterns: #| - shape: x #| expect_compile_invalid( - missing_target, "inside-toplevel must contain exactly one __TARGET__ placeholder in a supported expression position", + missing_target, "inside-toplevel[0].shape must contain exactly one __TARGET__ placeholder in a supported expression position", ) let duplicate_target = #|id: example #|description: Example. #|inside-toplevel: - #| shape: fn sample { __TARGET__; __TARGET__ } + #| - shape: fn sample { __TARGET__; __TARGET__ } #|patterns: #| - shape: x #| expect_compile_invalid( - duplicate_target, "inside-toplevel must contain exactly one __TARGET__ placeholder in a supported expression position", + duplicate_target, "inside-toplevel[0].shape must contain exactly one __TARGET__ placeholder in a supported expression position", ) } @@ -1340,7 +1408,7 @@ test "validate allows inner guard to reference inside expr id and const captures #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(name:id), $(lit:const), __TARGET__) + #| - shape: wrapper($(name:id), $(lit:const), __TARGET__) #|patterns: #| - shape: call($(name:id), $(lit:const)) #| guard: @@ -1370,7 +1438,7 @@ test "validate allows inner guard to reference inside toplevel id and const capt #|id: example #|description: Example. #|inside-toplevel: - #| shape: let $(name:id) = wrapper($(lit:const), __TARGET__) + #| - shape: let $(name:id) = wrapper($(lit:const), __TARGET__) #|patterns: #| - shape: call($(name:id), $(lit:const)) #| guard: @@ -1400,65 +1468,239 @@ test "validate rejects unsupported inside expr guard captures" { #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) - #| guard: - #| $other: "^safe_" + #| - shape: wrapper($(name:id), __TARGET__) + #| guard: + #| $other: "^safe_" #|patterns: #| - shape: call() #| expect_compile_invalid( - unknown_source, "inside-expr.guard.$other references unknown metavar", + unknown_source, "inside-expr[0].guard.$other references unknown metavar", ) let exp_source = #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(value:exp), __TARGET__) - #| guard: - #| $value: "^safe_" + #| - shape: wrapper($(value:exp), __TARGET__) + #| guard: + #| $value: "^safe_" #|patterns: #| - shape: call() #| expect_compile_invalid( - exp_source, "inside-expr.guard.$value cannot reference exp metavar value", + exp_source, "inside-expr[0].guard.$value cannot reference exp metavar value", ) let arg_source = #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(value:arg), __TARGET__) - #| guard: - #| $value: "^safe_" + #| - shape: wrapper($(value:arg), __TARGET__) + #| guard: + #| $value: "^safe_" #|patterns: #| - shape: call() #| expect_compile_invalid( - arg_source, "inside-expr.guard.$value cannot reference arg metavar value", + arg_source, "inside-expr[0].guard.$value cannot reference arg metavar value", ) let pat_source = #|id: example #|description: Example. #|inside-expr: - #| shape: match input { $(item:pat) => __TARGET__ } - #| guard: - #| $item: "^Some" + #| - shape: match input { $(item:pat) => __TARGET__ } + #| guard: + #| $item: "^Some" #|patterns: #| - shape: call() #| expect_compile_invalid( - pat_source, "inside-expr.guard.$item cannot reference pat metavar item", + pat_source, "inside-expr[0].guard.$item cannot reference pat metavar item", ) let invalid_regex_source = #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) - #| guard: - #| $name: "(" + #| - shape: wrapper($(name:id), __TARGET__) + #| guard: + #| $name: "(" #|patterns: #| - shape: call() #| expect_compile_invalid( - invalid_regex_source, "inside-expr.guard.$name has invalid regex", + invalid_regex_source, "inside-expr[0].guard.$name has invalid regex", + ) +} + +///| +test "validate compiles ordered inside expr alternatives independently" { + let source = + #|id: example + #|description: Multiple expression contexts. + #|inside-expr: + #| - shape: wrapper($(name:id), __TARGET__) + #| - shape: container($(name:id), __TARGET__) + #| guard: + #| $name: "^safe_" + #|patterns: + #| - shape: target($(name:id)) + #| + let rules = compile_one(source) + match rules[0].definition { + Structural(rule) => { + assert_eq(rule.inside_expr.length(), 2) + assert_eq(rule.inside_expr[0].guards.length(), 0) + assert_eq(rule.inside_expr[1].guards.length(), 1) + debug_inspect( + rule.inside_expr[0].compiled.identifier_metavars, + content="[\"name\"]", + ) + debug_inspect( + rule.inside_expr[1].compiled.identifier_metavars, + content="[\"name\"]", + ) + } + Taint(_) => fail("expected structural rule") + } +} + +///| +test "validate compiles ordered inside toplevel alternatives independently" { + let source = + #|id: example + #|description: Multiple top-level contexts. + #|inside-toplevel: + #| - shape: fn $(name:id) { __TARGET__ } + #| - shape: let $(name:id) = wrapper(__TARGET__) + #|patterns: + #| - shape: target($(name:id)) + #| + let rules = compile_one(source) + match rules[0].definition { + Structural(rule) => { + assert_eq(rule.inside_toplevel.length(), 2) + debug_inspect( + rule.inside_toplevel[0].compiled.identifier_metavars, + content="[\"name\"]", + ) + debug_inspect( + rule.inside_toplevel[1].compiled.identifier_metavars, + content="[\"name\"]", + ) + } + Taint(_) => fail("expected structural rule") + } +} + +///| +test "validate requires reused inside captures in every outer alternative" { + let missing = + #|id: example + #|description: Missing inherited capture. + #|inside-expr: + #| - shape: wrapper($(name:id), __TARGET__) + #| - shape: container(__TARGET__) + #|patterns: + #| - shape: target($(name:id)) + #| + expect_compile_invalid( + missing, "patterns[0] cannot use inherited inside-expr metavar name as id because inside-expr[1] does not declare it", + ) + let wrong_kind = + #|id: example + #|description: Conflicting inherited capture. + #|inside-expr: + #| - shape: wrapper($(name:id), __TARGET__) + #| - shape: container($(name:exp), __TARGET__) + #|patterns: + #| - shape: target($(name:id)) + #| + expect_compile_invalid( + wrong_kind, "patterns[0] cannot use inherited inside-expr[1] metavar name as id because it was declared as exp", + ) + let missing_from_patterns_not = + #|id: example + #|description: Missing inherited negative capture. + #|inside-expr: + #| - shape: wrapper($(name:id), __TARGET__) + #| - shape: container(__TARGET__) + #|patterns-not: + #| - shape: blocked($(name:id)) + #| + expect_compile_invalid( + missing_from_patterns_not, "patterns-not[0] cannot use inherited inside-expr metavar name as id because inside-expr[1] does not declare it", + ) +} + +///| +test "validate requires reused ellipsis captures in every outer alternative" { + let missing = + #|id: example + #|description: Missing inherited ellipsis. + #|inside-expr: + #| - shape: wrapper($$$(items:exp), __TARGET__) + #| - shape: container(__TARGET__) + #|patterns: + #| - shape: target($$$(items:exp)) + #| + expect_compile_invalid( + missing, "patterns[0] cannot use inherited inside-expr ellipsis metavar items because inside-expr[1] does not declare it with kind exp", + ) + let wrong_kind = + #|id: example + #|description: Conflicting inherited ellipsis. + #|inside-expr: + #| - shape: wrapper($$$(items:exp), __TARGET__) + #| - shape: container($$$(items:id), __TARGET__) + #|patterns: + #| - shape: target($$$(items:exp)) + #| + expect_compile_invalid( + wrong_kind, "patterns[0] cannot use inherited inside-expr[1] ellipsis metavar items with a different kind", + ) +} + +///| +test "validate allows unreferenced outer captures to differ by alternative" { + let source = + #|id: example + #|description: Branch-local captures. + #|inside-expr: + #| - shape: wrapper($(left:id), __TARGET__) + #| - shape: container($(right:exp), __TARGET__) + #|patterns: + #| - shape: target() + #| + let rules = compile_one(source) + match rules[0].definition { + Structural(rule) => assert_eq(rule.inside_expr.length(), 2) + Taint(_) => fail("expected structural rule") + } +} + +///| +test "validate indexes target errors in later outer alternatives" { + let source = + #|id: example + #|description: Missing target in second context. + #|inside-expr: + #| - shape: wrapper(__TARGET__) + #| - shape: container(no_target) + #|patterns: + #| - shape: target() + #| + expect_compile_invalid( + source, "inside-expr[1].shape must contain exactly one __TARGET__ placeholder in a supported expression position", + ) + let toplevel_source = + #|id: example + #|description: Duplicate target in second top-level context. + #|inside-toplevel: + #| - shape: fn sample { __TARGET__ } + #| - shape: let value = pair(__TARGET__, __TARGET__) + #|patterns: + #| - shape: target() + #| + expect_compile_invalid( + toplevel_source, "inside-toplevel[1].shape must contain exactly one __TARGET__ placeholder in a supported expression position", ) } diff --git a/rule/internal/rules/moonbitlang/catch_all.mbt b/rule/internal/rules/moonbitlang/catch_all.mbt index aec00e3..971900f 100644 --- a/rule/internal/rules/moonbitlang/catch_all.mbt +++ b/rule/internal/rules/moonbitlang/catch_all.mbt @@ -8,9 +8,8 @@ let _embed_catch_all_yaml : String = #| Prefer matching only the specific error cases that can be recovered from. #| #|inside-toplevel: - #| # TODO: support matching params - #| shape: | - #| async fn $f(_) -> $T { + #| - shape: | + #| async fn $_ { #| __TARGET__ #| } #| diff --git a/rule/internal/rules/moonbitlang/catch_all.yaml b/rule/internal/rules/moonbitlang/catch_all.yaml index 12cdb60..24fcdf7 100644 --- a/rule/internal/rules/moonbitlang/catch_all.yaml +++ b/rule/internal/rules/moonbitlang/catch_all.yaml @@ -4,9 +4,8 @@ description: | Prefer matching only the specific error cases that can be recovered from. inside-toplevel: - # TODO: support matching params - shape: | - async fn $f(_) -> $T { + - shape: | + async fn $_ { __TARGET__ } diff --git a/rule/internal/rules/moonbitlang/cstyle_backward_array_iteration.mbt b/rule/internal/rules/moonbitlang/cstyle_backward_array_iteration.mbt index 0174c16..921fd5b 100644 --- a/rule/internal/rules/moonbitlang/cstyle_backward_array_iteration.mbt +++ b/rule/internal/rules/moonbitlang/cstyle_backward_array_iteration.mbt @@ -7,10 +7,10 @@ let _embed_cstyle_backward_array_iteration_yaml : String = #| C-style backward array iteration that can be rewritten as simple for-in loops. #| #|inside-expr: - #| shape: | - #| for $(counter:id) = $(arr:id).length() - 1; $(counter:id) >= 0; $(counter:id) = $(counter:id) - 1 { - #| __TARGET__ - #| } + #| - shape: | + #| for $(counter:id) = $(arr:id).length() - 1; $(counter:id) >= 0; $(counter:id) = $(counter:id) - 1 { + #| __TARGET__ + #| } #| #|patterns: #| - shape: | diff --git a/rule/internal/rules/moonbitlang/cstyle_backward_array_iteration.yaml b/rule/internal/rules/moonbitlang/cstyle_backward_array_iteration.yaml index 0c95d79..81624ab 100644 --- a/rule/internal/rules/moonbitlang/cstyle_backward_array_iteration.yaml +++ b/rule/internal/rules/moonbitlang/cstyle_backward_array_iteration.yaml @@ -3,10 +3,10 @@ description: | C-style backward array iteration that can be rewritten as simple for-in loops. inside-expr: - shape: | - for $(counter:id) = $(arr:id).length() - 1; $(counter:id) >= 0; $(counter:id) = $(counter:id) - 1 { - __TARGET__ - } + - shape: | + for $(counter:id) = $(arr:id).length() - 1; $(counter:id) >= 0; $(counter:id) = $(counter:id) - 1 { + __TARGET__ + } patterns: - shape: | diff --git a/rule/internal/rules/moonbitlang/cstyle_backward_simple_forloop.mbt b/rule/internal/rules/moonbitlang/cstyle_backward_simple_forloop.mbt index 98f5010..5d8e5a6 100644 --- a/rule/internal/rules/moonbitlang/cstyle_backward_simple_forloop.mbt +++ b/rule/internal/rules/moonbitlang/cstyle_backward_simple_forloop.mbt @@ -7,10 +7,10 @@ let _embed_cstyle_backward_simple_forloop_yaml : String = #| C-style backward for loops that can be rewritten as simple for-in loops. #| #|inside-expr: - #| shape: | - #| for $(counter:id) = $(start:exp); $(counter:id) > $(limit:exp); $(counter:id) = $(counter:id) - 1 { - #| __TARGET__ - #| } + #| - shape: | + #| for $(counter:id) = $(start:exp); $(counter:id) > $(limit:exp); $(counter:id) = $(counter:id) - 1 { + #| __TARGET__ + #| } #|patterns-not: #| - shape: $(counter:id) #| diff --git a/rule/internal/rules/moonbitlang/cstyle_backward_simple_forloop.yaml b/rule/internal/rules/moonbitlang/cstyle_backward_simple_forloop.yaml index 6dbafa1..7fbd291 100644 --- a/rule/internal/rules/moonbitlang/cstyle_backward_simple_forloop.yaml +++ b/rule/internal/rules/moonbitlang/cstyle_backward_simple_forloop.yaml @@ -3,9 +3,9 @@ description: | C-style backward for loops that can be rewritten as simple for-in loops. inside-expr: - shape: | - for $(counter:id) = $(start:exp); $(counter:id) > $(limit:exp); $(counter:id) = $(counter:id) - 1 { - __TARGET__ - } + - shape: | + for $(counter:id) = $(start:exp); $(counter:id) > $(limit:exp); $(counter:id) = $(counter:id) - 1 { + __TARGET__ + } patterns-not: - shape: $(counter:id) diff --git a/rule/internal/rules/moonbitlang/cstyle_forward_array_iteration.mbt b/rule/internal/rules/moonbitlang/cstyle_forward_array_iteration.mbt index 258a4c3..0e3ce4c 100644 --- a/rule/internal/rules/moonbitlang/cstyle_forward_array_iteration.mbt +++ b/rule/internal/rules/moonbitlang/cstyle_forward_array_iteration.mbt @@ -7,10 +7,10 @@ let _embed_cstyle_forward_array_iteration_yaml : String = #| C-style forward array iteration that can be rewritten as simple for-in loops. #| #|inside-expr: - #| shape: | - #| for $(counter:id) = 0; $(counter:id) < $(arr:id).length(); $(counter:id) = $(counter:id) + 1 { - #| __TARGET__ - #| } + #| - shape: | + #| for $(counter:id) = 0; $(counter:id) < $(arr:id).length(); $(counter:id) = $(counter:id) + 1 { + #| __TARGET__ + #| } #| #|patterns: #| - shape: | diff --git a/rule/internal/rules/moonbitlang/cstyle_forward_array_iteration.yaml b/rule/internal/rules/moonbitlang/cstyle_forward_array_iteration.yaml index 9ced180..482b438 100644 --- a/rule/internal/rules/moonbitlang/cstyle_forward_array_iteration.yaml +++ b/rule/internal/rules/moonbitlang/cstyle_forward_array_iteration.yaml @@ -3,10 +3,10 @@ description: | C-style forward array iteration that can be rewritten as simple for-in loops. inside-expr: - shape: | - for $(counter:id) = 0; $(counter:id) < $(arr:id).length(); $(counter:id) = $(counter:id) + 1 { - __TARGET__ - } + - shape: | + for $(counter:id) = 0; $(counter:id) < $(arr:id).length(); $(counter:id) = $(counter:id) + 1 { + __TARGET__ + } patterns: - shape: | diff --git a/rule/internal/rules/moonbitlang/cstyle_forward_simple_forloop.mbt b/rule/internal/rules/moonbitlang/cstyle_forward_simple_forloop.mbt index 22940e6..f423237 100644 --- a/rule/internal/rules/moonbitlang/cstyle_forward_simple_forloop.mbt +++ b/rule/internal/rules/moonbitlang/cstyle_forward_simple_forloop.mbt @@ -7,10 +7,10 @@ let _embed_cstyle_forward_simple_forloop_yaml : String = #| C-style forward for loops that can be rewritten as simple for-in loops. #| #|inside-expr: - #| shape: | - #| for $(counter:id) = $(start:exp); $(counter:id) < $(limit:exp); $(counter:id) = $(counter:id) + 1 { - #| __TARGET__ - #| } + #| - shape: | + #| for $(counter:id) = $(start:exp); $(counter:id) < $(limit:exp); $(counter:id) = $(counter:id) + 1 { + #| __TARGET__ + #| } #|patterns-not: #| - shape: $(counter:id) #| diff --git a/rule/internal/rules/moonbitlang/cstyle_forward_simple_forloop.yaml b/rule/internal/rules/moonbitlang/cstyle_forward_simple_forloop.yaml index 0b38063..be97c35 100644 --- a/rule/internal/rules/moonbitlang/cstyle_forward_simple_forloop.yaml +++ b/rule/internal/rules/moonbitlang/cstyle_forward_simple_forloop.yaml @@ -3,9 +3,9 @@ description: | C-style forward for loops that can be rewritten as simple for-in loops. inside-expr: - shape: | - for $(counter:id) = $(start:exp); $(counter:id) < $(limit:exp); $(counter:id) = $(counter:id) + 1 { - __TARGET__ - } + - shape: | + for $(counter:id) = $(start:exp); $(counter:id) < $(limit:exp); $(counter:id) = $(counter:id) + 1 { + __TARGET__ + } patterns-not: - shape: $(counter:id) diff --git a/rule/internal/rules/moonbitlang/moon.pkg b/rule/internal/rules/moonbitlang/moon.pkg index 5e31957..136121d 100644 --- a/rule/internal/rules/moonbitlang/moon.pkg +++ b/rule/internal/rules/moonbitlang/moon.pkg @@ -19,6 +19,12 @@ dev_build( dev_build(rule: "embed", input: "match_option.yaml", output: "match_option.mbt") +dev_build( + rule: "embed", + input: "unnessary_else.yaml", + output: "unnessary_else.mbt", +) + dev_build( rule: "embed", input: "cstyle_forward_simple_forloop.yaml", diff --git a/rule/internal/rules/moonbitlang/pkg.generated.mbti b/rule/internal/rules/moonbitlang/pkg.generated.mbti index 7d54df4..6404d8f 100644 --- a/rule/internal/rules/moonbitlang/pkg.generated.mbti +++ b/rule/internal/rules/moonbitlang/pkg.generated.mbti @@ -18,6 +18,8 @@ pub let inspect_number_yaml : String pub let match_option_yaml : String +pub let unnessary_else_yaml : String + // Errors // Types and methods diff --git a/rule/internal/rules/moonbitlang/unnessary_else.mbt b/rule/internal/rules/moonbitlang/unnessary_else.mbt new file mode 100644 index 0000000..cd5cf27 --- /dev/null +++ b/rule/internal/rules/moonbitlang/unnessary_else.mbt @@ -0,0 +1,24 @@ +// Generated by moonbit-community/embed from ./rule/internal/rules/moonbitlang/unnessary_else.yaml. + +///| +let _embed_unnessary_else_yaml : String = + #|id: unnessary_else + #|description: | + #| Found an if expression whose else branch is empty or only returns (). + #| Prefer omitting the unnecessary else branch. + #| + #|patterns: + #| - shape: | + #| if $_ { + #| $_ + #| } else { + #| () + #| } + #| - shape: | + #| if $_ { + #| $_ + #| } else {} + #| + +///| +pub let unnessary_else_yaml : String = _embed_unnessary_else_yaml diff --git a/rule/internal/rules/moonbitlang/unnessary_else.yaml b/rule/internal/rules/moonbitlang/unnessary_else.yaml new file mode 100644 index 0000000..e387ab4 --- /dev/null +++ b/rule/internal/rules/moonbitlang/unnessary_else.yaml @@ -0,0 +1,16 @@ +id: unnessary_else +description: | + Found an if expression whose else branch is empty or only returns (). + Prefer omitting the unnecessary else branch. + +patterns: + - shape: | + if $_ { + $_ + } else { + () + } + - shape: | + if $_ { + $_ + } else {} diff --git a/rule/model/pkg.generated.mbti b/rule/model/pkg.generated.mbti index 8b46de4..aaedb1b 100644 --- a/rule/model/pkg.generated.mbti +++ b/rule/model/pkg.generated.mbti @@ -43,8 +43,8 @@ pub(all) struct CompiledRulePattern { } pub(all) struct CompiledStructuralRule { - inside_expr : CompiledRulePattern? - inside_toplevel : CompiledRulePattern? + inside_expr : Array[CompiledRulePattern] + inside_toplevel : Array[CompiledRulePattern] patterns : Array[CompiledRulePattern] patterns_not : Array[CompiledRulePattern] patterns_not_mode : StructuralPatternsNotMode @@ -70,6 +70,12 @@ pub(all) struct CompiledTaintSource { pattern : CompiledRulePattern } +pub(all) enum InsideToplevelMatchMode { + Default + Exact + Partial +} + pub(all) struct RawRuleSpec { path : String rule_id : String @@ -99,6 +105,7 @@ pub(all) enum RuleGuardCaptureKind { pub(all) struct RulePatternSpec { shape : String guards : Map[String, String] + match_mode : InsideToplevelMatchMode } pub(all) struct RulePrefilter { @@ -111,8 +118,8 @@ pub(all) enum StructuralPatternsNotMode { } pub(all) struct StructuralRuleSpec { - inside_expr : RulePatternSpec? - inside_toplevel : RulePatternSpec? + inside_expr : Array[RulePatternSpec] + inside_toplevel : Array[RulePatternSpec] patterns : Array[RulePatternSpec] patterns_not : Array[RulePatternSpec] patterns_not_mode : StructuralPatternsNotMode diff --git a/rule/model/rulespec.mbt b/rule/model/rulespec.mbt index 7f788a2..e3fef78 100644 --- a/rule/model/rulespec.mbt +++ b/rule/model/rulespec.mbt @@ -18,18 +18,34 @@ pub impl Show for RuleLoadError with fn output(self, logger) { } } +///| +/// How an `inside-toplevel` shape matches its top-level item. +/// +/// `Default` is resolved while compiling each alternative: function +/// definitions use partial matching and all other top-level items use exact +/// matching. `Exact` preserves full AST matching. `Partial` is supported only +/// for function definitions. +pub(all) enum InsideToplevelMatchMode { + Default + Exact + Partial +} + ///| /// A single YAML pattern clause before it is parsed into a MoonBit AST. /// -/// `shape` must be a valid MoonBit expression, except for `inside-toplevel` -/// where it must be one top-level item. Inline `$(name:exp)`, `$(name:id)`, +/// `shape` must be a valid MoonBit expression, except in an +/// `inside-toplevel` entry where it must be one top-level item. Inline +/// `$(name:exp)`, `$(name:id)`, /// `$(name:const)`, `$(name:arg)`, `$(name:pat)`, and `$(name:type)` /// placeholders plus `$$$name` / `$$$(name:kind)` ordered-list ellipses are /// discovered during compilation. `guard`, when present, maps `$`-prefixed id -/// or const capture names to regex strings. +/// or const capture names to regex strings. `match_mode` is configurable only +/// on `inside-toplevel`; all other clauses store `Default`. pub(all) struct RulePatternSpec { shape : String guards : Map[String, String] + match_mode : InsideToplevelMatchMode } ///| @@ -48,14 +64,16 @@ pub(all) enum StructuralPatternsNotMode { ///| /// Raw structural-rule definition loaded from YAML. /// -/// `inside_expr`, when present, is a pattern object whose shape must contain a -/// single `__TARGET__` placeholder. The normal `patterns` are then matched -/// inside that target. `inside_toplevel` is the top-level-item equivalent and -/// is mutually exclusive with `inside_expr`. `patterns_not` holds negative +/// `inside_expr` is an ordered array of alternative pattern objects whose +/// shapes must each contain a single `__TARGET__` placeholder. The normal +/// `patterns` are then matched inside the target selected by the first matching +/// outer alternative. `inside_toplevel` is the top-level-item equivalent and +/// is mutually exclusive with `inside_expr`. Empty arrays mean that the +/// corresponding outer context is absent. `patterns_not` holds negative /// structural patterns that suppress otherwise successful structural matches. pub(all) struct StructuralRuleSpec { - inside_expr : RulePatternSpec? - inside_toplevel : RulePatternSpec? + inside_expr : Array[RulePatternSpec] + inside_toplevel : Array[RulePatternSpec] patterns : Array[RulePatternSpec] patterns_not : Array[RulePatternSpec] patterns_not_mode : StructuralPatternsNotMode @@ -128,8 +146,8 @@ pub(all) struct CompiledRuleGuard { ///| /// A structural rule after shape parsing and metavar validation. pub(all) struct CompiledStructuralRule { - inside_expr : CompiledRulePattern? - inside_toplevel : CompiledRulePattern? + inside_expr : Array[CompiledRulePattern] + inside_toplevel : Array[CompiledRulePattern] patterns : Array[CompiledRulePattern] patterns_not : Array[CompiledRulePattern] patterns_not_mode : StructuralPatternsNotMode diff --git a/rule/model/rulespec_parse.mbt b/rule/model/rulespec_parse.mbt index f95407d..caaef66 100644 --- a/rule/model/rulespec_parse.mbt +++ b/rule/model/rulespec_parse.mbt @@ -107,6 +107,7 @@ fn expect_structural_rule( path, "patterns", allow_guard=true, + allow_match_mode=false, ) } else { [] @@ -120,6 +121,7 @@ fn expect_structural_rule( path, "patterns-not", allow_guard=true, + allow_match_mode=false, ) } else { [] @@ -131,20 +133,20 @@ fn expect_structural_rule( let inside_toplevel = expect_inside_toplevel(doc, path) if !has_patterns && has_patterns_not && - inside_expr is None && - inside_toplevel is None { + inside_expr.is_empty() && + inside_toplevel.is_empty() { raise invalid_rule( path~, info="patterns-not requires patterns, inside-expr, or inside-toplevel", ) } - if !has_patterns && patterns_not.is_empty() && inside_expr is Some(_) { + if !has_patterns && patterns_not.is_empty() && !inside_expr.is_empty() { raise invalid_rule( path~, info="inside-expr requires patterns or patterns-not", ) } - if !has_patterns && patterns_not.is_empty() && inside_toplevel is Some(_) { + if !has_patterns && patterns_not.is_empty() && !inside_toplevel.is_empty() { raise invalid_rule( path~, info="inside-toplevel requires patterns or patterns-not", @@ -158,12 +160,12 @@ fn expect_structural_rule( ///| fn expect_patterns_not_mode( - inside_expr : RulePatternSpec?, - inside_toplevel : RulePatternSpec?, + inside_expr : Array[RulePatternSpec], + inside_toplevel : Array[RulePatternSpec], patterns : Array[RulePatternSpec], patterns_not : Array[RulePatternSpec], ) -> StructuralPatternsNotMode { - if (inside_expr is Some(_) || inside_toplevel is Some(_)) && + if (!inside_expr.is_empty() || !inside_toplevel.is_empty()) && !patterns.is_empty() && !patterns_not.is_empty() { RejectUncoveredNegative @@ -176,14 +178,22 @@ fn expect_patterns_not_mode( fn expect_inside_expr( doc : Map[String, @yaml.Yaml], path : String, -) -> RulePatternSpec? raise { +) -> Array[RulePatternSpec] raise { match doc.get("inside-expr") { - None => None - Some(Map(clause_map)) => - Some( - parse_rule_clause_map(clause_map, path, "inside-expr", allow_guard=true), + None => [] + Some(value) => { + let patterns = parse_rule_clause_array( + expect_yaml_array_value(value, path, "inside-expr"), + path, + "inside-expr", + allow_guard=true, + allow_match_mode=false, ) - Some(_) => raise invalid_rule(path~, info="inside-expr must be a mapping") + if patterns.is_empty() { + raise invalid_rule(path~, info="inside-expr must not be empty") + } + patterns + } } } @@ -191,20 +201,22 @@ fn expect_inside_expr( fn expect_inside_toplevel( doc : Map[String, @yaml.Yaml], path : String, -) -> RulePatternSpec? raise { +) -> Array[RulePatternSpec] raise { match doc.get("inside-toplevel") { - None => None - Some(Map(clause_map)) => - Some( - parse_rule_clause_map( - clause_map, - path, - "inside-toplevel", - allow_guard=true, - ), + None => [] + Some(value) => { + let patterns = parse_rule_clause_array( + expect_yaml_array_value(value, path, "inside-toplevel"), + path, + "inside-toplevel", + allow_guard=true, + allow_match_mode=true, ) - Some(_) => - raise invalid_rule(path~, info="inside-toplevel must be a mapping") + if patterns.is_empty() { + raise invalid_rule(path~, info="inside-toplevel must not be empty") + } + patterns + } } } @@ -224,6 +236,7 @@ fn expect_taint_rule( path, "taint.sources", allow_guard=false, + allow_match_mode=false, ) if sources.is_empty() { raise invalid_rule(path~, info="taint.sources must not be empty") @@ -233,6 +246,7 @@ fn expect_taint_rule( path, "taint.sinks", allow_guard=false, + allow_match_mode=false, ) if sinks.is_empty() { raise invalid_rule(path~, info="taint.sinks must not be empty") @@ -245,6 +259,7 @@ fn expect_taint_rule( path, "taint.sanitizers", allow_guard=false, + allow_match_mode=false, ) Some(_) => raise invalid_rule(path~, info="sanitizers must be an array") } @@ -257,6 +272,7 @@ fn parse_rule_clause_array( path : String, context : String, allow_guard~ : Bool, + allow_match_mode~ : Bool, ) -> Array[RulePatternSpec] raise { let clauses : Array[RulePatternSpec] = [] for index in 0.. RulePatternSpec raise { - ensure_allowed_keys(path, clause_context, clause_map, ["shape", "guard"]) + let allowed_keys = if allow_match_mode { + ["shape", "guard", "match-mode"] + } else { + ["shape", "guard"] + } + ensure_allowed_keys(path, clause_context, clause_map, allowed_keys) if clause_map.contains("guard") && !allow_guard { raise invalid_rule( path~, info="\{clause_context}.guard is not supported for taint rules", ) } - let shape = expect_yaml_string(clause_map, path, "shape") + let shape = expect_rule_clause_shape(clause_map, path, clause_context) let guards = parse_guard_map(clause_map, path, clause_context) - { shape, guards } + let match_mode = parse_inside_toplevel_match_mode( + clause_map, path, clause_context, + ) + { shape, guards, match_mode } +} + +///| +fn parse_inside_toplevel_match_mode( + clause_map : Map[String, @yaml.Yaml], + path : String, + clause_context : String, +) -> InsideToplevelMatchMode raise { + match clause_map.get("match-mode") { + None => Default + Some(String("exact")) => Exact + Some(String("partial")) => Partial + Some(String(_)) => + raise invalid_rule( + path~, + info="\{clause_context}.match-mode must be exact or partial", + ) + Some(_) => + raise invalid_rule( + path~, + info="\{clause_context}.match-mode must be a string", + ) + } } ///| @@ -301,7 +358,7 @@ fn parse_guard_map( Some(Map(guard_map)) => { let guards : Map[String, String] = Map([]) for key, value in guard_map { - ensure_guard_key(key, path) + ensure_guard_key(key, path, clause_context) match value { String(regex_source) => guards[key] = regex_source _ => @@ -322,15 +379,37 @@ fn parse_guard_map( } ///| -fn ensure_guard_key(key : String, path : String) -> Unit raise { +fn ensure_guard_key( + key : String, + path : String, + clause_context : String, +) -> Unit raise { if !key.has_prefix("$") || key.length() <= 1 { raise invalid_rule( path~, - info="guard key \{key} must be a $-prefixed metavar name", + info="\{clause_context}.guard.\{key} must be a $-prefixed metavar name", ) } } +///| +fn expect_rule_clause_shape( + map : Map[String, @yaml.Yaml], + path : String, + clause_context : String, +) -> String raise { + match map.get("shape") { + Some(String(value)) => value + Some(_) => + raise invalid_rule(path~, info="\{clause_context}.shape must be a string") + None => + raise invalid_rule( + path~, + info="missing required key \{clause_context}.shape", + ) + } +} + ///| fn ensure_allowed_keys( path : String, diff --git a/rule/model/rulespec_parse_test.mbt b/rule/model/rulespec_parse_test.mbt index 34506dd..79decb1 100644 --- a/rule/model/rulespec_parse_test.mbt +++ b/rule/model/rulespec_parse_test.mbt @@ -64,8 +64,8 @@ test "model parses structural rule and prefixes yaml id with rule directory" { inspect(spec.patterns[0].shape, content="$(_expr:exp) == $(_expr:exp)") assert_eq(spec.patterns[0].guards.length(), 0) assert_eq(spec.patterns_not.length(), 0) - assert_true(spec.inside_expr is None) - assert_true(spec.inside_toplevel is None) + assert_true(spec.inside_expr.is_empty()) + assert_true(spec.inside_toplevel.is_empty()) } Taint(_) => fail("expected structural rule") } @@ -280,7 +280,7 @@ test "model derives reject-uncovered-negative for inside expr positives and nega #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) + #| - shape: wrapper($(name:id), __TARGET__) #|patterns: #| - shape: arr[$(name:id)] #|patterns-not: @@ -302,10 +302,10 @@ test "model parses inside toplevel shape and guard map" { #|id: example #|description: Example. #|inside-toplevel: - #| shape: | - #| fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } - #| guard: - #| $name: "^safe_" + #| - shape: | + #| fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ } + #| guard: + #| $name: "^safe_" #|patterns: #| - shape: call($(param:id)) #| @@ -314,28 +314,121 @@ test "model parses inside toplevel shape and guard map" { ) match rule.definition { Structural(spec) => { - guard spec.inside_toplevel is Some(inside_toplevel) else { - fail("expected inside-toplevel") - } - assert_true(spec.inside_expr is None) + assert_eq(spec.inside_toplevel.length(), 1) + let inside_toplevel = spec.inside_toplevel[0] + assert_true(spec.inside_expr.is_empty()) inspect( inside_toplevel.shape, content="fn $(name:id)($(param:id) : Int) -> Int { __TARGET__ }\n", ) inspect(inside_toplevel.guards["$name"], content="^safe_") + assert_true(inside_toplevel.match_mode is Default) + } + Taint(_) => fail("expected structural rule") + } +} + +///| +test "model parses explicit inside toplevel match modes" { + let source = + #|id: example + #|description: Explicit matching modes. + #|inside-toplevel: + #| - shape: fn exact_fn { __TARGET__ } + #| match-mode: exact + #| - shape: fn partial_fn { __TARGET__ } + #| match-mode: partial + #|patterns: + #| - shape: call() + #| + let rule = RawRuleSpec::from_str( + "/tmp/rules/example.yaml", "/tmp/rules", source, + ) + match rule.definition { + Structural(spec) => { + assert_eq(spec.inside_toplevel.length(), 2) + assert_true(spec.inside_toplevel[0].match_mode is Exact) + assert_true(spec.inside_toplevel[1].match_mode is Partial) } Taint(_) => fail("expected structural rule") } } +///| +test "model rejects invalid inside toplevel match modes" { + let invalid_value = + #|id: example + #|description: Invalid matching mode. + #|inside-toplevel: + #| - shape: fn sample { __TARGET__ } + #| match-mode: prefix + #|patterns: + #| - shape: call() + #| + expect_invalid_rule_info( + invalid_value, "inside-toplevel[0].match-mode must be exact or partial", + ) + let invalid_type = + #|id: example + #|description: Invalid matching mode type. + #|inside-toplevel: + #| - shape: fn sample { __TARGET__ } + #| match-mode: true + #|patterns: + #| - shape: call() + #| + expect_invalid_rule_info( + invalid_type, "inside-toplevel[0].match-mode must be a string", + ) +} + +///| +test "model rejects match mode outside inside toplevel" { + let patterns = + #|id: example + #|description: Invalid pattern matching mode. + #|patterns: + #| - shape: call() + #| match-mode: exact + #| + expect_invalid_rule_info( + patterns, "patterns[0] contains unsupported key match-mode", + ) + let inside_expr = + #|id: example + #|description: Invalid expression matching mode. + #|inside-expr: + #| - shape: wrapper(__TARGET__) + #| match-mode: partial + #|patterns: + #| - shape: call() + #| + expect_invalid_rule_info( + inside_expr, "inside-expr[0] contains unsupported key match-mode", + ) + let taint = + #|id: example + #|description: Invalid taint matching mode. + #|taint: + #| sources: + #| - shape: source() + #| match-mode: exact + #| sinks: + #| - shape: sink(__SOURCE__) + #| + expect_invalid_rule_info( + taint, "taint.sources[0] contains unsupported key match-mode", + ) +} + ///| test "model derives reject-uncovered-negative for inside toplevel positives and negatives" { let source = #|id: example #|description: Example. #|inside-toplevel: - #| shape: | - #| fn $(name:id) { __TARGET__ } + #| - shape: | + #| fn $(name:id) { __TARGET__ } #|patterns: #| - shape: arr[$(name:id)] #|patterns-not: @@ -371,7 +464,7 @@ test "model allows inside expr with only patterns-not" { #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) + #| - shape: wrapper($(name:id), __TARGET__) #|patterns-not: #| - shape: block($(name:id)) #| @@ -380,9 +473,8 @@ test "model allows inside expr with only patterns-not" { ) match rule.definition { Structural(spec) => { - guard spec.inside_expr is Some(inside_expr) else { - fail("expected inside-expr") - } + assert_eq(spec.inside_expr.length(), 1) + let inside_expr = spec.inside_expr[0] inspect(inside_expr.shape, content="wrapper($(name:id), __TARGET__)") assert_eq(inside_expr.guards.length(), 0) assert_eq(spec.patterns.length(), 0) @@ -399,8 +491,8 @@ test "model allows inside toplevel with only patterns-not" { #|id: example #|description: Example. #|inside-toplevel: - #| shape: | - #| fn $(name:id) { __TARGET__ } + #| - shape: | + #| fn $(name:id) { __TARGET__ } #|patterns-not: #| - shape: block($(name:id)) #| @@ -409,9 +501,8 @@ test "model allows inside toplevel with only patterns-not" { ) match rule.definition { Structural(spec) => { - guard spec.inside_toplevel is Some(inside_toplevel) else { - fail("expected inside-toplevel") - } + assert_eq(spec.inside_toplevel.length(), 1) + let inside_toplevel = spec.inside_toplevel[0] inspect(inside_toplevel.shape, content="fn $(name:id) { __TARGET__ }\n") assert_eq(inside_toplevel.guards.length(), 0) assert_eq(spec.patterns.length(), 0) @@ -428,10 +519,10 @@ test "model parses inside expr shape and guard map" { #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(name:id), $(lit:const), __TARGET__) - #| guard: - #| $name: "^safe_" - #| $lit: "^ok$" + #| - shape: wrapper($(name:id), $(lit:const), __TARGET__) + #| guard: + #| $name: "^safe_" + #| $lit: "^ok$" #|patterns: #| - shape: call($(name:id)) #| @@ -440,9 +531,8 @@ test "model parses inside expr shape and guard map" { ) match rule.definition { Structural(spec) => { - guard spec.inside_expr is Some(inside_expr) else { - fail("expected inside-expr") - } + assert_eq(spec.inside_expr.length(), 1) + let inside_expr = spec.inside_expr[0] inspect( inside_expr.shape, content="wrapper($(name:id), $(lit:const), __TARGET__)", @@ -463,7 +553,7 @@ test "model rejects scalar inside toplevel" { #|patterns: #| - shape: call() #| - expect_invalid_rule_info(source, "inside-toplevel must be a mapping") + expect_invalid_rule_info(source, "inside-toplevel must be an array") } ///| @@ -472,48 +562,50 @@ test "model rejects malformed inside toplevel object schema" { #|id: example #|description: Example. #|inside-toplevel: - #| guard: - #| $name: "^safe_" + #| - guard: + #| $name: "^safe_" #|patterns: #| - shape: call() #| - expect_invalid_rule_info(missing_shape, "missing required key shape") + expect_invalid_rule_info( + missing_shape, "missing required key inside-toplevel[0].shape", + ) let unsupported_key = #|id: example #|description: Example. #|inside-toplevel: - #| shape: fn sample { __TARGET__ } - #| extra: nope + #| - shape: fn sample { __TARGET__ } + #| extra: nope #|patterns: #| - shape: call() #| expect_invalid_rule_info( - unsupported_key, "inside-toplevel contains unsupported key extra", + unsupported_key, "inside-toplevel[0] contains unsupported key extra", ) let non_mapping_guard = #|id: example #|description: Example. #|inside-toplevel: - #| shape: fn sample { __TARGET__ } - #| guard: nope + #| - shape: fn sample { __TARGET__ } + #| guard: nope #|patterns: #| - shape: call() #| expect_invalid_rule_info( - non_mapping_guard, "inside-toplevel.guard must be a mapping", + non_mapping_guard, "inside-toplevel[0].guard must be a mapping", ) let non_string_guard_value = #|id: example #|description: Example. #|inside-toplevel: - #| shape: fn sample { __TARGET__ } - #| guard: - #| $name: 1 + #| - shape: fn sample { __TARGET__ } + #| guard: + #| $name: 1 #|patterns: #| - shape: call() #| expect_invalid_rule_info( - non_string_guard_value, "inside-toplevel.guard.$name must be a string", + non_string_guard_value, "inside-toplevel[0].guard.$name must be a string", ) } @@ -523,7 +615,7 @@ test "model rejects invalid inside toplevel combinations" { #|id: example #|description: Example. #|inside-toplevel: - #| shape: fn sample { __TARGET__ } + #| - shape: fn sample { __TARGET__ } #|taint: #| sources: #| - shape: source() @@ -537,9 +629,9 @@ test "model rejects invalid inside toplevel combinations" { #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|inside-toplevel: - #| shape: fn sample { __TARGET__ } + #| - shape: fn sample { __TARGET__ } #|patterns: #| - shape: call() #| @@ -550,7 +642,7 @@ test "model rejects invalid inside toplevel combinations" { #|id: example #|description: Example. #|inside-toplevel: - #| shape: fn sample { __TARGET__ } + #| - shape: fn sample { __TARGET__ } #| expect_invalid_rule_info( inside_only, "inside-toplevel requires patterns or patterns-not", @@ -566,7 +658,7 @@ test "model rejects scalar inside expr" { #|patterns: #| - shape: call($(name:id)) #| - expect_invalid_rule_info(source, "inside-expr must be a mapping") + expect_invalid_rule_info(source, "inside-expr must be an array") } ///| @@ -575,70 +667,74 @@ test "model rejects malformed inside expr object schema" { #|id: example #|description: Example. #|inside-expr: - #| guard: - #| $name: "^safe_" + #| - guard: + #| $name: "^safe_" #|patterns: #| - shape: call($(name:id)) #| - expect_invalid_rule_info(missing_shape, "missing required key shape") + expect_invalid_rule_info( + missing_shape, "missing required key inside-expr[0].shape", + ) let non_string_shape = #|id: example #|description: Example. #|inside-expr: - #| shape: 1 + #| - shape: 1 #|patterns: #| - shape: call($(name:id)) #| - expect_invalid_rule_info(non_string_shape, "shape must be a string") + expect_invalid_rule_info( + non_string_shape, "inside-expr[0].shape must be a string", + ) let unsupported_key = #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) - #| extra: nope + #| - shape: wrapper($(name:id), __TARGET__) + #| extra: nope #|patterns: #| - shape: call($(name:id)) #| expect_invalid_rule_info( - unsupported_key, "inside-expr contains unsupported key extra", + unsupported_key, "inside-expr[0] contains unsupported key extra", ) let non_mapping_guard = #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) - #| guard: nope + #| - shape: wrapper($(name:id), __TARGET__) + #| guard: nope #|patterns: #| - shape: call($(name:id)) #| expect_invalid_rule_info( - non_mapping_guard, "inside-expr.guard must be a mapping", + non_mapping_guard, "inside-expr[0].guard must be a mapping", ) let non_string_guard_value = #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) - #| guard: - #| $name: 1 + #| - shape: wrapper($(name:id), __TARGET__) + #| guard: + #| $name: 1 #|patterns: #| - shape: call($(name:id)) #| expect_invalid_rule_info( - non_string_guard_value, "inside-expr.guard.$name must be a string", + non_string_guard_value, "inside-expr[0].guard.$name must be a string", ) let bare_guard_key = #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper($(name:id), __TARGET__) - #| guard: - #| name: "^safe_" + #| - shape: wrapper($(name:id), __TARGET__) + #| guard: + #| name: "^safe_" #|patterns: #| - shape: call($(name:id)) #| expect_invalid_rule_info( - bare_guard_key, "guard key name must be a $-prefixed metavar name", + bare_guard_key, "inside-expr[0].guard.name must be a $-prefixed metavar name", ) } @@ -702,7 +798,7 @@ test "model rejects invalid patterns-not combinations" { #|id: example #|description: Example. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #| expect_invalid_rule_info( inside_only, "inside-expr requires patterns or patterns-not", @@ -739,7 +835,7 @@ test "model rejects malformed structural guard schema" { #| name: "^safe_" #| expect_invalid_rule_info( - bare_guard_key, "guard key name must be a $-prefixed metavar name", + bare_guard_key, "patterns[0].guard.name must be a $-prefixed metavar name", ) } @@ -848,3 +944,146 @@ test "model rejects malformed taint schema" { taint_guard, "taint.sources[0].guard is not supported for taint rules", ) } + +///| +test "model parses ordered inside expr alternatives" { + let source = + #|id: example + #|description: Multiple expression contexts. + #|inside-expr: + #| - shape: wrapper(__TARGET__) + #| - shape: container($(name:id), __TARGET__) + #| guard: + #| $name: "^safe_" + #|patterns: + #| - shape: target() + #| + let rule = RawRuleSpec::from_str( + "/tmp/rules/example.yaml", "/tmp/rules", source, + ) + match rule.definition { + Structural(spec) => { + assert_eq(spec.inside_expr.length(), 2) + inspect(spec.inside_expr[0].shape, content="wrapper(__TARGET__)") + inspect( + spec.inside_expr[1].shape, + content="container($(name:id), __TARGET__)", + ) + inspect(spec.inside_expr[1].guards["$name"], content="^safe_") + assert_true(spec.inside_toplevel.is_empty()) + } + Taint(_) => fail("expected structural rule") + } +} + +///| +test "model parses ordered inside toplevel alternatives" { + let source = + #|id: example + #|description: Multiple top-level contexts. + #|inside-toplevel: + #| - shape: fn $(name:id) { __TARGET__ } + #| - shape: let $(name:id) = wrapper(__TARGET__) + #|patterns: + #| - shape: target($(name:id)) + #| + let rule = RawRuleSpec::from_str( + "/tmp/rules/example.yaml", "/tmp/rules", source, + ) + match rule.definition { + Structural(spec) => { + assert_eq(spec.inside_toplevel.length(), 2) + inspect( + spec.inside_toplevel[0].shape, + content="fn $(name:id) { __TARGET__ }", + ) + inspect( + spec.inside_toplevel[1].shape, + content="let $(name:id) = wrapper(__TARGET__)", + ) + assert_true(spec.inside_expr.is_empty()) + } + Taint(_) => fail("expected structural rule") + } +} + +///| +test "model rejects old and empty inside context forms" { + let old_expr_mapping = + #|id: example + #|description: Example. + #|inside-expr: + #| shape: wrapper(__TARGET__) + #|patterns: + #| - shape: target() + #| + expect_invalid_rule_info(old_expr_mapping, "inside-expr must be an array") + let old_toplevel_mapping = + #|id: example + #|description: Example. + #|inside-toplevel: + #| shape: fn sample { __TARGET__ } + #|patterns: + #| - shape: target() + #| + expect_invalid_rule_info( + old_toplevel_mapping, "inside-toplevel must be an array", + ) + let empty_expr = + #|id: example + #|description: Example. + #|inside-expr: [] + #|patterns: + #| - shape: target() + #| + expect_invalid_rule_info(empty_expr, "inside-expr must not be empty") + let empty_toplevel = + #|id: example + #|description: Example. + #|inside-toplevel: [] + #|patterns: + #| - shape: target() + #| + expect_invalid_rule_info(empty_toplevel, "inside-toplevel must not be empty") +} + +///| +test "model indexes malformed inside context alternatives" { + let non_mapping = + #|id: example + #|description: Example. + #|inside-expr: + #| - shape: wrapper(__TARGET__) + #| - container(__TARGET__) + #|patterns: + #| - shape: target() + #| + expect_invalid_rule_info(non_mapping, "inside-expr[1] must be a mapping") + let unsupported_key = + #|id: example + #|description: Example. + #|inside-toplevel: + #| - shape: fn sample { __TARGET__ } + #| - shape: let value = __TARGET__ + #| extra: nope + #|patterns: + #| - shape: target() + #| + expect_invalid_rule_info( + unsupported_key, "inside-toplevel[1] contains unsupported key extra", + ) + let invalid_guard = + #|id: example + #|description: Example. + #|inside-expr: + #| - shape: wrapper(__TARGET__) + #| - shape: container($(name:id), __TARGET__) + #| guard: + #| name: "^safe_" + #|patterns: + #| - shape: target() + #| + expect_invalid_rule_info( + invalid_guard, "inside-expr[1].guard.name must be a $-prefixed metavar name", + ) +} diff --git a/rule/prefilter/prefilter.mbt b/rule/prefilter/prefilter.mbt index ef11c18..517c96f 100644 --- a/rule/prefilter/prefilter.mbt +++ b/rule/prefilter/prefilter.mbt @@ -107,25 +107,35 @@ pub fn compile_rule_prefilter( ///| fn compile_structural_prefilter(rule : CompiledStructuralRule) -> RulePrefilter { let alternatives : Array[Array[String]] = [] - let inside_literals : Array[String] = if rule.inside_toplevel is Some(pattern) { - required_literals_from_compiled_pattern(pattern) - } else if rule.inside_expr is Some(pattern) { - required_literals_from_compiled_pattern(pattern) + let inside_patterns = if !rule.inside_toplevel.is_empty() { + rule.inside_toplevel } else { - [] + rule.inside_expr } - if rule.patterns.is_empty() { - alternatives.push(unique_literals(inside_literals)) - } - for pattern in rule.patterns { - alternatives.push( - unique_literals( - concat_string_arrays( - inside_literals, - required_literals_from_compiled_pattern(pattern), - ), - ), - ) + if inside_patterns.is_empty() { + for pattern in rule.patterns { + alternatives.push( + unique_literals(required_literals_from_compiled_pattern(pattern)), + ) + } + } else { + for inside in inside_patterns { + let inside_literals = required_literals_from_compiled_pattern(inside) + if rule.patterns.is_empty() { + alternatives.push(unique_literals(inside_literals)) + } else { + for pattern in rule.patterns { + alternatives.push( + unique_literals( + concat_string_arrays( + inside_literals, + required_literals_from_compiled_pattern(pattern), + ), + ), + ) + } + } + } } { alternatives, } } @@ -181,14 +191,14 @@ fn collect_node_literals( push_literal(literals, name) } } - Type_Name => + Type_Name => { if prefilter_type_placeholder_name(node) is Some(name) { if !is_filter_placeholder(name, compiled) { - collect_node_children_literals(node, compiled, literals) + push_literal(literals, name) } - } else { - collect_node_children_literals(node, compiled, literals) } + collect_node_children_literals(node, compiled, literals) + } Constant_BigInt | Constant_Byte | Constant_Bytes @@ -233,28 +243,43 @@ fn collect_node_children_literals( literals : Array[String], ) -> Unit { for entry in node.children { - let (_, child) = entry - collect_node_literals(child, compiled, literals) + let (child_name, child) = entry + let ignored = match child_name { + Some(name) => prefilter_ignores_field(compiled, node.kind, name) + None => false + } + if !ignored { + collect_node_literals(child, compiled, literals) + } } } +///| +fn prefilter_ignores_field( + compiled : @matching.CompiledExprPattern, + parent_kind : @untyped_ast.NodeKind, + child_name : String, +) -> Bool { + for field in compiled.ignored_fields { + if field.parent_kind == parent_kind && field.child_name == child_name { + return true + } + } + false +} + ///| fn prefilter_type_placeholder_name(node : @untyped_ast.Node) -> String? { if node.kind != Type_Name { return None } - guard prefilter_child(node, "tys") is Some(tys) else { return None } - if !tys.children.is_empty() { - return None - } guard prefilter_child(node, "constr_id") is Some(constr_id) else { return None } guard prefilter_child(constr_id, "id") is Some(long_ident) else { return None } - guard long_ident.kind == LongIdent_Ident else { return None } - prefilter_leaf_string_child(long_ident, "value") + long_ident.normalized_long_ident() } ///| diff --git a/rule/prefilter/prefilter_wbtest.mbt b/rule/prefilter/prefilter_wbtest.mbt index 04eaa3c..9f4f924 100644 --- a/rule/prefilter/prefilter_wbtest.mbt +++ b/rule/prefilter/prefilter_wbtest.mbt @@ -219,7 +219,7 @@ test "prefilter requires inside expr and inner pattern literals together" { #|id: example #|description: Wrapped target call. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns: #| - shape: target() #| @@ -239,7 +239,7 @@ test "prefilter requires inside toplevel and inner pattern literals together" { #|id: example #|description: Top-level target call. #|inside-toplevel: - #| shape: fn safe_entry { __TARGET__ } + #| - shape: fn safe_entry { __TARGET__ } #|patterns: #| - shape: target() #| @@ -249,6 +249,102 @@ test "prefilter requires inside toplevel and inner pattern literals together" { assert_false(rule_is_relevant_to_source(rule, "fn other { target() }\n")) } +///| +test "prefilter skips omitted partial function fields" { + let source = + #|id: example + #|description: Partial top-level function. + #|inside-toplevel: + #| - shape: fn safe_entry { __TARGET__ } + #|patterns: + #| - shape: target() + #| + let rule = compile_prefilter_rule_source(source) + assert_true( + rule_is_relevant_to_source( + rule, + ( + #|/// Documented entry. + #|#custom.attribute + #|pub async fn safe_entry(value : InputType) -> ResultType noraise { + #| target() + #|} + #| + ), + ), + ) + assert_false( + rule_is_relevant_to_source( + rule, "pub async fn other(value : InputType) -> ResultType { target() }\n", + ), + ) +} + +///| +test "prefilter skips compiled ignored fields by parent and child name" { + let source = + #|id: example + #|description: Partial top-level function. + #|inside-toplevel: + #| - shape: fn safe_entry { __TARGET__ } + #|patterns: + #| - shape: target() + #| + let rule = compile_prefilter_rule_source(source) + guard rule.definition is Structural(structural) else { + fail("expected structural rule") + } + let pattern = structural.inside_toplevel[0] + let compiled = pattern.compiled + guard prefilter_child(compiled.ast, "fun_decl") is Some(fun_decl) else { + fail("expected function declaration") + } + for index in 0.. RequiredType noraise { __TARGET__ } + #|patterns: + #| - shape: target() + #| + let rule = compile_prefilter_rule_source(source) + assert_true( + rule_is_relevant_to_source( + rule, "async fn safe_entry() -> RequiredType noraise { target() }\n", + ), + ) + assert_false( + rule_is_relevant_to_source( + rule, "async fn safe_entry() -> OtherType noraise { target() }\n", + ), + ) +} + ///| test "prefilter ignores patterns-not literals for structural rules" { let source = @@ -270,7 +366,7 @@ test "prefilter uses inside expr literals for patterns-not only rules" { #|id: example #|description: Wrapped target unless ignored. #|inside-expr: - #| shape: wrapper(__TARGET__) + #| - shape: wrapper(__TARGET__) #|patterns-not: #| - shape: impossible_negative_literal() #| @@ -313,3 +409,65 @@ test "prefilter literal matching treats regex metacharacters literally" { assert_true(literal_matches_source("target.name", "let _ = target.name\n")) assert_false(literal_matches_source("target.name", "let _ = targetxname\n")) } + +///| +test "prefilter builds outer and positive pattern cartesian alternatives" { + let source = + #|id: example + #|description: Multiple outer and inner alternatives. + #|inside-expr: + #| - shape: wrapper(__TARGET__) + #| - shape: container(__TARGET__) + #|patterns: + #| - shape: first_target() + #| - shape: second_target() + #| + let rule = compile_prefilter_rule_source(source) + assert_eq(rule.prefilter.alternatives.length(), 4) + assert_true( + rule_is_relevant_to_source(rule, "fn sample { wrapper(first_target()) }\n"), + ) + assert_true( + rule_is_relevant_to_source(rule, "fn sample { wrapper(second_target()) }\n"), + ) + assert_true( + rule_is_relevant_to_source( + rule, "fn sample { container(first_target()) }\n", + ), + ) + assert_true( + rule_is_relevant_to_source( + rule, "fn sample { container(second_target()) }\n", + ), + ) + assert_false( + rule_is_relevant_to_source(rule, "fn sample { wrapper(noop()) }\n"), + ) + assert_false( + rule_is_relevant_to_source(rule, "fn sample { other(first_target()) }\n"), + ) +} + +///| +test "prefilter gives each patterns-not-only outer its own alternative" { + let source = + #|id: example + #|description: Multiple clean contexts. + #|inside-expr: + #| - shape: wrapper(__TARGET__) + #| - shape: container(__TARGET__) + #|patterns-not: + #| - shape: impossible_negative_literal() + #| + let rule = compile_prefilter_rule_source(source) + assert_eq(rule.prefilter.alternatives.length(), 2) + assert_true(rule_is_relevant_to_source(rule, "fn sample { wrapper(ok()) }\n")) + assert_true( + rule_is_relevant_to_source(rule, "fn sample { container(ok()) }\n"), + ) + assert_false( + rule_is_relevant_to_source( + rule, "fn sample { impossible_negative_literal() }\n", + ), + ) +} diff --git a/testdata/builtin-catch-all-variants/sample.mbt b/testdata/builtin-catch-all-variants/sample.mbt new file mode 100644 index 0000000..18c62cf --- /dev/null +++ b/testdata/builtin-catch-all-variants/sample.mbt @@ -0,0 +1,22 @@ +///| +async fn no_parameters { + try risky() catch { _ => () } +} +///| +async fn varied_parameters(_, value : Int, fallback? : Int = 0) -> Int { + try risky() catch { error => value } +} +///| +pub async fn public_noraise(value : Int) noraise { + try risky() catch { error => () } +} +///| +/// Qualified generic function. +#custom.attribute +priv async fn[T] Box::qualified(value : T) -> T { + try risky() catch { error => value } +} +///| +fn synchronous_function(value : Int) -> Int { + try risky() catch { _ => value } +} diff --git a/testdata/builtin-rules-all/unnessary_else.mbt b/testdata/builtin-rules-all/unnessary_else.mbt new file mode 100644 index 0000000..d7bb6f8 --- /dev/null +++ b/testdata/builtin-rules-all/unnessary_else.mbt @@ -0,0 +1,11 @@ +///| +fn unnecessary_empty_else(flag : Bool) -> Unit { + if flag { + prepare() + finish() + } else {} +} +///| +fn non_unit_else(flag : Bool) -> Unit { + ignore(if flag { first() } else { second() }) +} diff --git a/testdata/ellipsis/inside/rules/example.yaml b/testdata/ellipsis/inside/rules/example.yaml index 5f7c1ec..b1290ae 100644 --- a/testdata/ellipsis/inside/rules/example.yaml +++ b/testdata/ellipsis/inside/rules/example.yaml @@ -2,6 +2,6 @@ id: ellipsis-inside description: | Reuse the surrounding argument prefix inside the target. inside-expr: - shape: wrapper($$$args, __TARGET__) + - shape: wrapper($$$args, __TARGET__) patterns: - shape: target($$$args) diff --git a/testdata/ellipsis/invalid/inside-kind.yaml b/testdata/ellipsis/invalid/inside-kind.yaml index 60bb8d8..6c28bc8 100644 --- a/testdata/ellipsis/invalid/inside-kind.yaml +++ b/testdata/ellipsis/invalid/inside-kind.yaml @@ -1,6 +1,6 @@ id: invalid-inside-kind description: Invalid inherited ellipsis kind. inside-expr: - shape: wrapper($$$(items:exp), __TARGET__) + - shape: wrapper($$$(items:exp), __TARGET__) patterns: - shape: target($$$(items:id)) diff --git a/testdata/ellipsis/invalid/inside-single.yaml b/testdata/ellipsis/invalid/inside-single.yaml index 1a4f03e..68033ca 100644 --- a/testdata/ellipsis/invalid/inside-single.yaml +++ b/testdata/ellipsis/invalid/inside-single.yaml @@ -1,6 +1,6 @@ id: invalid-inside-single description: Invalid inherited ellipsis form. inside-expr: - shape: wrapper($$$items, __TARGET__) + - shape: wrapper($$$items, __TARGET__) patterns: - shape: target($(items:exp)) diff --git a/testdata/inside-alternatives/rules/expr.yaml b/testdata/inside-alternatives/rules/expr.yaml new file mode 100644 index 0000000..7864c3b --- /dev/null +++ b/testdata/inside-alternatives/rules/expr.yaml @@ -0,0 +1,8 @@ +id: expr +description: | + Multiple expression contexts share one inner pattern. +inside-expr: + - shape: wrapper($(name:id), __TARGET__) + - shape: container($(name:id), __TARGET__) +patterns: + - shape: target($(name:id)) diff --git a/testdata/inside-alternatives/rules/toplevel.yaml b/testdata/inside-alternatives/rules/toplevel.yaml new file mode 100644 index 0000000..f29f59b --- /dev/null +++ b/testdata/inside-alternatives/rules/toplevel.yaml @@ -0,0 +1,8 @@ +id: toplevel +description: | + Multiple top-level contexts share one inner pattern. +inside-toplevel: + - shape: fn $(name:id) { __TARGET__ } + - shape: let $(name:id) = box(__TARGET__) +patterns: + - shape: consume($(name:id)) diff --git a/testdata/inside-alternatives/src/expr.mbt b/testdata/inside-alternatives/src/expr.mbt new file mode 100644 index 0000000..f2a76d8 --- /dev/null +++ b/testdata/inside-alternatives/src/expr.mbt @@ -0,0 +1,4 @@ +fn sample { + wrapper(alpha, target(alpha)); + container(beta, target(beta)) +} diff --git a/testdata/inside-alternatives/src/toplevel.mbt b/testdata/inside-alternatives/src/toplevel.mbt new file mode 100644 index 0000000..961aee4 --- /dev/null +++ b/testdata/inside-alternatives/src/toplevel.mbt @@ -0,0 +1,4 @@ +fn run { + consume(run) +} +let value = box(consume(value)) diff --git a/testdata/inside-expr-target/rules/example.yaml b/testdata/inside-expr-target/rules/example.yaml index 5da9407..b35d64f 100644 --- a/testdata/inside-expr-target/rules/example.yaml +++ b/testdata/inside-expr-target/rules/example.yaml @@ -2,9 +2,9 @@ id: example description: | Local println shadows the builtin. inside-expr: - shape: | - let println = $_ - __TARGET__ + - shape: | + let println = $_ + __TARGET__ patterns: - shape: | println($_) diff --git a/testdata/patterns-not-inside/rules/example.yaml b/testdata/patterns-not-inside/rules/example.yaml index 6e99638..e676991 100644 --- a/testdata/patterns-not-inside/rules/example.yaml +++ b/testdata/patterns-not-inside/rules/example.yaml @@ -2,7 +2,7 @@ id: example description: | Wrapper payload without danger. inside-expr: - shape: wrapper(__TARGET__) + - shape: wrapper(__TARGET__) patterns-not: - shape: danger()