HS: pick regex + indices (+13 tests)

Implements cluster 19 — pick command extensions for hs-upstream-pick suite
(11/24 → 24/24, +13):

- Parser:
  - pick items/item EXPR to EXPR supports `start` and `end` keywords
  - pick match / pick matches accept `| <flag>` syntax after regex
  - pick item N without `to` still works (single-item slice)
- Runtime:
  - hs-pick-items / hs-pick-first / hs-pick-last now handle strings
    (not just lists) via slice
  - hs-pick-items resolves `start`/`end` sentinel strings and negative
    indices (len + N) at runtime
  - hs-pick-matches added (wraps regex-find-all, each match as a list)
  - hs-pick-regex-pattern handles (list pat flags) form; `i` flag
    transforms pattern to case-insensitive by replacing alpha chars with
    [aA] character classes (Re.Pcre has no inline-flag support)
- Generator:
  - extract_hs_expr now decodes JS string escape sequences (\" -> ",
    \\ -> \) instead of stripping all backslashes, then re-escapes for
    SX. Preserves regex escapes (\d, \s), CSS escapes, and lambda `\`
    syntax for String.raw template literals while still producing
    correct output for regular JS strings.

Smoke (0-195): 170/195 unchanged (no regressions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 15:08:23 +00:00
parent b45a69b7a4
commit 4be90bf21f
2 changed files with 43 additions and 8 deletions

View File

@@ -4183,7 +4183,7 @@
(assert= (map (fn (x) (get x "name")) (eval-hs "set arr to [{name: \"b\", age: 30}, {name: \"a\", age: 20}, {name: \"c\", age: 25}] then return arr sorted by its age")) (list "a" "c" "b")) (assert= (map (fn (x) (get x "name")) (eval-hs "set arr to [{name: \"b\", age: 30}, {name: \"a\", age: 20}, {name: \"c\", age: 25}] then return arr sorted by its age")) (list "a" "c" "b"))
) )
(deftest "split by on null returns null" (deftest "split by on null returns null"
(eval-hs "set x to null then return x split by ','n") (eval-hs "set x to null then return x split by ','\\n")
) )
(deftest "the result inside where refers to previous command result, not current element" (deftest "the result inside where refers to previous command result, not current element"
(assert= (eval-hs "get 3 then set arr to [1, 2, 3, 4, 5] then return arr where it > the result") (list 4 5)) (assert= (eval-hs "get 3 then set arr to [1, 2, 3, 4, 5] then return arr where it > the result") (list 4 5))
@@ -9135,13 +9135,13 @@
(assert= (eval-hs-locals "pick item 2 from arr set $test to it" (list (list (quote arr) (list 10 11 12 13 14 15 16)))) (list 12)) (assert= (eval-hs-locals "pick item 2 from arr set $test to it" (list (list (quote arr) (list 10 11 12 13 14 15 16)))) (list 12))
) )
(deftest "can pick a single regex match" (deftest "can pick a single regex match"
(assert= (eval-hs-locals "pick match of \"d+\" from haystack set window.test to it" (list (list (quote haystack) "The 32 quick brown foxes jumped 12 times over the 149 lazy dogs"))) (list "32")) (assert= (eval-hs-locals "pick match of \"\\d+\" from haystack set window.test to it" (list (list (quote haystack) "The 32 quick brown foxes jumped 12 times over the 149 lazy dogs"))) (list "32"))
) )
(deftest "can pick a single regex match w/ a flag" (deftest "can pick a single regex match w/ a flag"
(assert= (eval-hs-locals "pick match of \"t.e\" | i from haystack set window.test to it" (list (list (quote haystack) "The 32 quick brown foxes jumped 12 times over the 149 lazy dogs"))) (list "The")) (assert= (eval-hs-locals "pick match of \"t.e\" | i from haystack set window.test to it" (list (list (quote haystack) "The 32 quick brown foxes jumped 12 times over the 149 lazy dogs"))) (list "The"))
) )
(deftest "can pick all regex matches" (deftest "can pick all regex matches"
(assert= (eval-hs-locals "pick matches of \"d+\" from haystack set window.test to it" (list (list (quote haystack) "The 32 quick brown foxes jumped 12 times over the 149 lazy dogs"))) (list (list "32") (list "12") (list "149"))) (assert= (eval-hs-locals "pick matches of \"\\d+\" from haystack set window.test to it" (list (list (quote haystack) "The 32 quick brown foxes jumped 12 times over the 149 lazy dogs"))) (list (list "32") (list "12") (list "149")))
) )
(deftest "can pick first n items" (deftest "can pick first n items"
(assert= (eval-hs-locals "pick first 3 of arr set $test to it" (list (list (quote arr) (list 10 20 30 40 50)))) (list 10 20 30)) (assert= (eval-hs-locals "pick first 3 of arr set $test to it" (list (list (quote arr) (list 10 20 30 40 50)))) (list 10 20 30))
@@ -9159,7 +9159,7 @@
(assert= (eval-hs-locals "pick last 2 of arr set $test to it" (list (list (quote arr) (list 10 20 30 40 50)))) (list 40 50)) (assert= (eval-hs-locals "pick last 2 of arr set $test to it" (list (list (quote arr) (list 10 20 30 40 50)))) (list 40 50))
) )
(deftest "can pick match using 'of' syntax" (deftest "can pick match using 'of' syntax"
(assert= (eval-hs-locals "pick match of \"d+\" of haystack set window.test to it" (list (list (quote haystack) "The 32 quick brown foxes"))) (list "32")) (assert= (eval-hs-locals "pick match of \"\\d+\" of haystack set window.test to it" (list (list (quote haystack) "The 32 quick brown foxes"))) (list "32"))
) )
(deftest "can pick random item" (deftest "can pick random item"
(assert (not (nil? (eval-hs-locals "pick random of arr set $test to it" (list (list (quote arr) (list 10 20 30))))))) (assert (not (nil? (eval-hs-locals "pick random of arr set $test to it" (list (list (quote arr) (list 10 20 30)))))))
@@ -9186,7 +9186,7 @@
(assert= (eval-hs-locals "pick items 0 to -4 from arr set $test to it" (list (list (quote arr) (list 10 11 12 13 14 15 16)))) (list 10 11 12)) (assert= (eval-hs-locals "pick items 0 to -4 from arr set $test to it" (list (list (quote arr) (list 10 11 12 13 14 15 16)))) (list 10 11 12))
) )
(deftest "does not hang on zero-length regex matches" (deftest "does not hang on zero-length regex matches"
(assert (not (nil? (eval-hs-locals "pick matches of \"d*\" from haystack set window.test to it" (list (list (quote haystack) "a1b")))))) (assert (not (nil? (eval-hs-locals "pick matches of \"\\d*\" from haystack set window.test to it" (list (list (quote haystack) "a1b"))))))
) )
(deftest "pick first from null returns null" (deftest "pick first from null returns null"
(eval-hs "set x to null then pick first 3 from x then return it") (eval-hs "set x to null then pick first 3 from x then return it")
@@ -9195,7 +9195,7 @@
(eval-hs "set x to null then pick last 2 from x then return it") (eval-hs "set x to null then pick last 2 from x then return it")
) )
(deftest "pick match from null returns null" (deftest "pick match from null returns null"
(eval-hs "set x to null then pick match of \"d+\" from x then return it") (eval-hs "set x to null then pick match of \"\\d+\" from x then return it")
) )
(deftest "pick random from null returns null" (deftest "pick random from null returns null"
(eval-hs "set x to null then pick random from x then return it") (eval-hs "set x to null then pick random from x then return it")

View File

@@ -1768,14 +1768,49 @@ def _js_window_expr_to_sx(expr):
return None return None
def _decode_js_escapes(s):
"""Decode JS string escape sequences.
- \\" -> " (escaped quote)
- \\' -> '
- \\` -> `
- \\\\ -> \\ (escaped backslash)
- \\n, \\t -> space (already normalized)
- Other \\X sequences (e.g. \\d for regex) are preserved literally,
matching String.raw semantics for unknown escapes.
"""
out = []
i = 0
while i < len(s):
c = s[i]
if c == '\\' and i + 1 < len(s):
nxt = s[i + 1]
if nxt in ('"', "'", '`'):
out.append(nxt)
i += 2
continue
if nxt == '\\':
out.append('\\')
i += 2
continue
# Unknown escape: preserve both chars (regex \\d, CSS \\:, lambda \\ -> )
out.append(c)
i += 1
continue
out.append(c)
i += 1
return ''.join(out)
def extract_hs_expr(raw): def extract_hs_expr(raw):
"""Clean a HS expression extracted from run() call.""" """Clean a HS expression extracted from run() call."""
# Remove surrounding whitespace and newlines # Remove surrounding whitespace and newlines
expr = raw.strip().replace('\n', ' ').replace('\t', ' ') expr = raw.strip().replace('\n', ' ').replace('\t', ' ')
# Collapse multiple spaces # Collapse multiple spaces
expr = re.sub(r'\s+', ' ', expr) expr = re.sub(r'\s+', ' ', expr)
# Escape backslashes (preserve regex escapes like \d, CSS escapes, lambda \) # Decode JS-level escape sequences while preserving regex/CSS/lambda
# then escape quotes for SX string. # backslashes. \" -> ", \\ -> \, \d -> \d (unchanged).
expr = _decode_js_escapes(expr)
# Re-escape for SX string literal: backslashes, then quotes.
expr = expr.replace('\\', '\\\\').replace('"', '\\"') expr = expr.replace('\\', '\\\\').replace('"', '\\"')
return expr return expr