Add filter and underscore magic lambdas

This commit is contained in:
Oliver Blanthorn 2026-07-20 13:02:06 +02:00
parent f246c5f7ee
commit 8a6eb03f41
No known key found for this signature in database
GPG key ID: 2BB8C36BB504BFF3
6 changed files with 111 additions and 12 deletions

View file

@ -39,7 +39,7 @@ class Signature:
# Type declaration
if ':' in param:
name, typ = map(str.strip, param.split(':'))
if (typ not in ('number', 'boolean', 'string', 'string[]', 'ModeName')
if (typ not in ('number', 'boolean', 'string', 'string[]', 'any[]', 'ModeName')
and '|' not in typ
and typ[0] not in ['"',"'"]
):

View file

@ -93,6 +93,7 @@ import * as Native from "@src/lib/native"
import * as TTS from "@src/lib/text_to_speech"
import * as excmd_parser from "@src/parsers/exmode"
import * as escape from "@src/lib/escape"
import * as Collections from "@src/lib/collections"
import semverCompare from "semver-compare"
import * as hint_util from "@src/lib/hint_util"
import { OpenMode } from "@src/lib/hint_util"
@ -4158,6 +4159,27 @@ export async function composite(...cmds: string[]) {
}
}
/**
* Transform every element of a piped array with an underscore selector.
* `_` is identity; dotted paths select properties and throw when an
* intermediate value is missing. In exversion 2, non-selector targets map ex
* commands, and `array .| target` is shorthand for `array | map target`.
* Example: `js [{url: "one"}, {url: "two"}] | map _.url`.
*/
//#both
export function map(expression: string, values: any[]): any[] {
return Collections.map(expression, values)
}
/**
* Keep elements of a piped array whose underscore selector is truthy.
* Example: `js [{url: "one"}, {}] | filter _.url`.
*/
//#both
export function filter(expression: string, values: any[]): any[] {
return Collections.filter(expression, values)
}
/**
* Escape command for safe use in shell with composite. E.g: `composite js MALICIOUS_WEBSITE_FUNCTION() | shellescape | exclaim ls`
*/

View file

@ -0,0 +1,27 @@
import { filter, map, selector } from "@src/lib/collections"
test.each([
["_", { url: "one" }, { url: "one" }],
["_.url", { url: "one" }, "one"],
["_.author.name", { author: { name: "Olie" } }, "Olie"],
])("applies magic selector %s", (source, value, expected) =>
expect(selector(source)(value)).toEqual(expected),
)
test("maps and filters arrays with magic selectors", () => {
const values = [{ url: "one" }, {}, { url: "two" }]
expect(map("_.url", filter("_.url", values))).toEqual(["one", "two"])
})
test("uses direct property access semantics", () =>
expect(() => selector("_.author.name")({})).toThrow())
test.each(["url", "_.url()", "_.0", "_.foo-bar"])(
"rejects unsupported selector %s",
source => expect(() => selector(source)).toThrow("selector"),
)
test.each(["one two", { one: 1 }])("rejects non-array input", value => {
expect(() => map("_", value as any)).toThrow("array")
expect(() => filter("_", value as any)).toThrow("array")
})

23
src/lib/collections.ts Normal file
View file

@ -0,0 +1,23 @@
const selectorPattern = /^_(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/
export const isSelector = (source: string) => selectorPattern.test(source)
export function selector(source: string): (value: any) => any {
if (!selectorPattern.test(source))
throw new Error(`Invalid selector: ${source}`)
const path = source.split(".").slice(1)
return value => path.reduce((current, property) => current[property], value)
}
function array(values: any): any[] {
if (!Array.isArray(values)) throw new Error("Expected an array")
return values
}
export function map(source: string, values: any[]): any[] {
return array(values).map(selector(source))
}
export function filter(source: string, values: any[]): any[] {
return array(values).filter(selector(source))
}

View file

@ -152,18 +152,37 @@ test("preserves typed values in ordinary pipes", async () => {
expect(run.mock.calls[1][2]).toBe(value)
})
test.each(["values .| double", "values | map double"])(
"maps exactly one command in %s",
test.each([
["values .| double", "double"],
["values | map double", "double"],
["values .| _double", "_double"],
["values | map _double", "_double"],
])("maps exactly one command in %s", async (source, target) => {
const run = jest.fn((command, _piped, input) =>
command === "values" ? [1, 2, 3] : input * 2,
)
await expect(evaluate(source, run)).resolves.toEqual([2, 4, 6])
expect(run.mock.calls.map(call => call[0])).toEqual([
"values",
target,
target,
target,
])
})
test.each(["values .| _.url", "values | map _.url"])(
"maps magic selectors through the standard command in %s",
async source => {
const run = jest.fn((command, _piped, input) =>
command === "values" ? [1, 2, 3] : input * 2,
)
await expect(evaluate(source, run)).resolves.toEqual([2, 4, 6])
const values = [{ url: "one" }, { url: "two" }]
const run = jest.fn((command, _piped, input) => {
if (command === "values") return values
if (command === "map _.url") return input.map(value => value.url)
throw new Error(`Unexpected command: ${command}`)
})
await expect(evaluate(source, run)).resolves.toEqual(["one", "two"])
expect(run.mock.calls.map(call => call[0])).toEqual([
"values",
"double",
"double",
"double",
"map _.url",
])
},
)

View file

@ -1,4 +1,5 @@
import { ExProgram } from "@src/lib/excmd"
import { isSelector } from "@src/lib/collections"
const operators = [".|", "&&", "||", "|", ";"] as const
@ -323,13 +324,20 @@ function compile(
raw?: string,
): ExStage {
const mapped = /^map(?:\s+(.*))?$/.exec(command)
if (!mapped) return { command, piped, raw }
if (!mapped || (mapped[1] && isSelector(mapped[1])))
return { command, piped, raw }
if (!mapped[1]) throw new Error("map requires a command or block")
return mapStage([commandStage(mapped[1], false, raw)], piped)
}
const push = (stage: ExStage) => {
stages.push(mapNext ? mapStage([stage], true) : stage)
stages.push(
mapNext
? isSelector(stage.command)
? { ...stage, command: `map ${stage.command}`, piped: true }
: mapStage([stage], true)
: stage,
)
mapNext = false
}