mirror of
https://github.com/tridactyl/tridactyl.git
synced 2026-09-10 07:16:33 -04:00
Add MVP map
This commit is contained in:
parent
8776d2c0ce
commit
f246c5f7ee
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue