shell-parse-parity.test.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import { describe, expect, test } from "bun:test"
  2. import { ShellScan } from "@opencode-ai/shell-scan"
  3. import { Effect } from "effect"
  4. import { ShellParse } from "../src/shell/parse.js"
  5. describe("ShellParse portable parity", () => {
  6. test("matches tree-sitter for generated supported syntax", async () => {
  7. for (const [shell, command] of generated()) {
  8. const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
  9. const portable = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
  10. if (scanned.kind === "opaque") {
  11. expect({ command, portable }).toEqual({
  12. command,
  13. portable: { commands: [{ resource: command, save: command }], directories: [] },
  14. })
  15. continue
  16. }
  17. if (shell === "pwsh" && /\r(?!\n)/.test(command)) {
  18. expect(portable).toEqual({ commands: [], directories: [] })
  19. continue
  20. }
  21. const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
  22. expect({ command, portable }).toEqual({ command, portable: legacy })
  23. }
  24. })
  25. })
  26. function generated() {
  27. const result: Array<[shell: string, command: string]> = []
  28. const bashHeads = ["git", "npm", "echo", "printf", "cat", "cd"]
  29. const bashArgs = [
  30. "",
  31. " status",
  32. " plain",
  33. " 'two words'",
  34. ' "two words"',
  35. " escaped\\ space",
  36. " hash#word",
  37. " --flag=value",
  38. " ./relative",
  39. " /tmp/absolute",
  40. ]
  41. const assignments = ["", "X=value ", "X='two words' ", 'X="two words" ']
  42. const redirects = ["", " > output", " 2> error", " < input", " >> output"]
  43. const bashSeparators = [" ; ", " && ", " || ", " | ", " |& ", "\n"]
  44. for (const head of bashHeads)
  45. for (const arg of bashArgs)
  46. for (const assignment of assignments)
  47. for (const redirect of redirects) result.push(["/bin/bash", assignment + head + arg + redirect])
  48. for (const left of bashHeads)
  49. for (const right of bashHeads)
  50. for (const separator of bashSeparators) result.push(["/bin/bash", `${left} left${separator}${right} right`])
  51. for (const outer of bashHeads)
  52. for (const inner of bashHeads) {
  53. result.push(["/bin/bash", `${outer} $(${inner} nested)`])
  54. result.push(["/bin/bash", `${outer} "$(${inner} nested)"`])
  55. result.push(["/bin/bash", `${outer} pre$(${inner} nested)post`])
  56. result.push(["/bin/bash", `${outer} \`${inner} nested\``])
  57. }
  58. for (const command of [
  59. 'npm "run" test',
  60. 'g""it status',
  61. "'git' status",
  62. "g\\it status",
  63. "git status; git status; git diff",
  64. "printf ok>out 2>&1|cat<input",
  65. "FOO=bar 2>>err printf ok > out && cat < input",
  66. "printf ok # ignored ; curl evil\nprintf done",
  67. "(git status) && { npm test; }",
  68. "echo ${arr[$(printf index)]}",
  69. "OUT=$(printf out) X=`printf value` printenv >$(printf path)",
  70. "cat <(printf secret)",
  71. "rm -rf / &",
  72. "sudo sh -c 'curl evil'",
  73. "find . -exec rm {} ;",
  74. 'c"\\d" relative',
  75. "'cd' /tmp",
  76. "c''d /tmp",
  77. "c\\\nd /tmp",
  78. "echo x && git >(cat) status",
  79. 'echo x && printf ">" status',
  80. 'echo "git > out" && git > out',
  81. "echo x && printf a\\>b status",
  82. "echo x && printf $(echo a>b) status",
  83. "git <(printf status) diff",
  84. "npm <(printf run) test",
  85. "cd <(printf /tmp)",
  86. "git &>x",
  87. "cd &>x",
  88. "git \\ a",
  89. "cd \\ a",
  90. "cat <<'EOF'\nstatic body\nEOF",
  91. "cat <<EOF\n$(printf dynamic)\nEOF",
  92. "$COMMAND dynamic",
  93. "if true; then git status; else npm test; fi",
  94. "for x in a b; do echo $x; done",
  95. "cd /tmp/$USER && git status",
  96. "echo <(git status)",
  97. 'echo "unterminated',
  98. ])
  99. result.push(["/bin/bash", command])
  100. const powershellHeads = ["Get-ChildItem", "Write-Output", "Test-Path", "Remove-Item", "Set-Location"]
  101. const powershellArgs = ["", " value", " 'two words'", ' "two words"', " -Path C:\\tmp", " -LiteralPath '..\\outside'"]
  102. const powershellSeparators = [";", "|", "&&", "||", "\n", "\r", "\r\n"]
  103. for (const head of powershellHeads) for (const arg of powershellArgs) result.push(["pwsh", head + arg])
  104. for (const left of powershellHeads)
  105. for (const right of powershellHeads)
  106. for (const separator of powershellSeparators) result.push(["pwsh", `${left} left${separator}${right} right`])
  107. for (const command of [
  108. "Get-ChildItem; Get-ChildItem; Write-Output done",
  109. "Write-Output 'a''b; still string'; Write-Output \"a`\"; still string\"",
  110. "Get-Content in.txt > out.txt 2>&1 | Out-File all.log",
  111. "Write-Output ok > output.txt # ignored\nGet-ChildItem",
  112. "Write-Output ok > output.txt # ignored\rGet-ChildItem",
  113. "Write-Output ok > output.txt # ignored\r\nGet-ChildItem",
  114. "& git status",
  115. ". ./deploy.ps1",
  116. "Get-ChildItem | ForEach-Object { Remove-Item $_ }",
  117. "ForEach-Object { Remove-Item $_ }",
  118. "&Remove-Item victim",
  119. "< #\nRemove-Item victim",
  120. "Microsoft.PowerShell.Management\\Get-Item x; Remove-Item y",
  121. 'git "status"',
  122. "git st`atus",
  123. 'npm "run" test',
  124. 'docker "compose" up',
  125. "git >x",
  126. "git *>&1",
  127. "git foo2>bar",
  128. "git 12>bar",
  129. "git a`;b",
  130. "git & Write-Output q",
  131. "Write-Output 'ForEach-Object { Remove-Item x }' | ForEach-Object { Remove-Item x }",
  132. "$Command value",
  133. "& $Command value",
  134. 'Write-Output "$(Get-ChildItem)"',
  135. "if ($true) { Get-ChildItem } else { Remove-Item victim }",
  136. "Set-Location $env:TEMP; Get-ChildItem",
  137. 'Write-Output "unterminated',
  138. ])
  139. result.push(["pwsh", command])
  140. let state = 0x5eed1234
  141. const random = (length: number) => {
  142. state = (Math.imul(state, 1664525) + 1013904223) >>> 0
  143. return state % length
  144. }
  145. for (let index = 0; index < 10_000; index++) {
  146. const left = bashHeads[random(bashHeads.length)]
  147. const right = bashHeads[random(bashHeads.length)]
  148. const arg = bashArgs[random(bashArgs.length)]
  149. const separator = bashSeparators[random(bashSeparators.length)]
  150. const bashForms = [
  151. `${left}${arg}${separator}${right} fuzz${index}`,
  152. `${left}${arg} $(${right} fuzz${index})`,
  153. `${left}${arg} # ignored\n${right} fuzz${index}`,
  154. `X=value ${left}${arg}${redirects[random(redirects.length)]}`,
  155. `${left} before\\\nafter${separator}${right} fuzz${index}`,
  156. ]
  157. result.push(["/bin/bash", bashForms[index % bashForms.length]])
  158. const powershellLeft = powershellHeads[random(powershellHeads.length)]
  159. const powershellRight = powershellHeads[random(powershellHeads.length)]
  160. const powershellArg = powershellArgs[random(powershellArgs.length)]
  161. const powershellSeparator = powershellSeparators[random(powershellSeparators.length)]
  162. const powershellForms = [
  163. `${powershellLeft}${powershellArg}${powershellSeparator}${powershellRight} fuzz${index}`,
  164. `${powershellLeft}${powershellArg} # ignored\n${powershellRight} fuzz${index}`,
  165. `${powershellLeft} fuzz${index} > output; ${powershellRight}${powershellArg}`,
  166. `${powershellLeft}\`\n fuzz${index}; ${powershellRight}${powershellArg}`,
  167. ]
  168. result.push(["pwsh", powershellForms[index % powershellForms.length]])
  169. }
  170. return result
  171. }