Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Change log

## Version 1.2.5 (September 27, 2026)

* [358](https://github.com/pgourlain/vscode_erlang/issues/358) : Launch (Run / Debug) broken on Linux and macOS since 1.2.4
- Since `erl` is spawned without a shell (`erlang.useShell` = `auto`), the launch.json `arguments` string was handed to `erl` as a single word (e.g. `-s myapp start`), which `erl` silently ignores, and a missing `arguments` became the literal word `undefined`, which crashed the node at boot. `arguments` and `erlang.erlangArgs` items are now split into words like `/bin/sh` does (quotes and backslash escapes included), and an empty value adds nothing to the command line

---

## Version 1.2.4 (September 26, 2026)

* [356](https://github.com/pgourlain/vscode_erlang/issues/356) : Header files (`.hrl`) treated as modules
Expand Down
2 changes: 1 addition & 1 deletion lib/ErlangShellDebugger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export class ErlangShellForDebugging extends GenericShell {
debugStartArgs.push("-s", "vscode_connection", "start");

var argsFile = this.createArgsFilev1(startDir, launchArguments.noDebug, launchArguments.addEbinsToCodepath, launchArguments.verbose);
var processArgs = debugStartArgs.concat(argsFile).concat([launchArguments.arguments]);
var processArgs = debugStartArgs.concat(argsFile).concat(this.commandLineArgs(launchArguments.arguments));
this.started = true;
var result = this.LaunchProcess(erlPath, startDir, processArgs, !launchArguments.verbose);
return result;
Expand Down
69 changes: 69 additions & 0 deletions lib/GenericShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,71 @@ export function quoteForShell(value: string, useShell: boolean): string {
return useShell ? `"${value}"` : value;
}

// Splits a free-form command line (launch.json "arguments", erlang.erlangArgs
// items) into argv words the way /bin/sh would: whitespace separates words,
// '...' is literal, "..." allows \" \\ \$ \` escapes, a bare \ escapes the
// next character. Needed when useShell is false (#358): spawn would otherwise
// hand "-s myapp start" to erl as one word, which erl silently ignores.
export function splitCommandLine(value: string): string[] {
const words: string[] = [];
let current = '';
let inWord = false;
let i = 0;
while (i < value.length) {
const c = value[i];
if (c === "'") {
const end = value.indexOf("'", i + 1);
const stop = end === -1 ? value.length : end;
current += value.substring(i + 1, stop);
inWord = true;
i = stop + 1;
} else if (c === '"') {
inWord = true;
i++;
while (i < value.length && value[i] !== '"') {
if (value[i] === '\\' && i + 1 < value.length && '"\\$`'.indexOf(value[i + 1]) !== -1) {
i++;
}
current += value[i];
i++;
}
i++;
} else if (c === '\\' && i + 1 < value.length) {
current += value[i + 1];
inWord = true;
i += 2;
} else if (/\s/.test(c)) {
if (inWord) {
words.push(current);
current = '';
inWord = false;
}
i++;
} else {
current += c;
inWord = true;
i++;
}
}
if (inWord) {
words.push(current);
}
return words;
}

// argv words for a user-supplied command line. Through a shell it is passed
// as-is (the shell splits it); otherwise it is split here. A missing or blank
// value yields no word at all: without a shell, spawn would pass undefined as
// the literal word "undefined" and "" as an empty word, and erl appends either
// to the preceding flag (-eval ... / -s vscode_connection start), which kills
// the node at boot (#358).
export function commandLineArgs(value: string | undefined, useShell: boolean): string[] {
if (value === undefined || value === null || value.trim() === '') {
return [];
}
return useShell ? [value] : splitCommandLine(value);
}

export class GenericShell extends EventEmitter {
protected childProcess: ChildProcess;
protected logOutput: ILogOutput;
Expand Down Expand Up @@ -91,6 +156,10 @@ export class GenericShell extends EventEmitter {
return quoteForShell(value, this.useShell);
}

protected commandLineArgs(value: string | undefined): string[] {
return commandLineArgs(value, this.useShell);
}

protected RunProcess(processName, startDir: string, args: string[]): Promise<number> {

return new Promise<number>((resolve, reject) => {
Expand Down
6 changes: 4 additions & 2 deletions lib/lsp/ErlangShellLSP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export class ErlangShellLSP extends GenericShell {
}
// Use special command line arguments
if (this.erlangArgs) {
debugStartArgs = debugStartArgs.concat(this.erlangArgs)
this.erlangArgs.forEach(arg => {
debugStartArgs = debugStartArgs.concat(this.commandLineArgs(arg));
});
}
debugStartArgs.push(
"-noshell",
Expand All @@ -50,7 +52,7 @@ export class ErlangShellLSP extends GenericShell {
"-s", "int",
"-vscode_port", listen_port.toString(),
"-s", "vscode_lsp_entry", "start", listen_port.toString());
var processArgs = debugStartArgs.concat([args]);
var processArgs = debugStartArgs.concat(this.commandLineArgs(args));

var result = this.LaunchProcess("erl", startDir, processArgs);
return result;
Expand Down
9 changes: 9 additions & 0 deletions test/test-suite/erlangShellLSP.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,12 @@ suite('ErlangShellLSP distribution hardening', () => {
assert.ok(!args.includes('ERL_EPMD_ADDRESS'), 'no epmd binding when not distributed');
});
});

suite('ErlangShellLSP argv', () => {
test('blank extra args add no empty word to the erl command line (#358)', async () => {
const { args } = await captureStartArgs(false);
assert.ok(!args.includes(''), 'no empty argv word');
assert.ok(!args.includes(undefined as any), 'no undefined argv word');
assert.strictEqual(args[args.length - 1], '12345', '-s vscode_lsp_entry start <port> ends the command line');
});
});
37 changes: 36 additions & 1 deletion test/test-suite/useShell.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as assert from 'assert';
import { quoteForShell } from '../../lib/GenericShell';
import { quoteForShell, splitCommandLine, commandLineArgs } from '../../lib/GenericShell';
import { resolveUseShell } from '../../lib/ErlangConfigurationProvider';

suite('erlang.useShell resolution', () => {
Expand Down Expand Up @@ -27,3 +27,38 @@ suite('quoteForShell', () => {
assert.strictEqual(quoteForShell('{127,0,0,1}', false), '{127,0,0,1}');
});
});

suite('splitCommandLine', () => {
test('splits on whitespace like /bin/sh', () => {
assert.deepStrictEqual(splitCommandLine(' -s myapp start '), ['-s', 'myapp', 'start']);
});

test('keeps quoted words together and strips the quotes', () => {
assert.deepStrictEqual(splitCommandLine(`-eval 'application:start(x), ok.' -pa "/a b/ebin"`),
['-eval', 'application:start(x), ok.', '-pa', '/a b/ebin']);
});

test('handles escapes inside double quotes and bare backslashes', () => {
assert.deepStrictEqual(splitCommandLine(`-eval "io:format(\\"hi\\")" a\\ b`),
['-eval', 'io:format("hi")', 'a b']);
});

test('keeps an explicitly quoted empty word', () => {
assert.deepStrictEqual(splitCommandLine(`-x ''`), ['-x', '']);
});
});

suite('commandLineArgs', () => {
test('missing or blank value yields no argv word (#358)', () => {
for (const useShell of [true, false]) {
assert.deepStrictEqual(commandLineArgs(undefined, useShell), []);
assert.deepStrictEqual(commandLineArgs('', useShell), []);
assert.deepStrictEqual(commandLineArgs(' ', useShell), []);
}
});

test('passes the value as-is through a shell, splits it otherwise', () => {
assert.deepStrictEqual(commandLineArgs('-s myapp', true), ['-s myapp']);
assert.deepStrictEqual(commandLineArgs('-s myapp', false), ['-s', 'myapp']);
});
});
Loading