Files

146 lines
6.8 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
describe("PowerShell practical syntax", () => {
test.each([
["$result = git status", ["git status"]],
["$result += (git status)", ["git status"]],
["[string]$result = git status", ["git status"]],
["$x = 1; $x++; git status", ["git status"]],
["$true; 1 + 2; 'literal'; git status", ["git status"]],
["${result}# ignored\ngit status", ["git status"]],
[
"Write-Output { ${result}# ignored\ngit status }",
["Write-Output { ${result}# ignored\ngit status }", "git status"],
],
["Switch-Branch main; Function-Name arg", ["Switch-Branch main", "Function-Name arg"]],
["(git status)", ["git status"]],
["@(git status; git diff)", ["git status", "git diff"]],
["$x = @{ status = git status; count = 1 }", ["git status"]],
["if (Test-Path file) { git status } else { git diff }", ["Test-Path file", "git status", "git diff"]],
["if ($true) { git status } elseif ($false) { git diff }", ["git status", "git diff"]],
["foreach ($file in (Get-ChildItem .)) { Get-Content $file }", ["Get-ChildItem .", "Get-Content $file"]],
["foreach ($file in Get-ChildItem .) { Get-Content $file }", ["Get-ChildItem .", "Get-Content $file"]],
["for ($i = 0; $i -lt 2; $i++) { git status }", ["git status"]],
["while (Test-Path file) { git status; break }", ["Test-Path file", "git status"]],
["do { git status } until ($true)", ["git status"]],
["function Get-Status { param($file); git status }; Get-Status", ["git status", "Get-Status"]],
["function Get-Status($file = (Get-Item .)) { git status }", ["Get-Item .", "git status"]],
[
"try { git status } catch { Write-Output $_ } finally { git diff }",
["git status", "Write-Output $_", "git diff"],
],
["& { git status }", ["& { git status }", "git status"]],
["return git status", ["git status"]],
["<# <# ignored } #> #> ignored\ngit status", ["git status"]],
["git <# ignored #> status", ["git <# ignored #> status"]],
['Write-Output "$(git status)"', ['Write-Output "$(git status)"', "git status"]],
[
'Write-Output "$(Write-Output "$(git status)")"',
['Write-Output "$(Write-Output "$(git status)")"', 'Write-Output "$(git status)"', "git status"],
],
["Write-Output @'\n$(not-a-command)\n'@; git status", ["Write-Output @'\n$(not-a-command)\n'@", "git status"]],
['Write-Output @"\n$(git status)\n"@', ['Write-Output @"\n$(git status)\n"@', "git status"]],
[
"cmd.exe --% $(literal) > literal.txt; still-literal\ngit status",
["cmd.exe --% $(literal) > literal.txt; still-literal", "git status"],
],
[
'cmd.exe --% "literal|still-literal" | Write-Output done',
['cmd.exe --% "literal|still-literal"', "Write-Output done"],
],
["Write-Output '--%' ; git status", ["Write-Output '--%'", "git status"]],
["Write-Output prefix--% literal; git status", ["Write-Output prefix--% literal", "git status"]],
["git status |\n\n# comment\nOut-String", ["git status", "Out-String"]],
] as const)("extracts command resources from %s", (source, resources) => {
const result = ShellScan.scanPowerShell(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.resource)).toEqual([...resources])
})
test.each(["\n", "\r", "\r\n"])(
"distinguishes standalone continuations from escapes inside tokens: %j",
(newline) => {
expect(ShellScan.scanPowerShell(`git \`${newline}\tstatus`)).toMatchObject({
kind: "scanned",
commands: [{ words: ["git", "status"], rawWords: ["git", "status"] }],
})
expect(ShellScan.scanPowerShell(`Write-Output left\`${newline}right`)).toMatchObject({
kind: "scanned",
commands: [
{ words: ["Write-Output", `left${newline}right`], rawWords: ["Write-Output", `left\`${newline}right`] },
],
})
},
)
test("decodes literal backtick escapes without evaluating expressions", () => {
const source = '& "Wr`ite-Output" "tab`tnewline`n`u{1f642}" left`;right'
expect(ShellScan.scanPowerShell(source)).toMatchObject({
kind: "scanned",
commands: [
{
resource: source,
words: ["Write-Output", "tab\tnewline\n\u{1f642}", "left;right"],
rawWords: ['"Wr`ite-Output"', '"tab`tnewline`n`u{1f642}"', "left`;right"],
},
],
})
})
test("preserves word offsets relative to each raw command, including nested invocations", () => {
const source = ' & git\tstatus\t--short; Write-Output "$(git\tlog)"'
const result = ShellScan.scanPowerShell(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands[0]?.wordEnds).toEqual([5, 12, 20])
expect(result.commands[2]?.resource).toBe("git\tlog")
for (const command of result.commands) {
expect(command.wordEnds).toHaveLength(command.rawWords.length)
for (const [index, word] of command.rawWords.entries()) {
const end = command.wordEnds![index]!
expect(command.resource.slice(end - word.length, end)).toBe(word)
}
}
})
test.each([
["ForEach-Object { git status }", true],
["git status; ForEach-Object { git diff }", true],
["git status | ForEach-Object { git diff }", undefined],
["git status && ForEach-Object { git diff }", undefined],
["git status || ForEach-Object { git diff }", undefined],
["& ForEach-Object { git status }", undefined],
["& 'ForEach-Object' { git status }", undefined],
["% { git status }", true],
["foreachthing { git status }", true],
] as const)("retains caller and lexical statement-head context: %s", (source, statementHead) => {
const result = ShellScan.scanPowerShell(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
const caller = result.commands.find((command) => command.rawWords.at(-1)?.startsWith("{"))
expect(caller).toBeDefined()
expect(caller?.statementHead).toBe(statementHead)
expect(result.commands.at(-1)?.words[0]).toBe("git")
})
test.each([
"if ($true) { git status",
'Write-Output "$(git status"',
"Write-Output @(git status",
"Write-Output @'\nunclosed",
"<# unclosed",
"git status |\n# no operand",
"Write-Output `u{110000}",
"Write-Output `u{xyz}",
])("bounds and reports incomplete lexical structures: %s", (source) => {
expect(ShellScan.scanPowerShell(source).kind).toBe("opaque")
})
test("bounds mixed expression, string, and block recursion", () => {
expect(ShellScan.scanPowerShell("$(".repeat(40) + "git status" + ")".repeat(40)).kind).toBe("opaque")
expect(ShellScan.scanPowerShell('Write-Output "$('.repeat(40) + "git status" + ')"'.repeat(40)).kind).toBe("opaque")
})
})