Skip to content

Commit 3446d06

Browse files
authored
Merge pull request microsoft#273131 from microsoft/tyriar/272704
Re-write && to ; in pwsh
2 parents c7e7ef5 + e1f78f1 commit 3446d06

File tree

5 files changed

+102
-12
lines changed

5 files changed

+102
-12
lines changed

src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/commandSimplifier.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,32 @@ import { IWorkspaceContextService } from '../../../../../platform/workspace/comm
99
import type { ITerminalInstance } from '../../../terminal/browser/terminal.js';
1010
import { isPowerShell } from './runInTerminalHelpers.js';
1111
import type { IRunInTerminalInputParams } from './tools/runInTerminalTool.js';
12+
import { TreeSitterCommandParserLanguage, type TreeSitterCommandParser } from './treeSitterCommandParser.js';
1213

1314
export class CommandSimplifier {
1415
constructor(
1516
private readonly _osBackend: Promise<OperatingSystem>,
17+
private readonly _treeSitterCommandParser: TreeSitterCommandParser,
1618
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
1719
) {
1820
}
1921

2022
async rewriteIfNeeded(args: IRunInTerminalInputParams, instance: Pick<ITerminalInstance, 'getCwdResource'> | undefined, shell: string): Promise<string> {
21-
const commandLine = args.command;
2223
const os = await this._osBackend;
2324

25+
let commandLine = args.command;
26+
commandLine = await this._removeRedundantCdPrefix(instance, commandLine, os, shell);
27+
commandLine = await this._rewriteUnsupportedPwshChainOperators(commandLine, os, shell);
28+
29+
return commandLine;
30+
}
31+
32+
private async _removeRedundantCdPrefix(instance: Pick<ITerminalInstance, 'getCwdResource'> | undefined, commandLine: string, os: OperatingSystem, shell: string): Promise<string> {
33+
const isPwsh = isPowerShell(shell, os);
34+
2435
// Re-write the command if it starts with `cd <dir> && <suffix>` or `cd <dir>; <suffix>`
2536
// to just `<suffix>` if the directory matches the current terminal's cwd. This simplifies
2637
// the result in the chat by removing redundancies that some models like to add.
27-
const isPwsh = isPowerShell(shell, os);
2838
const cdPrefixMatch = commandLine.match(
2939
isPwsh
3040
? /^(?:cd(?: \/d)?|Set-Location(?: -Path)?) (?<dir>[^\s]+) ?(?:&&|;)\s+(?<suffix>.+)$/i
@@ -68,7 +78,25 @@ export class CommandSimplifier {
6878
}
6979
}
7080
}
81+
return commandLine;
82+
}
7183

84+
private async _rewriteUnsupportedPwshChainOperators(commandLine: string, os: OperatingSystem, shell: string) {
85+
// TODO: This should just be Windows PowerShell in the future when the powershell grammar
86+
// supports chain operators https://github.com/airbus-cert/tree-sitter-powershell/issues/27
87+
if (isPowerShell(shell, os)) {
88+
const doubleAmpersandCaptures = await this._treeSitterCommandParser.queryTree(TreeSitterCommandParserLanguage.PowerShell, commandLine, [
89+
'(',
90+
' (command',
91+
' (command_elements',
92+
' (generic_token) @double.ampersand',
93+
' (#eq? @double.ampersand "&&")))',
94+
')',
95+
].join('\n'));
96+
for (const capture of doubleAmpersandCaptures.reverse()) {
97+
commandLine = `${commandLine.substring(0, capture.node.startIndex)};${commandLine.substring(capture.node.endIndex)}`;
98+
}
99+
}
72100
return commandLine;
73101
}
74102
}

