Skip to content
This repository was archived by the owner on Mar 3, 2026. It is now read-only.

Commit 32a8962

Browse files
committed
♻️ refactor: update command building to use array for PHPStan service
1 parent d8041a6 commit 32a8962

4 files changed

Lines changed: 123 additions & 76 deletions

File tree

src/services/phpstan-service.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,32 +55,35 @@ export class PhpstanService extends BasePhpToolService {
5555
/**
5656
* Build PHPStan command with configuration
5757
*/
58-
protected buildToolCommand(filePath: string): string {
59-
let command = 'phpstan analyze --error-format=json --no-progress';
58+
protected buildToolCommand(filePath: string): string[] {
59+
const command = ['phpstan', 'analyze', '--error-format=json', '--no-progress'];
6060

6161
// Use config file if specified, otherwise use individual settings
6262
if (this.config.configPath) {
6363
// Use specified config file
64-
command += ` -c "${this.config.configPath}"`;
64+
command.push('-c', this.config.configPath);
6565
} else {
6666
// Try to auto-detect common PHPStan config files
6767
const autoConfigPath = this.detectPhpstanConfig();
6868
if (autoConfigPath) {
6969
console.log(`PHPStan: Auto-detected config file: ${autoConfigPath}`);
70-
command += ` -c "${autoConfigPath}"`;
70+
command.push('-c', autoConfigPath);
7171
} else {
7272
// Use individual settings when no config file is found
73-
command += ` --level=${this.config.level}`;
73+
command.push(`--level=${this.config.level}`);
7474

7575
// Add exclude paths
7676
if (this.config.excludePaths && this.config.excludePaths.length > 0) {
7777
for (const excludePath of this.config.excludePaths) {
78-
command += ` --exclude="${excludePath}"`;
78+
command.push(`--exclude=${excludePath}`);
7979
}
8080
}
8181
}
8282
}
8383

84+
// Add the file to analyze
85+
command.push(filePath);
86+
8487
return command;
8588
}
8689

src/shared/services/base-php-tool-service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export abstract class BasePhpToolService {
6565
/**
6666
* Build the command to execute the tool
6767
*/
68-
protected abstract buildToolCommand(relativePath: string): string;
68+
protected abstract buildToolCommand(relativePath: string): string[];
6969

7070
/**
7171
* Process the tool output and convert to diagnostics
@@ -193,7 +193,7 @@ export abstract class BasePhpToolService {
193193
try {
194194
// PHPStan returns exit code 1 when errors are found, which is normal
195195
const allowedExitCodes = this.toolName === 'phpstan' ? [0, 1] : [0];
196-
console.log(`Executing ${this.displayName} command: ${toolCommand}`);
196+
console.log(`Executing ${this.displayName} command: ${toolCommand.join(' ')}`);
197197

198198
const output = DdevUtils.execDdev(toolCommand, workspaceFolder.uri.fsPath, allowedExitCodes);
199199
console.log(`${this.displayName} output length: ${output.length} characters`);

src/shared/utils/ddev-utils.ts

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
*/
2020

2121
import * as vscode from 'vscode';
22-
import { execSync } from 'child_process';
22+
import { spawnSync } from 'child_process';
23+
import * as fs from 'fs';
24+
import * as path from 'path';
2325

2426
/**
2527
* DDEV project validation result
@@ -46,11 +48,8 @@ export class DdevUtils {
4648
*/
4749
public static hasDdevProject(workspacePath: string): boolean {
4850
try {
49-
const ddevConfig = execSync('test -f .ddev/config.yaml && echo "exists"', {
50-
cwd: workspacePath,
51-
encoding: 'utf-8'
52-
});
53-
return ddevConfig.includes('exists');
51+
const configPath = path.join(workspacePath, '.ddev', 'config.yaml');
52+
return fs.existsSync(configPath);
5453
} catch (error) {
5554
return false;
5655
}
@@ -64,11 +63,11 @@ export class DdevUtils {
6463
*/
6564
public static isDdevRunning(workspacePath: string): boolean {
6665
try {
67-
execSync('ddev exec echo "test"', {
66+
const result = spawnSync('ddev', ['exec', 'echo', 'test'], {
6867
cwd: workspacePath,
69-
stdio: 'ignore'
68+
encoding: 'utf-8'
7069
});
71-
return true;
70+
return result.status === 0;
7271
} catch (error) {
7372
return false;
7473
}
@@ -83,7 +82,7 @@ export class DdevUtils {
8382
*/
8483
public static isToolInstalled(toolName: string, workspacePath: string): boolean {
8584
try {
86-
this.execDdev(`${toolName} --version`, workspacePath);
85+
this.execDdev([toolName, '--version'], workspacePath);
8786
return true;
8887
} catch (error) {
8988
return false;
@@ -109,7 +108,7 @@ export class DdevUtils {
109108

110109
// Try to run the tool
111110
try {
112-
this.execDdev(`${toolName} --version`, workspacePath);
111+
this.execDdev([toolName, '--version'], workspacePath);
113112

114113
return {
115114
isValid: true
@@ -174,37 +173,62 @@ export class DdevUtils {
174173
/**
175174
* Execute a command in the DDEV container
176175
*
177-
* @param command Command to execute
176+
* @param command Command to execute (as array of strings)
178177
* @param workspacePath Path to the workspace
179178
* @param allowedExitCodes Array of exit codes that should not throw (default: [0])
180179
* @returns Output of the command
181180
* @throws Error if the command fails with disallowed exit code
182181
*/
183-
public static execDdev(command: string, workspacePath: string, allowedExitCodes: number[] = [0]): string {
182+
public static execDdev(command: string[], workspacePath: string, allowedExitCodes: number[] = [0]): string {
184183
try {
185-
// Wrap command in bash -c to allow setting environment variables (specifically disabling Xdebug)
186-
// This fixes issues where Xdebug causes the command to hang or run slowly
187-
// We use single quotes for the bash command and escape any single quotes in the original command
188-
const escapedCommand = command.replace(/'/g, "'\\''");
189-
return execSync(`ddev exec bash -c 'XDEBUG_MODE=off ${escapedCommand}'`, {
184+
// Use spawnSync to avoid shell injection and safely pass arguments
185+
// We use 'env' to set environment variables inside the container
186+
const args = ['exec', 'env', 'XDEBUG_MODE=off', ...command];
187+
188+
const result = spawnSync('ddev', args, {
190189
cwd: workspacePath,
191190
encoding: 'utf-8'
192191
});
193-
} catch (error: any) {
194-
// Check if this is an acceptable exit code (e.g., PHPStan returns 1 when errors are found)
195-
if (error.status && allowedExitCodes.includes(error.status)) {
192+
193+
if (result.error) {
194+
throw result.error;
195+
}
196+
197+
// Check if this is an acceptable exit code
198+
if (result.status !== null && allowedExitCodes.includes(result.status)) {
196199
// Return stdout even if exit code is non-zero but allowed
197-
console.log(`Command exited with allowed code ${error.status}: ${command}`);
198-
return error.stdout || '';
200+
if (result.status !== 0) {
201+
console.log(`Command exited with allowed code ${result.status}: ${command.join(' ')}`);
202+
}
203+
return result.stdout || '';
204+
}
205+
206+
if (result.status !== 0) {
207+
// Enhance error message with more details
208+
const enhancedError = new Error(result.stderr || 'Command execution failed');
209+
enhancedError.name = 'CommandError';
210+
(enhancedError as any).status = result.status;
211+
(enhancedError as any).stderr = result.stderr;
212+
(enhancedError as any).stdout = result.stdout;
213+
(enhancedError as any).command = `ddev exec ${command.join(' ')}`;
214+
(enhancedError as any).workspacePath = workspacePath;
215+
throw enhancedError;
216+
}
217+
218+
return result.stdout;
219+
} catch (error: any) {
220+
// If error was already thrown above, rethrow it
221+
if (error.name === 'CommandError') {
222+
throw error;
199223
}
200224

201-
// Enhance error message with more details
225+
// Handle unexpected errors
202226
const enhancedError = new Error(error.message || 'Command execution failed');
203227
enhancedError.name = error.name || 'CommandError';
204228
(enhancedError as any).status = error.status;
205229
(enhancedError as any).stderr = error.stderr;
206230
(enhancedError as any).stdout = error.stdout;
207-
(enhancedError as any).command = `ddev exec ${command}`;
231+
(enhancedError as any).command = `ddev exec ${command.join(' ')}`;
208232
(enhancedError as any).workspacePath = workspacePath;
209233

210234
throw enhancedError;

src/test/ddev-utils.test.ts

Lines changed: 63 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -21,73 +21,85 @@ import * as assert from 'assert';
2121
import * as sinon from 'sinon';
2222
import { afterEach, beforeEach } from 'mocha';
2323
import { DdevUtils } from '../shared/utils/ddev-utils';
24+
import * as cp from 'child_process';
25+
import * as fs from 'fs';
2426

2527
suite('DdevUtils Test Suite', () => {
2628
let sandbox: sinon.SinonSandbox;
27-
let execSyncStub: sinon.SinonStub;
29+
let spawnSyncStub: sinon.SinonStub;
30+
let existsSyncStub: sinon.SinonStub;
2831

2932
beforeEach(() => {
3033
sandbox = sinon.createSandbox();
31-
// Mock the entire child_process module
32-
const childProcess = require('child_process');
33-
execSyncStub = sandbox.stub(childProcess, 'execSync');
34+
// Use require to get the module to ensure we can stub it
35+
const cp = require('child_process');
36+
const fs = require('fs');
37+
38+
// Try to stub, but handle if it fails (basic check)
39+
try {
40+
spawnSyncStub = sandbox.stub(cp, 'spawnSync');
41+
existsSyncStub = sandbox.stub(fs, 'existsSync');
42+
} catch (e) {
43+
console.error('Failed to stub modules:', e);
44+
throw e;
45+
}
3446
});
3547

3648
afterEach(() => {
3749
sandbox.restore();
3850
});
3951

4052
test('hasDdevProject returns true when .ddev/config.yaml exists', () => {
41-
execSyncStub.returns('exists\n');
53+
existsSyncStub.returns(true);
4254

4355
const result = DdevUtils.hasDdevProject('/test/workspace');
4456

4557
assert.strictEqual(result, true);
46-
assert.strictEqual(execSyncStub.calledOnce, true);
58+
assert.strictEqual(existsSyncStub.calledOnce, true);
4759
});
4860

4961
test('hasDdevProject returns false when .ddev/config.yaml does not exist', () => {
50-
execSyncStub.throws(new Error('File not found'));
62+
existsSyncStub.returns(false);
5163

5264
const result = DdevUtils.hasDdevProject('/test/workspace');
5365

5466
assert.strictEqual(result, false);
5567
});
5668

5769
test('isDdevRunning returns true when DDEV container is running', () => {
58-
execSyncStub.returns('');
70+
spawnSyncStub.returns({ status: 0, stdout: 'test\n' });
5971

6072
const result = DdevUtils.isDdevRunning('/test/workspace');
6173

6274
assert.strictEqual(result, true);
6375
});
6476

6577
test('isDdevRunning returns false when DDEV container is not running', () => {
66-
execSyncStub.throws(new Error('Container not running'));
78+
spawnSyncStub.returns({ status: 1, stderr: 'Container not running' });
6779

6880
const result = DdevUtils.isDdevRunning('/test/workspace');
6981

7082
assert.strictEqual(result, false);
7183
});
7284

7385
test('isToolInstalled returns true when tool is available', () => {
74-
execSyncStub.returns('PHPStan 1.10.0\n');
86+
spawnSyncStub.returns({ status: 0, stdout: 'PHPStan 1.10.0\n' });
7587

7688
const result = DdevUtils.isToolInstalled('phpstan', '/test/workspace');
7789

7890
assert.strictEqual(result, true);
7991
});
8092

8193
test('isToolInstalled returns false when tool is not available', () => {
82-
execSyncStub.throws(new Error('Command not found'));
94+
spawnSyncStub.returns({ status: 1, stderr: 'Command not found' });
8395

8496
const result = DdevUtils.isToolInstalled('phpstan', '/test/workspace');
8597

8698
assert.strictEqual(result, false);
8799
});
88100

89101
test('validateDdevTool returns invalid result when no DDEV project found', () => {
90-
execSyncStub.throws(new Error('File not found'));
102+
existsSyncStub.returns(false);
91103

92104
const result = DdevUtils.validateDdevTool('phpstan', '/test/workspace');
93105

@@ -97,10 +109,10 @@ suite('DdevUtils Test Suite', () => {
97109
});
98110

99111
test('validateDdevTool returns valid result when tool is available', () => {
100-
// First call (hasDdevProject) succeeds
101-
execSyncStub.onFirstCall().returns('exists\n');
102-
// Second call (tool version check) succeeds
103-
execSyncStub.onSecondCall().returns('PHPStan 1.10.0\n');
112+
// hasDdevProject
113+
existsSyncStub.returns(true);
114+
// execDdev (tool check)
115+
spawnSyncStub.returns({ status: 0, stdout: 'PHPStan 1.10.0\n' });
104116

105117
const result = DdevUtils.validateDdevTool('phpstan', '/test/workspace');
106118

@@ -109,12 +121,12 @@ suite('DdevUtils Test Suite', () => {
109121
});
110122

111123
test('validateDdevTool returns error message for DDEV issues', () => {
112-
// First call (hasDdevProject) succeeds
113-
execSyncStub.onFirstCall().returns('exists\n');
114-
// Second call (tool version check) fails
115-
execSyncStub.onSecondCall().throws(new Error('Container not running'));
116-
// Third call (isDdevRunning) fails
117-
execSyncStub.onThirdCall().throws(new Error('DDEV not running'));
124+
// hasDdevProject
125+
existsSyncStub.returns(true);
126+
// execDdev (tool check) fails
127+
spawnSyncStub.onFirstCall().returns({ status: 1, stderr: 'Container not running' });
128+
// isDdevRunning fails
129+
spawnSyncStub.onSecondCall().returns({ status: 1, stderr: 'DDEV not running' });
118130

119131
const result = DdevUtils.validateDdevTool('phpstan', '/test/workspace');
120132

@@ -124,12 +136,12 @@ suite('DdevUtils Test Suite', () => {
124136
});
125137

126138
test('validateDdevTool returns tool not found message when tool is missing', () => {
127-
// First call (hasDdevProject) succeeds
128-
execSyncStub.onFirstCall().returns('exists\n');
129-
// Second call (tool version check) fails
130-
execSyncStub.onSecondCall().throws(new Error('Command not found'));
131-
// Third call (isDdevRunning) succeeds
132-
execSyncStub.onThirdCall().returns('');
139+
// hasDdevProject
140+
existsSyncStub.returns(true);
141+
// execDdev (tool check) fails
142+
spawnSyncStub.onFirstCall().returns({ status: 127, stderr: 'Command not found' });
143+
// isDdevRunning succeeds
144+
spawnSyncStub.onSecondCall().returns({ status: 0, stdout: 'test\n' });
133145

134146
const result = DdevUtils.validateDdevTool('phpstan', '/test/workspace');
135147

@@ -139,27 +151,35 @@ suite('DdevUtils Test Suite', () => {
139151
assert.ok(result.userMessage?.includes('phpstan/phpstan'));
140152
});
141153

142-
test('execDdev wraps command with XDEBUG_MODE=off', () => {
143-
execSyncStub.returns('output');
154+
test('execDdev passes args array correctly', () => {
155+
spawnSyncStub.returns({ status: 0, stdout: 'output' });
144156

145-
const result = DdevUtils.execDdev('phpstan analyze', '/test/workspace');
157+
const result = DdevUtils.execDdev(['phpstan', 'analyze'], '/test/workspace');
146158

147159
assert.strictEqual(result, 'output');
148-
assert.strictEqual(execSyncStub.calledOnce, true);
149-
const callArgs = execSyncStub.firstCall.args;
150-
assert.ok(callArgs[0].includes("XDEBUG_MODE=off"));
151-
assert.ok(callArgs[0].includes("bash -c"));
152-
assert.ok(callArgs[0].includes("'XDEBUG_MODE=off phpstan analyze'"));
160+
assert.strictEqual(spawnSyncStub.calledOnce, true);
161+
const callArgs = spawnSyncStub.firstCall.args;
162+
assert.strictEqual(callArgs[0], 'ddev');
163+
assert.deepStrictEqual(callArgs[1], ['exec', 'env', 'XDEBUG_MODE=off', 'phpstan', 'analyze']);
164+
assert.strictEqual(callArgs[2].cwd, '/test/workspace');
153165
});
154166

155-
test('execDdev escapes single quotes in command', () => {
156-
execSyncStub.returns('output');
167+
test('execDdev throws on non-zero exit code', () => {
168+
spawnSyncStub.returns({ status: 1, stderr: 'error', stdout: '' });
157169

158-
const result = DdevUtils.execDdev("echo 'hello'", '/test/workspace');
170+
assert.throws(() => {
171+
DdevUtils.execDdev(['ls'], '/test/workspace');
172+
}, (err: any) => {
173+
return err.status === 1 && err.stderr === 'error';
174+
});
175+
});
159176

160-
assert.strictEqual(result, 'output');
161-
const callArgs = execSyncStub.firstCall.args;
162-
// Should be: ddev exec bash -c 'XDEBUG_MODE=off echo '\''hello'\'''
163-
assert.ok(callArgs[0].includes("'XDEBUG_MODE=off echo '\\''hello'\\'''"));
177+
test('execDdev returns stdout on allowed non-zero exit code', () => {
178+
spawnSyncStub.returns({ status: 1, stdout: 'partial output' });
179+
180+
const result = DdevUtils.execDdev(['ls'], '/test/workspace', [0, 1]);
181+
182+
assert.strictEqual(result, 'partial output');
164183
});
165184
});
185+

0 commit comments

Comments
 (0)