Add MVP map

This commit is contained in:
Oliver Blanthorn 2026-07-20 11:46:59 +02:00
parent 8776d2c0ce
commit f246c5f7ee
No known key found for this signature in database
GPG key ID: 2BB8C36BB504BFF3
2 changed files with 203 additions and 23 deletions

View file

@ -143,6 +143,124 @@ test("evaluates pipes and sequences in order", async () => {
expect(result).toBe("d(c)")
})
test("preserves typed values in ordinary pipes", async () => {
const value = [{ id: 1 }]
const run = jest.fn((source, _piped, input) =>
source === "source" ? value : input,
)
await expect(evaluate("source | sink", run)).resolves.toBe(value)
expect(run.mock.calls[1][2]).toBe(value)
})
test.each(["values .| double", "values | map double"])(
"maps exactly one 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])
expect(run.mock.calls.map(call => call[0])).toEqual([
"values",
"double",
"double",
"double",
])
},
)
test("maps a command with arguments and pipes the collected results", async () => {
const run = jest.fn((command, _piped, input) => {
if (command === "values") return [1, 2]
if (command === "add 3") return input + 3
return input.reduce((sum, value) => sum + value, 0)
})
await expect(evaluate("values | map add 3 | sum", run)).resolves.toBe(9)
})
test.each([
"values .| { double | stringify }",
"values | map { double | stringify }",
])("maps a multi-stage block in %s", source =>
expect(
evaluate(source, (command, _piped, input) => {
if (command === "values") return [1, 2]
if (command === "double") return input * 2
return String(input)
}),
).resolves.toEqual(["2", "4"]),
)
test.each(["matrix .| map { double }", "matrix | map map { double }"])(
"supports nested command and block maps in %s",
source =>
expect(
evaluate(source, (command, _piped, input) =>
command === "matrix"
? [
[1, 2],
[3, 4],
]
: input * 2,
),
).resolves.toEqual([
[2, 4],
[6, 8],
]),
)
test("runs mapped commands concurrently while preserving result order", async () => {
let active = 0
let maximumActive = 0
const run = jest.fn(async (command, _piped, input) => {
if (command === "values") return [1, 2, 3]
maximumActive = Math.max(maximumActive, ++active)
await new Promise(resolve => setTimeout(resolve, 4 - input))
active--
return input * 2
})
await expect(evaluate("values .| work", run)).resolves.toEqual([2, 4, 6])
expect(maximumActive).toBe(3)
})
test("reports the failed map item while already-started items continue", async () => {
const started: number[] = []
const error = await evaluate("values .| work", (command, _piped, input) => {
if (command === "values") return [0, 1, 2]
started.push(input)
if (input === 1) throw new Error("boom")
return input
}).catch(error => error)
expect(error).toEqual(
expect.objectContaining({ message: "map item 1: boom" }),
)
expect(error.cause).toEqual(expect.objectContaining({ message: "boom" }))
expect(started).toEqual([0, 1, 2])
})
test.each(["text", "object"])("rejects %s map input", async command => {
const value = command === "text" ? "one two" : { one: 1, two: 2 }
await expect(
evaluate(`${command} | map echo`, source =>
source === command ? value : source,
),
).rejects.toThrow("map expected an array")
})
test("maps an empty array without invoking the target", async () => {
const run = jest.fn(source => {
if (source === "values") return []
throw new Error("target invoked")
})
await expect(evaluate("values .| target", run)).resolves.toEqual([])
expect(run).toHaveBeenCalledTimes(1)
})
test.each(["map target", "values | map"])(
"rejects invalid map form %s",
source =>
expect(evaluate(source, jest.fn())).rejects.toThrow("map requires"),
)
test("continues incomplete operators across newlines", async () => {
const run = jest.fn((source, piped, value) =>
piped ? `${source}(${value})` : source,
@ -154,10 +272,8 @@ test("continues incomplete operators across newlines", async () => {
])
})
test.each(["a && b", "a .| b"])(
"rejects unsupported execution syntax in %s",
source =>
expect(evaluate(source, jest.fn())).rejects.toThrow("Unsupported"),
test.each(["a && b"])("rejects unsupported execution syntax in %s", source =>
expect(evaluate(source, jest.fn())).rejects.toThrow("Unsupported"),
)
test("rejects unsupported syntax before execution", async () => {

View file

@ -281,11 +281,29 @@ export type ExCommandRunner = (
interface ExStage {
piped: boolean
command: string
map?: ExStage[]
program?: ExProgram
raw?: string
block?: ExStage[]
}
const mapStage = (map: ExStage[], piped = false): ExStage => ({
command: "",
piped,
map,
})
function mapError(index: number, cause: unknown) {
return Object.assign(
new Error(
`map item ${index}: ${
cause instanceof Error ? cause.message : String(cause)
}`,
),
{ cause },
)
}
function compile(
source: string,
structure: ExStructure,
@ -294,24 +312,55 @@ function compile(
const stages: ExStage[] = []
let sourceText = ""
let piped = false
let mapNext = false
let block: Extract<ExPart, { type: "block" }> | undefined
let blockCommand = ""
let raw: string | undefined
function commandStage(
command: string,
piped: boolean,
raw?: string,
): ExStage {
const mapped = /^map(?:\s+(.*))?$/.exec(command)
if (!mapped) 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)
mapNext = false
}
const flush = () => {
if (block && raw !== undefined)
throw new Error("Unsupported ex block with a heredoc")
if (block) {
if (sourceText.trim())
throw new Error("Unsupported text after ex block")
const receivesInput = piped || (stages.length === 0 && initialPiped)
const body = compile(source, block.body, receivesInput)
if (blockCommand) {
const mapDepth = /^map(?:\s+map)*$/.test(blockCommand)
? blockCommand.split(/\s+/).length
: 0
const receivesInput =
piped || mapNext || (stages.length === 0 && initialPiped)
const body = compile(
source,
block.body,
receivesInput || mapDepth > 0,
)
if (mapDepth > 0) {
let stage = mapStage(body)
for (let depth = 1; depth < mapDepth; depth++)
stage = mapStage([stage])
stage.piped = piped
push(stage)
} else if (blockCommand) {
if (receivesInput)
throw new Error(
"Unsupported pipeline input with an ex block argument",
)
stages.push({
push({
command: blockCommand,
piped,
program: {
@ -320,10 +369,10 @@ function compile(
},
})
} else {
stages.push({ block: body, command: "", piped })
push({ block: body, command: "", piped })
}
} else if (sourceText.trim()) {
stages.push({ command: sourceText.trim(), piped, raw })
push(commandStage(sourceText.trim(), piped, raw))
}
sourceText = ""
block = undefined
@ -352,17 +401,29 @@ function compile(
sourceText = ""
continue
}
if (!["|", ";", "\n"].includes(part.operator))
if (!["|", ".|", ";", "\n"].includes(part.operator))
throw new Error(
`Unsupported ex syntax: ${source.slice(part.start, part.end)}`,
)
flush()
mapNext = part.operator === ".|"
piped = part.operator === "|"
}
flush()
return stages
}
async function mapValues(stages: ExStage[], run: ExCommandRunner, input: any) {
if (!Array.isArray(input)) throw new Error("map expected an array")
return Promise.all(
input.map((item, index) =>
execute(stages, run, true, item).catch(error => {
throw mapError(index, error)
}),
),
)
}
async function execute(
stages: ExStage[],
run: ExCommandRunner,
@ -375,18 +436,21 @@ async function execute(
const piped = stage.piped || (index === 0 && initialPiped)
const input = piped ? value : undefined
try {
value =
stage.block !== undefined
? await execute(stage.block, run, piped, input)
: stage.raw === undefined
? await run(stage.command, piped, input, stage.program)
: await run(
stage.command,
piped,
input,
stage.program,
stage.raw,
)
if (stage.map && !piped)
throw new Error("map requires pipeline input")
value = stage.map
? await mapValues(stage.map, run, input)
: stage.block !== undefined
? await execute(stage.block, run, piped, input)
: stage.raw === undefined
? await run(stage.command, piped, input, stage.program)
: await run(
stage.command,
piped,
input,
stage.program,
stage.raw,
)
} catch (error) {
while (stages[index + 1]?.piped) index++
if (index === stages.length - 1) throw error