Route PHP through the tree-sitter structural matcher

ober

496ac1874fbb974617c305d87a46f9bca9af3a52

diff --git a/lib/semgrep/match/structural.sls b/lib/semgrep/match/structural.sls
index 75490a4..a1d4a28 100644
--- a/lib/semgrep/match/structural.sls
+++ b/lib/semgrep/match/structural.sls
@@ -46,6 +46,20 @@
            (string=? language "ts")
            (string=? language "tsx")))
   (def (go-language? language) (string=? language "go"))
+  (def (php-language? language) (string=? language "php"))
+  (def (metavariable-name-char-uppercase? name)
+       (and (> (string-length name) 0)
+            (let ([c (string-ref name 0)])
+              (or (char=? c #\_) (char-upper-case? c)))))
+  (def (php-metavariable-name-from-node n)
+       (and (string=? (node-type n) "variable_name")
+            (let ([text (node-text n)])
+              (and (> (string-length text) 1)
+                   (char=? (string-ref text 0) #\$)
+                   (let ([name (substring text 1 (string-length text))])
+                     (and (valid-metavariable-name? name)
+                          (metavariable-name-char-uppercase? name)
+                          name))))))
   (def (go-skip-spaces source i len)
        (if (and (< i len) (char-whitespace? (string-ref source i)))
            (go-skip-spaces source (+ i 1) len)
@@ -197,7 +211,8 @@
                       #t))))]
            [(and (char=? (string-ref source i) #\$)
                  (< (+ i 1) len)
-                 (identifier-start? (string-ref source (+ i 1))))
+                 (identifier-start? (string-ref source (+ i 1)))
+                 (not (php-language? language)))
             (let name-loop ([j (+ i 2)])
               (if (and (< j len) (identifier-rest? (string-ref source j)))
                   (name-loop (+ j 1))
@@ -1146,6 +1161,11 @@
        (let ([text (node-text pattern)])
          (cond
            [(ellipsis-node? pattern) (list bindings)]
+           [(and (php-language? language)
+                 (php-metavariable-name-from-node pattern)) =>
+            (lambda (name)
+              (let ([next (bind-metavariable name target bindings)])
+                (if next (list next) '())))]
            [(go-deep-expression-matches
               language
               pattern
@@ -1549,6 +1569,37 @@
                            (begin
                              (tree-close! (parse-result-tree result))
                              (loop (cdr scaffolds)))))))))))
+  (define php-pattern-scaffolds
+    (list (cons "<?php " "") (cons "<?php " ";")))
+  (def (php-parse-pattern rewritten-pattern)
+       (let ([trimmed (string-trim rewritten-pattern)])
+         (let loop ([scaffolds php-pattern-scaffolds])
+           (if (null? scaffolds)
+               (values #f #f)
+               (let* ([prefix (caar scaffolds)]
+                      [suffix (cdar scaffolds)]
+                      [wrapped (string-append prefix trimmed suffix)]
+                      [result (parse-target-string "php" wrapped)]
+                      [root (parse-result-root result)])
+                 (if (or (not root) (parse-result-has-errors? result))
+                     (begin
+                       (when root (node-close! root))
+                       (tree-close! (parse-result-tree result))
+                       (loop (cdr scaffolds)))
+                     (let* ([hstart (utf8-length prefix)]
+                            [hend (+ hstart (utf8-length trimmed))]
+                            [hole (and (<= (node-start-byte root) hstart)
+                                       (>= (node-end-byte root) hend)
+                                       (go-deepest-node-containing
+                                         root
+                                         hstart
+                                         hend))])
+                       (node-close! root)
+                       (if hole
+                           (values hole result)
+                           (begin
+                             (tree-close! (parse-result-tree result))
+                             (loop (cdr scaffolds)))))))))))
   (def (run-structural-matches
          language
          normalized
@@ -1586,36 +1637,40 @@
        (let ([rewritten-pattern (rewrite-metavariables
                                   language
                                   pattern-source)])
-         (if (go-language? language)
-             (let-values ([(normalized result)
-                           (go-parse-pattern rewritten-pattern)])
-               (if (not normalized)
-                   '()
-                   (let ([matches (run-structural-matches
-                                    language
-                                    normalized
-                                    target-root
-                                    initial-bindings)])
-                     (node-close! normalized)
-                     (tree-close! (parse-result-tree result))
-                     matches)))
-             (let* ([pattern-result (parse-target-string
-                                      language
-                                      rewritten-pattern)]
-                    [pattern-root (parse-result-root pattern-result)])
-               (let-values ([(normalized owned-nodes)
-                             (normalized-pattern-root
-                               language
-                               pattern-root)])
-                 (let ([matches (run-structural-matches
-                                  language
-                                  normalized
-                                  target-root
-                                  initial-bindings)])
-                   (for-each node-close! owned-nodes)
-                   (when pattern-root (node-close! pattern-root))
-                   (tree-close! (parse-result-tree pattern-result))
-                   matches))))))
+         (cond
+           [(or (go-language? language) (php-language? language))
+            (let-values ([(normalized result)
+                          (if (go-language? language)
+                              (go-parse-pattern rewritten-pattern)
+                              (php-parse-pattern rewritten-pattern))])
+              (if (not normalized)
+                  '()
+                  (let ([matches (run-structural-matches
+                                   language
+                                   normalized
+                                   target-root
+                                   initial-bindings)])
+                    (node-close! normalized)
+                    (tree-close! (parse-result-tree result))
+                    matches)))]
+           [else
+            (let* ([pattern-result (parse-target-string
+                                     language
+                                     rewritten-pattern)]
+                   [pattern-root (parse-result-root pattern-result)])
+              (let-values ([(normalized owned-nodes)
+                            (normalized-pattern-root
+                              language
+                              pattern-root)])
+                (let ([matches (run-structural-matches
+                                 language
+                                 normalized
+                                 target-root
+                                 initial-bindings)])
+                  (for-each node-close! owned-nodes)
+                  (when pattern-root (node-close! pattern-root))
+                  (tree-close! (parse-result-tree pattern-result))
+                  matches)))])))
   (def (structural-pattern-matches
          language
          pattern-source
diff --git a/lib/semgrep/parse/parse-target.sls b/lib/semgrep/parse/parse-target.sls
index 9344834..1fe8150 100644
--- a/lib/semgrep/parse/parse-target.sls
+++ b/lib/semgrep/parse/parse-target.sls
@@ -24,6 +24,7 @@
            [(string=? canonical "javascript") 'javascript]
            [(string=? canonical "typescript") 'javascript]
            [(string=? canonical "go") 'go]
+           [(string=? canonical "php") 'php]
            [else
             (error 'parse-target-string
               "unsupported language in current MVP"
diff --git a/lib/semgrep/scan.sls b/lib/semgrep/scan.sls
index d47cd71..8aa18e9 100644
--- a/lib/semgrep/scan.sls
+++ b/lib/semgrep/scan.sls
@@ -743,7 +743,6 @@
              (string=? canonical "yaml")
              (string=? canonical "c")
              (string=? canonical "terraform")
-             (string=? canonical "php")
              (string=? canonical "java")
              (string=? canonical "csharp")
              (string=? canonical "swift")
diff --git a/src/.jerbuild-hashes b/src/.jerbuild-hashes
index 3cc67e1..e28d356 100644
--- a/src/.jerbuild-hashes
+++ b/src/.jerbuild-hashes
@@ -2,12 +2,12 @@
   ("src/semgrep/result.ss" . "22D23E40B49BA529")
   ("src/semgrep/output/json.ss" . "293881CFA2ADB7BC")
   ("src/semgrep/lang.ss" . "6982E07679D20836")
-  ("src/semgrep/parse/parse-target.ss" . "B1616180DE7038ED")
-  ("src/semgrep/scan.ss" . "1E18DBE6EC56D12C")
-  ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
+  ("src/semgrep/parse/parse-target.ss" . "984D9B3DED8F3EFE")
+  ("src/semgrep/scan.ss" . "5A2E9AFF3229A61E")
   ("src/semgrep/fix.ss" . "2E5B65B1FEF3B2B1")
-  ("src/semgrep/schema/lang.ss" . "CAE2CA859C9A9FD0")
+  ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
   ("src/semgrep/rule.ss" . "E12C108153C181FA")
-  ("src/semgrep/match/structural.ss" . "AA1BA746924C8189")
+  ("src/semgrep/schema/lang.ss" . "CAE2CA859C9A9FD0")
+  ("src/semgrep/match/structural.ss" . "8144F1297C91CF9F")
   ("src/semgrep/main.ss" . "A4EC9E7F2A09D25E")
   ("src/semgrep/cli.ss" . "EBDC4B1DAD3F13CC"))
diff --git a/src/semgrep/match/structural.ss b/src/semgrep/match/structural.ss
index dae8b86..b0dcf15 100644
--- a/src/semgrep/match/structural.ss
+++ b/src/semgrep/match/structural.ss
@@ -57,6 +57,28 @@
 (def (go-language? language)
   (string=? language "go"))
 
+(def (php-language? language)
+  (string=? language "php"))
+
+;; A Semgrep metavariable name: first char uppercase or underscore. This is how
+;; PHP metavars are distinguished from ordinary `$lowercase` variables.
+(def (metavariable-name-char-uppercase? name)
+  (and (> (string-length name) 0)
+       (let ([c (string-ref name 0)])
+         (or (char=? c #\_) (char-upper-case? c)))))
+
+;; PHP metavariable: a `variable_name` node `$NAME` whose NAME is uppercase-first
+;; (and a valid metavariable name). Returns the name, or #f.
+(def (php-metavariable-name-from-node n)
+  (and (string=? (node-type n) "variable_name")
+       (let ([text (node-text n)])
+         (and (> (string-length text) 1)
+              (char=? (string-ref text 0) #\$)
+              (let ([name (substring text 1 (string-length text))])
+                (and (valid-metavariable-name? name)
+                     (metavariable-name-char-uppercase? name)
+                     name))))))
+
 (def (go-skip-spaces source i len)
   (if (and (< i len) (char-whitespace? (string-ref source i)))
       (go-skip-spaces source (+ i 1) len)
@@ -212,9 +234,13 @@
              (let* ([name (string-slice source (+ i 4) j)]
                     [token (ellipsis-metavariable-token language name)])
                (values j state #f (add-string-reversed token acc) #t))))]
+      ;; In PHP `$x` is the variable sigil, so do NOT text-rewrite `$IDENT`;
+      ;; leave it as a variable_name and recognize metavars at the AST level
+      ;; (a variable_name whose name is uppercase-first).
       [(and (char=? (string-ref source i) #\$)
             (< (+ i 1) len)
-            (identifier-start? (string-ref source (+ i 1))))
+            (identifier-start? (string-ref source (+ i 1)))
+            (not (php-language? language)))
        (let name-loop ([j (+ i 2)])
          (if (and (< j len)
                   (identifier-rest? (string-ref source j)))
@@ -1150,6 +1176,12 @@
   (let ([text (node-text pattern)])
     (cond
       [(ellipsis-node? pattern) (list bindings)]
+      ;; PHP metavariable `$NAME` (uppercase) -> bind to the target node.
+      [(and (php-language? language)
+            (php-metavariable-name-from-node pattern))
+       => (lambda (name)
+            (let ([next (bind-metavariable name target bindings)])
+              (if next (list next) '())))]
       [(go-deep-expression-matches language pattern target bindings)
        => (lambda (matches) matches)]
       [(go-import-declaration-matches language pattern target bindings)
@@ -1529,6 +1561,39 @@
                         (tree-close! (parse-result-tree result))
                         (loop (cdr scaffolds)))))))))))
 
+;; PHP patterns are bare fragments (no `<?php`), which parse as raw text, so we
+;; wrap them in `<?php ...` and extract the spanned node by byte range.
+(define php-pattern-scaffolds
+  (list (cons "<?php " "")      ; statements / declarations
+        (cons "<?php " ";")))   ; expressions (need a terminating `;`)
+
+(def (php-parse-pattern rewritten-pattern)
+  (let ([trimmed (string-trim rewritten-pattern)])
+    (let loop ([scaffolds php-pattern-scaffolds])
+      (if (null? scaffolds)
+          (values #f #f)
+          (let* ([prefix (caar scaffolds)]
+                 [suffix (cdar scaffolds)]
+                 [wrapped (string-append prefix trimmed suffix)]
+                 [result (parse-target-string "php" wrapped)]
+                 [root (parse-result-root result)])
+            (if (or (not root) (parse-result-has-errors? result))
+                (begin
+                  (when root (node-close! root))
+                  (tree-close! (parse-result-tree result))
+                  (loop (cdr scaffolds)))
+                (let* ([hstart (utf8-length prefix)]
+                       [hend (+ hstart (utf8-length trimmed))]
+                       [hole (and (<= (node-start-byte root) hstart)
+                                  (>= (node-end-byte root) hend)
+                                  (go-deepest-node-containing root hstart hend))])
+                  (node-close! root)
+                  (if hole
+                      (values hole result)
+                      (begin
+                        (tree-close! (parse-result-tree result))
+                        (loop (cdr scaffolds)))))))))))
+
 (def (run-structural-matches language normalized target-root initial-bindings)
   (let* ([chain-ellipsis? (chain-node-has-ellipsis? normalized)]
          [raw-matches
@@ -1561,15 +1626,20 @@
        target-root
        initial-bindings)
   (let ([rewritten-pattern (rewrite-metavariables language pattern-source)])
-    (if (go-language? language)
-        (let-values ([(normalized result) (go-parse-pattern rewritten-pattern)])
+    (cond
+      [(or (go-language? language) (php-language? language))
+        (let-values ([(normalized result)
+                      (if (go-language? language)
+                          (go-parse-pattern rewritten-pattern)
+                          (php-parse-pattern rewritten-pattern))])
           (if (not normalized)
               '()
               (let ([matches (run-structural-matches
                                language normalized target-root initial-bindings)])
                 (node-close! normalized)
                 (tree-close! (parse-result-tree result))
-                matches)))
+                matches)))]
+      [else
         (let* ([pattern-result (parse-target-string language rewritten-pattern)]
                [pattern-root (parse-result-root pattern-result)])
           (let-values ([(normalized owned-nodes)
@@ -1579,7 +1649,7 @@
               (for-each node-close! owned-nodes)
               (when pattern-root (node-close! pattern-root))
               (tree-close! (parse-result-tree pattern-result))
-              matches))))))
+              matches)))])))
 
 (def (structural-pattern-matches language pattern-source target-root)
   (structural-pattern-matches-with-bindings
diff --git a/src/semgrep/parse/parse-target.ss b/src/semgrep/parse/parse-target.ss
index 170c627..0a73b42 100644
--- a/src/semgrep/parse/parse-target.ss
+++ b/src/semgrep/parse/parse-target.ss
@@ -16,6 +16,7 @@
       [(string=? canonical "javascript") 'javascript]
       [(string=? canonical "typescript") 'javascript]
       [(string=? canonical "go") 'go]
+      [(string=? canonical "php") 'php]
       [else
        (error 'parse-target-string "unsupported language in current MVP" language)])))
 
diff --git a/src/semgrep/scan.ss b/src/semgrep/scan.ss
index c378811..769b5b7 100644
--- a/src/semgrep/scan.ss
+++ b/src/semgrep/scan.ss
@@ -815,7 +815,6 @@
         (string=? canonical "yaml")
         (string=? canonical "c")
         (string=? canonical "terraform")
-        (string=? canonical "php")
         (string=? canonical "java")
         (string=? canonical "csharp")
         (string=? canonical "swift")