|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * Copyright (c) Microsoft Corporation. All rights reserved. |
| 3 | + * Licensed under the MIT License. See License.txt in the project root for license information. |
| 4 | + *--------------------------------------------------------------------------------------------*/ |
| 5 | + |
| 6 | +import { deepStrictEqual } from 'assert'; |
| 7 | +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; |
| 8 | +import type { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; |
| 9 | +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; |
| 10 | +import { ITreeSitterLibraryService } from '../../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js'; |
| 11 | +import { TreeSitterLibraryService } from '../../../../../services/treeSitter/browser/treeSitterLibraryService.js'; |
| 12 | +import { FileService } from '../../../../../../platform/files/common/fileService.js'; |
| 13 | +import { NullLogService } from '../../../../../../platform/log/common/log.js'; |
| 14 | +import { Schemas } from '../../../../../../base/common/network.js'; |
| 15 | +import { TestIPCFileSystemProvider } from '../../../../../test/electron-browser/workbenchTestServices.js'; |
| 16 | +import { TreeSitterCommandParser, TreeSitterCommandParserLanguage } from '../../browser/treeSitterCommandParser.js'; |
| 17 | + |
| 18 | +suite('TreeSitterCommandParser', () => { |
| 19 | + const store = ensureNoDisposablesAreLeakedInTestSuite(); |
| 20 | + |
| 21 | + let instantiationService: TestInstantiationService; |
| 22 | + let parser: TreeSitterCommandParser; |
| 23 | + |
| 24 | + setup(() => { |
| 25 | + const logService = new NullLogService(); |
| 26 | + const fileService = store.add(new FileService(logService)); |
| 27 | + const fileSystemProvider = new TestIPCFileSystemProvider(); |
| 28 | + store.add(fileService.registerProvider(Schemas.file, fileSystemProvider)); |
| 29 | + |
| 30 | + instantiationService = workbenchInstantiationService({ |
| 31 | + fileService: () => fileService, |
| 32 | + }, store); |
| 33 | + |
| 34 | + const treeSitterLibraryService = store.add(instantiationService.createInstance(TreeSitterLibraryService)); |
| 35 | + treeSitterLibraryService.isTest = true; |
| 36 | + instantiationService.stub(ITreeSitterLibraryService, treeSitterLibraryService); |
| 37 | + |
| 38 | + parser = instantiationService.createInstance(TreeSitterCommandParser); |
| 39 | + }); |
| 40 | + |
| 41 | + suite('Bash command parsing', () => { |
| 42 | + async function t(commandLine: string, expectedCommands: string[]) { |
| 43 | + const result = await parser.extractSubCommands(TreeSitterCommandParserLanguage.Bash, commandLine); |
| 44 | + deepStrictEqual(result, expectedCommands); |
| 45 | + } |
| 46 | + |
| 47 | + test('simple commands', () => t('ls -la', ['ls -la'])); |
| 48 | + test('commands with &&', () => t('echo hello && ls -la', ['echo hello', 'ls -la'])); |
| 49 | + test('commands with ||', () => t('test -f file.txt || touch file.txt', ['test -f file.txt', 'touch file.txt'])); |
| 50 | + test('commands with semicolons', () => t('cd /tmp; ls; pwd', ['cd /tmp', 'ls', 'pwd'])); |
| 51 | + test('pipe chains', () => t('cat file.txt | grep pattern | sort | uniq', ['cat file.txt', 'grep pattern', 'sort', 'uniq'])); |
| 52 | + test('commands with subshells', () => t('echo $(date +%Y) && ls', ['echo $(date +%Y)', 'date +%Y', 'ls'])); |
| 53 | + test('complex quoting', () => t('echo "hello && world" && echo \'test\'', ['echo "hello && world"', 'echo \'test\''])); |
| 54 | + test('escaped characters', () => t('echo hello\\ world && ls', ['echo hello\\ world', 'ls'])); |
| 55 | + test('background commands', () => t('sleep 10 & echo done', ['sleep 10', 'echo done'])); |
| 56 | + test('variable assignments', () => t('VAR=value command1 && echo $VAR', ['VAR=value command1', 'echo $VAR'])); |
| 57 | + test('redirections', () => t('echo hello > file.txt && cat < file.txt', ['echo hello', 'cat'])); |
| 58 | + test('arithmetic expansion', () => t('echo $((1 + 2)) && ls', ['echo $((1 + 2))', 'ls'])); |
| 59 | + |
| 60 | + suite('control flow and structures', () => { |
| 61 | + test('if-then-else', () => t('if [ -f file.txt ]; then cat file.txt; else echo "not found"; fi', ['cat file.txt', 'echo "not found"'])); |
| 62 | + test('simple iteration', () => t('for file in *.txt; do cat "$file"; done', ['cat "$file"'])); |
| 63 | + test('function declaration and call', () => t('function test_func() { echo "inside function"; } && test_func', ['echo "inside function"', 'test_func'])); |
| 64 | + test('heredoc with commands', () => t('cat << EOF\nhello\nworld\nEOF\necho done', ['cat', 'echo done'])); |
| 65 | + }); |
| 66 | + |
| 67 | + suite('edge cases', () => { |
| 68 | + test('malformed syntax', () => t('echo "unclosed quote && ls', ['echo'])); |
| 69 | + test('unmatched parentheses', () => t('echo $(missing closing && ls', ['echo $(missing closing && ls', 'missing closing', 'ls'])); |
| 70 | + test('very long command lines', () => t('echo ' + 'a'.repeat(10000) + ' && ls', ['echo ' + 'a'.repeat(10000), 'ls'])); |
| 71 | + test('special characters', () => t('echo "πλαςε 测试 🚀" && ls', ['echo "πλαςε 测试 🚀"', 'ls'])); |
| 72 | + test('multiline with continuations', () => t('echo hello \\\n&& echo world \\\n&& ls', ['echo hello', 'echo world', 'ls'])); |
| 73 | + test('commands with comments', () => t('echo hello # this is a comment\nls # another comment', ['echo hello', 'ls'])); |
| 74 | + }); |
| 75 | + |
| 76 | + // TODO: These should be common but the pwsh grammar doesn't handle && yet https://github.com/microsoft/vscode/issues/272704 |
| 77 | + suite('real-world scenarios', () => { |
| 78 | + test('complex Docker commands', () => t('docker run -it --rm -v $(pwd):/app ubuntu:latest bash -c "cd /app && npm install && npm test"', ['docker run -it --rm -v $(pwd):/app ubuntu:latest bash -c "cd /app && npm install && npm test"', 'pwd'])); |
| 79 | + test('Git workflow commands', () => t('git add . && git commit -m "Update feature" && git push origin main', [ |
| 80 | + 'git add .', |
| 81 | + 'git commit -m "Update feature"', |
| 82 | + 'git push origin main' |
| 83 | + ])); |
| 84 | + test('npm/yarn workflow commands', () => t('npm ci && npm run build && npm test && npm run lint', [ |
| 85 | + 'npm ci', |
| 86 | + 'npm run build', |
| 87 | + 'npm test', |
| 88 | + 'npm run lint' |
| 89 | + ])); |
| 90 | + test('build system commands', () => t('make clean && make -j$(nproc) && make install PREFIX=/usr/local', [ |
| 91 | + 'make clean', |
| 92 | + 'make -j$(nproc)', |
| 93 | + 'nproc', |
| 94 | + 'make install PREFIX=/usr/local' |
| 95 | + ])); |
| 96 | + }); |
| 97 | + }); |
| 98 | + |
| 99 | + suite('PowerShell command parsing', () => { |
| 100 | + async function t(commandLine: string, expectedCommands: string[]) { |
| 101 | + const result = await parser.extractSubCommands(TreeSitterCommandParserLanguage.PowerShell, commandLine); |
| 102 | + deepStrictEqual(result, expectedCommands); |
| 103 | + } |
| 104 | + |
| 105 | + test('simple commands', () => t('Get-ChildItem -Path C:\\', ['Get-ChildItem -Path C:\\'])); |
| 106 | + test('commands with semicolons', () => t('Get-Date; Get-Location; Write-Host "done"', ['Get-Date', 'Get-Location', 'Write-Host "done"'])); |
| 107 | + test('pipeline commands', () => t('Get-Process | Where-Object {$_.CPU -gt 100} | Sort-Object CPU', ['Get-Process ', 'Where-Object {$_.CPU -gt 100} ', 'Sort-Object CPU'])); |
| 108 | + test('command substitution', () => t('Write-Host $(Get-Date) ; Get-Location', ['Write-Host $(Get-Date)', 'Get-Date', 'Get-Location'])); |
| 109 | + test('complex parameters', () => t('Get-ChildItem -Path "C:\\Program Files" -Recurse -Include "*.exe"', ['Get-ChildItem -Path "C:\\Program Files" -Recurse -Include "*.exe"'])); |
| 110 | + test('splatting', () => t('$params = @{Path="C:\\"; Recurse=$true}; Get-ChildItem @params', ['Get-ChildItem @params'])); |
| 111 | + test('here-strings', () => t('Write-Host @"\nhello\nworld\n"@ ; Get-Date', ['Write-Host @"\nhello\nworld\n"@', 'Get-Date'])); |
| 112 | + test('method calls', () => t('"hello".ToUpper() ; Get-Date', ['Get-Date'])); |
| 113 | + test('complex quoting', () => t('Write-Host "She said `"Hello`"" ; Write-Host \'Single quotes\'', ['Write-Host "She said `"Hello`""', 'Write-Host \'Single quotes\''])); |
| 114 | + |
| 115 | + suite('Control flow and structures', () => { |
| 116 | + test('logical and', () => t('Test-Path "file.txt" -and Get-Content "file.txt"', ['Test-Path "file.txt" -and Get-Content "file.txt"'])); |
| 117 | + test('foreach with script block', () => t('ForEach-Object { Write-Host $_.Name } ; Get-Date', ['ForEach-Object { Write-Host $_.Name }', 'Write-Host $_.Name', 'Get-Date'])); |
| 118 | + test('if-else', () => t('if (Test-Path "file.txt") { Get-Content "file.txt" } else { Write-Host "not found" }', ['Test-Path "file.txt"', 'Get-Content "file.txt"', 'Write-Host "not found"'])); |
| 119 | + test('error handling', () => t('try { Get-Content "file.txt" } catch { Write-Error "failed" }', ['Get-Content "file.txt"', 'Write-Error "failed"'])); |
| 120 | + }); |
| 121 | + }); |
| 122 | + |
| 123 | + suite('all shell command parsing', () => { |
| 124 | + async function t(commandLine: string, expectedCommands: string[]) { |
| 125 | + for (const shell of [TreeSitterCommandParserLanguage.Bash, TreeSitterCommandParserLanguage.PowerShell]) { |
| 126 | + const result = await parser.extractSubCommands(shell, commandLine); |
| 127 | + deepStrictEqual(result, expectedCommands); |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + suite('edge cases', () => { |
| 132 | + test('empty strings', () => t('', [])); |
| 133 | + test('whitespace-only strings', () => t(' \n\t ', [])); |
| 134 | + }); |
| 135 | + }); |
| 136 | +}); |
0 commit comments