src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ function createPowerShellModelDescription(shell: string): string {
5656
`This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`,
5757
'',
5858
'Command Execution:',
59-
// Even for pwsh 7+ we want to use `;` to chain commands since the tree sitter grammar
60-
// doesn't parse `&&`. See https://github.com/airbus-cert/tree-sitter-powershell/issues/27
59+
// TODO: Even for pwsh 7+ we want to use `;` to chain commands since the tree sitter grammar
60+
// doesn't parse `&&`. We want to change this to avoid `&&` only in Windows PowerShell when
61+
// the grammar supports it https://github.com/airbus-cert/tree-sitter-powershell/issues/27
6162
'- Use semicolons ; to chain commands on one line, NEVER use && even when asked explicitly',
6263
'- Prefer pipelines | for object-based data flow',
6364
'- Never create a sub-shell (eg. powershell -c "command") unless explicitly asked',
@@ -293,8 +294,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
293294
this._osBackend = this._remoteAgentService.getEnvironment().then(remoteEnv => remoteEnv?.os ?? OS);
294295

295296
this._terminalToolCreator = _instantiationService.createInstance(ToolTerminalCreator);
296-
this._commandSimplifier = _instantiationService.createInstance(CommandSimplifier, this._osBackend);
297297
this._treeSitterCommandParser = this._instantiationService.createInstance(TreeSitterCommandParser);
298+
this._commandSimplifier = _instantiationService.createInstance(CommandSimplifier, this._osBackend, this._treeSitterCommandParser);
298299
this._telemetry = _instantiationService.createInstance(RunInTerminalToolTelemetry);
299300
this._commandLineAutoApprover = this._register(_instantiationService.createInstance(CommandLineAutoApprover));
300301
this._profileFetcher = _instantiationService.createInstance(TerminalProfileFetcher);

src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { BugIndicatingError } from '../../../../../base/common/errors.js';
77
import { derived, waitForState } from '../../../../../base/common/observable.js';
88
import { ITreeSitterLibraryService } from '../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js';
9-
import type { Language, Parser, Query } from '@vscode/tree-sitter-wasm';
9+
import type { Language, Parser, Query, QueryCapture } from '@vscode/tree-sitter-wasm';
1010

1111
export const enum TreeSitterCommandParserLanguage {
1212
Bash = 'bash',
@@ -42,9 +42,32 @@ export class TreeSitterCommandParser {
4242

4343
const captures = query.captures(tree.rootNode);
4444
const subCommands = captures.map(e => e.node.text);
45+
4546
return subCommands;
4647
}
4748

49+
async queryTree(languageId: TreeSitterCommandParserLanguage, commandLine: string, querySource: string): Promise<QueryCapture[]> {
50+
const parser = await this._parser;
51+
const language = await waitForState(derived(reader => {
52+
return this._treeSitterLibraryService.getLanguage(languageId, true, reader);
53+
}));
54+
parser.setLanguage(language);
55+
56+
const tree = parser.parse(commandLine);
57+
if (!tree) {
58+
throw new BugIndicatingError('Failed to parse tree');
59+
}
60+
61+
const query = await this._treeSitterLibraryService.createQuery(language, querySource);
62+
if (!query) {
63+
throw new BugIndicatingError('Failed to create tree sitter query');
64+
}
65+
66+
const captures = query.captures(tree.rootNode);
67+
68+
return captures;
69+
}
70+
4871
private async _getQuery(language: Language): Promise<Query> {
4972
let query = this._queries.get(language);
5073
if (!query) {

src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/commandSimplifier.test.ts renamed to src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandSimplifier.test.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,21 @@ import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js
1515
import { URI } from '../../../../../../base/common/uri.js';
1616
import type { IRunInTerminalInputParams } from '../../browser/tools/runInTerminalTool.js';
1717
import { Workspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js';
18+
import { TreeSitterCommandParser } from '../../browser/treeSitterCommandParser.js';
19+
import { ITreeSitterLibraryService } from '../../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js';
20+
import { TestIPCFileSystemProvider } from '../../../../../test/electron-browser/workbenchTestServices.js';
21+
import { NullLogService } from '../../../../../../platform/log/common/log.js';
22+
import { FileService } from '../../../../../../platform/files/common/fileService.js';
23+
import { Schemas } from '../../../../../../base/common/network.js';
24+
import { TreeSitterLibraryService } from '../../../../../services/treeSitter/browser/treeSitterLibraryService.js';
1825

1926
suite('command re-writing', () => {
2027
const store = ensureNoDisposablesAreLeakedInTestSuite();
2128

2229
let instantiationService: TestInstantiationService;
2330
let workspaceService: TestContextService;
2431

32+
let parser: TreeSitterCommandParser;
2533
let commandSimplifier: CommandSimplifier;
2634

2735
function createRewriteParams(command: string, chatSessionId?: string): IRunInTerminalInputParams {
@@ -45,14 +53,27 @@ suite('command re-writing', () => {
4553
}
4654

4755
setup(() => {
48-
instantiationService = workbenchInstantiationService(undefined, store);
56+
const fileService = store.add(new FileService(new NullLogService()));
57+
const fileSystemProvider = new TestIPCFileSystemProvider();
58+
store.add(fileService.registerProvider(Schemas.file, fileSystemProvider));
59+
60+
instantiationService = workbenchInstantiationService({
61+
fileService: () => fileService,
62+
}, store);
63+
64+
const treeSitterLibraryService = store.add(instantiationService.createInstance(TreeSitterLibraryService));
65+
treeSitterLibraryService.isTest = true;
66+
instantiationService.stub(ITreeSitterLibraryService, treeSitterLibraryService);
67+
68+
parser = instantiationService.createInstance(TreeSitterCommandParser);
69+
4970
workspaceService = instantiationService.get(IWorkspaceContextService) as TestContextService;
5071
});
5172

5273
suite('cd <cwd> && <suffix> -> <suffix>', () => {
5374
(!isWindows ? suite : suite.skip)('Posix', () => {
5475
setup(() => {
55-
commandSimplifier = instantiationService.createInstance(CommandSimplifier, Promise.resolve(OperatingSystem.Linux));
76+
commandSimplifier = instantiationService.createInstance(CommandSimplifier, Promise.resolve(OperatingSystem.Linux), parser);
5677
});
5778

5879
test('should return original command when no cd prefix pattern matches', async () => {
@@ -168,7 +189,7 @@ suite('command re-writing', () => {
168189

169190
(isWindows ? suite : suite.skip)('Windows', () => {
170191
setup(() => {
171-
commandSimplifier = instantiationService.createInstance(CommandSimplifier, Promise.resolve(OperatingSystem.Windows));
192+
commandSimplifier = instantiationService.createInstance(CommandSimplifier, Promise.resolve(OperatingSystem.Windows), parser);
172193
});
173194

174195
test('should ignore any trailing back slash', async () => {
@@ -310,7 +331,7 @@ suite('command re-writing', () => {
310331
test('should not rewrite cd /d when directory does not match cwd', async () => {
311332
const testDir = 'C:\\test\\workspace';
312333
const differentDir = 'C:\\different\\path';
313-
const command = `cd /d ${differentDir} && echo hello`;
334+
const command = `cd /d ${differentDir} ; echo hello`;
314335
const options = createRewriteParams(command, 'session-1');
315336
setWorkspaceFolders([URI.file(testDir)]);
316337

@@ -344,4 +365,22 @@ suite('command re-writing', () => {
344365
});
345366
});
346367
});
368+
369+
suite('PowerShell: && -> ;', () => {
370+
async function t(originalCommandLine: string, expectedResult: string) {
371+
const parameters = createRewriteParams(originalCommandLine);
372+
const result = await commandSimplifier.rewriteIfNeeded(parameters, undefined, 'pwsh');
373+
strictEqual(result, expectedResult);
374+
}
375+
setup(() => {
376+
commandSimplifier = instantiationService.createInstance(CommandSimplifier, Promise.resolve(OperatingSystem.Windows), parser);
377+
});
378+
379+
test('should rewrite && to ; in PowerShell commands', () => t('echo hello && echo world', 'echo hello ; echo world'));
380+
test('should rewrite multiple && to ; in PowerShell commands', () => t('echo first && echo second && echo third', 'echo first ; echo second ; echo third'));
381+
test('should handle complex commands with && operators', () => t('npm install && npm test && echo "build complete"', 'npm install ; npm test ; echo "build complete"'));
382+
test('should work with Windows PowerShell shell identifier', () => t('Get-Process && Stop-Process', 'Get-Process ; Stop-Process'));
383+
test('should preserve existing semicolons', () => t('echo hello; echo world && echo final', 'echo hello; echo world ; echo final'));
384+
test('should not rewrite strings', () => t('echo "&&" && Write-Host "&& &&" && "&&"', 'echo "&&" ; Write-Host "&& &&" ; "&&"'));
385+
});
347386
});

src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ suite('TreeSitterCommandParser', () => {
2222
let parser: TreeSitterCommandParser;
2323

2424
setup(() => {
25-
const logService = new NullLogService();
26-
const fileService = store.add(new FileService(logService));
25+
const fileService = store.add(new FileService(new NullLogService()));
2726
const fileSystemProvider = new TestIPCFileSystemProvider();
2827
store.add(fileService.registerProvider(Schemas.file, fileSystemProvider));
2928

0 commit comments

Comments
 (0)