Skip to content

Commit

Permalink
Made changes more conservative
Browse files Browse the repository at this point in the history
  • Loading branch information
d-lebed committed Nov 28, 2024
1 parent 2136991 commit c8a1869
Show file tree
Hide file tree
Showing 15 changed files with 316 additions and 350 deletions.
2 changes: 1 addition & 1 deletion vscode/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ function collectClientOptions(
});
}

outputChannel.info(
outputChannel.debug(
`Document Selector Paths: ${JSON.stringify(documentSelector)}`,
);

Expand Down
43 changes: 43 additions & 0 deletions vscode/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { promisify } from "util";

import * as vscode from "vscode";
import { State } from "vscode-languageclient";
import { Executable } from "vscode-languageclient/node";

export enum Command {
Start = "rubyLsp.start",
Expand Down Expand Up @@ -70,6 +71,22 @@ export interface PathConverterInterface {
toRemoteUri: (localUri: vscode.Uri) => vscode.Uri;
}

export class PathConverter implements PathConverterInterface {
readonly pathMapping: [string, string][] = [];

toRemotePath(path: string) {
return path;
}

toLocalPath(path: string) {
return path;
}

toRemoteUri(localUri: vscode.Uri) {
return localUri;
}
}

// Event emitter used to signal that the language status items need to be refreshed
export const STATUS_EMITTER = new vscode.EventEmitter<
WorkspaceInterface | undefined
Expand Down Expand Up @@ -152,3 +169,29 @@ export function featureEnabled(feature: keyof typeof FEATURE_FLAGS): boolean {
// If that number is below the percentage, then the feature is enabled for this user
return hashNum < percentage;
}

export function parseCommand(commandString: string): Executable {
// Regular expression to split arguments while respecting quotes
const regex = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g;

const parts =
commandString.match(regex)?.map((arg) => {
// Remove surrounding quotes, if any
return arg.replace(/^['"]|['"]$/g, "");
}) ?? [];

// Extract environment variables
const env: Record<string, string> = {};
while (parts[0] && parts[0].includes("=")) {
const [key, value] = parts.shift()?.split("=") ?? [];
if (key) {
env[key] = value || "";
}
}

// The first part is the command, the rest are arguments
const command = parts.shift() || "";
const args = parts;

return { command, args, options: { env } };
}
54 changes: 36 additions & 18 deletions vscode/src/ruby.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
/* eslint-disable no-process-env */
import path from "path";
import os from "os";
import { ExecOptions } from "child_process";

import * as vscode from "vscode";
import { Executable } from "vscode-languageclient/node";

import { asyncExec, PathConverterInterface, RubyInterface } from "./common";
import { Executable, ExecutableOptions } from "vscode-languageclient/node";

import {
asyncExec,
parseCommand,
PathConverter,
PathConverterInterface,
RubyInterface,
} from "./common";
import { WorkspaceChannel } from "./workspaceChannel";
import { Shadowenv } from "./ruby/shadowenv";
import { Chruby } from "./ruby/chruby";
import { PathConverter, VersionManager } from "./ruby/versionManager";
import { VersionManager } from "./ruby/versionManager";
import { Mise } from "./ruby/mise";
import { RubyInstaller } from "./ruby/rubyInstaller";
import { Rbenv } from "./ruby/rbenv";
Expand Down Expand Up @@ -51,9 +56,9 @@ export class Ruby implements RubyInterface {

private readonly shell = process.env.SHELL?.replace(/(\s+)/g, "\\$1");
private _env: NodeJS.ProcessEnv = {};
private _manager?: VersionManager;
private _pathConverter: PathConverterInterface = new PathConverter();
private _error = false;
private _pathConverter: PathConverterInterface;
private _wrapCommand: (executable: Executable) => Executable;
private readonly context: vscode.ExtensionContext;
private readonly customBundleGemfile?: string;
private readonly outputChannel: WorkspaceChannel;
Expand All @@ -69,6 +74,8 @@ export class Ruby implements RubyInterface {
this.workspaceFolder = workspaceFolder;
this.outputChannel = outputChannel;
this.telemetry = telemetry;
this._pathConverter = new PathConverter();
this._wrapCommand = (executable: Executable) => executable;

const customBundleGemfile: string = vscode.workspace
.getConfiguration("rubyLsp")
Expand Down Expand Up @@ -101,10 +108,6 @@ export class Ruby implements RubyInterface {
return this._pathConverter;
}

set pathConverter(pathConverter: PathConverterInterface) {
this._pathConverter = pathConverter;
}

get env() {
return this._env;
}
Expand Down Expand Up @@ -185,12 +188,22 @@ export class Ruby implements RubyInterface {
}
}

runActivatedScript(command: string, options: ExecOptions = {}) {
return this._manager!.runActivatedScript(command, options);
runActivatedScript(script: string, options: ExecutableOptions = {}) {
const parsedExecutable = parseCommand(script);
const executable = this.activateExecutable({
...parsedExecutable,
options,
});
const command = [executable.command, ...(executable.args || [])].join(" ");

return asyncExec(command, {
cwd: this.workspaceFolder.uri.fsPath || executable.options?.cwd,
env: { ...process.env, ...executable.options?.env },
});
}

activateExecutable(executable: Executable) {
return this._manager!.activateExecutable(executable);
return this._wrapCommand(executable);
}

async manuallySelectRuby() {
Expand Down Expand Up @@ -245,20 +258,25 @@ export class Ruby implements RubyInterface {
}

private async runActivation(manager: VersionManager) {
const { env, version, yjit, gemPath } = await manager.activate();
const { env, version, yjit, gemPath, pathConverter, wrapCommand } =
await manager.activate();
const [major, minor, _patch] = version.split(".").map(Number);

this.sanitizeEnvironment(env);

this.pathConverter = await manager.buildPathConverter(this.workspaceFolder);

// We need to set the process environment too to make other extensions such as Sorbet find the right Ruby paths
process.env = env;
this._env = env;
this._manager = manager;
this.rubyVersion = version;
this.yjitEnabled = (yjit && major > 3) || (major === 3 && minor >= 2);
this.gemPath.push(...gemPath);

if (pathConverter) {
this._pathConverter = pathConverter;
}
if (wrapCommand) {
this._wrapCommand = wrapCommand;
}
}

// Fetch information related to the Ruby version. This can only be invoked after activation, so that `rubyVersion` is
Expand Down
159 changes: 127 additions & 32 deletions vscode/src/ruby/compose.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint-disable no-process-env */
import { ExecOptions } from "child_process";
import path from "path";
import os from "os";
import { StringDecoder } from "string_decoder";
import { ExecOptions } from "child_process";

import * as vscode from "vscode";
import { Executable } from "vscode-languageclient/node";
Expand All @@ -10,8 +12,13 @@ import {
ContainerPathConverter,
fetchPathMapping,
} from "../docker";
import { parseCommand, spawn } from "../common";

import { VersionManager, ActivationResult } from "./versionManager";
import {
VersionManager,
ActivationResult,
ACTIVATION_SEPARATOR,
} from "./versionManager";

// Compose
//
Expand All @@ -24,52 +31,63 @@ export class Compose extends VersionManager {
async activate(): Promise<ActivationResult> {
await this.ensureConfigured();

const parsedResult = await this.runEnvActivationScript(
`${this.composeRunCommand()} ${this.composeServiceName()} ruby`,
const rubyCommand = `${this.composeRunCommand()} ${this.composeServiceName()} ruby -W0 -rjson`;
const { stderr: output } = await this.runRubyCode(
rubyCommand,
this.activationScript,
);

this.outputChannel.debug(`Activation output: ${output}`);

const activationContent = new RegExp(
`${ACTIVATION_SEPARATOR}(.*)${ACTIVATION_SEPARATOR}`,
).exec(output);

const parsedResult = this.parseWithErrorHandling(activationContent![1]);
const pathConverter = await this.buildPathConverter();

const wrapCommand = (executable: Executable) => {
const composeCommad = parseCommand(
`${this.composeRunCommand()} ${this.composeServiceName()}`,
);

const command = {
command: composeCommad.command,
args: [
...(composeCommad.args ?? []),
executable.command,
...(executable.args ?? []),
],
options: {
...executable.options,
env: {
...executable.options?.env,
...composeCommad.options?.env,
},
},
};

return command;
};

return {
env: { ...process.env },
yjit: parsedResult.yjit,
version: parsedResult.version,
gemPath: parsedResult.gemPath,
pathConverter,
wrapCommand,
};
}

runActivatedScript(command: string, options: ExecOptions = {}) {
return this.runScript(
`${this.composeRunCommand()} ${this.composeServiceName()} ${command}`,
options,
);
}

activateExecutable(executable: Executable) {
const composeCommand = this.parseCommand(
`${this.composeRunCommand()} ${this.composeServiceName()}`,
);

return {
command: composeCommand.command,
args: [
...composeCommand.args,
executable.command,
...(executable.args || []),
],
options: {
...executable.options,
env: { ...(executable.options?.env || {}), ...composeCommand.env },
},
};
}

async buildPathConverter(workspaceFolder: vscode.WorkspaceFolder) {
protected async buildPathConverter() {
const pathMapping = fetchPathMapping(
this.composeConfig,
this.composeServiceName(),
);

const stats = Object.entries(pathMapping).map(([local, remote]) => {
const absolute = path.resolve(workspaceFolder.uri.fsPath, local);
const absolute = path.resolve(this.workspaceFolder.uri.fsPath, local);
return vscode.workspace.fs.stat(vscode.Uri.file(absolute)).then(
(stat) => ({ stat, local, remote, absolute }),
() => ({ stat: undefined, local, remote, absolute }),
Expand Down Expand Up @@ -162,6 +180,83 @@ export class Compose extends VersionManager {
});
}

protected runRubyCode(
rubyCommand: string,
code: string,
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
this.outputChannel.info(
`Ruby \`${rubyCommand}\` running Ruby code: \`${code}\``,
);

const {
command,
args,
options: { env } = { env: {} },
} = parseCommand(rubyCommand);
const ruby = spawn(command, args, this.execOptions({ env }));

let stdout = "";
let stderr = "";

const stdoutDecoder = new StringDecoder("utf-8");
const stderrDecoder = new StringDecoder("utf-8");

ruby.stdout.on("data", (data) => {
stdout += stdoutDecoder.write(data);

if (stdout.includes("END_OF_RUBY_CODE_OUTPUT")) {
stdout = stdout.replace(/END_OF_RUBY_CODE_OUTPUT.*/s, "");
resolve({ stdout, stderr });
}
});
ruby.stderr.on("data", (data) => {
stderr += stderrDecoder.write(data);
});
ruby.on("error", (error) => {
reject(error);
});
ruby.on("close", (status) => {
if (status) {
reject(new Error(`Process exited with status ${status}: ${stderr}`));
} else {
resolve({ stdout, stderr });
}
});

const script = [
"begin",
...code.split("\n").map((line) => ` ${line}`),
"ensure",
' puts "END_OF_RUBY_CODE_OUTPUT"',
"end",
].join("\n");

this.outputChannel.info(`Running Ruby code:\n${script}`);

ruby.stdin.write(script);
ruby.stdin.end();
});
}

protected execOptions(options: ExecOptions = {}): ExecOptions {
let shell: string | undefined;

// If the user has configured a default shell, we use that one since they are probably sourcing their version
// manager scripts in that shell's configuration files. On Windows, we never set the shell no matter what to ensure
// that activation runs on `cmd.exe` and not PowerShell, which avoids complex quoting and escaping issues.
if (vscode.env.shell.length > 0 && os.platform() !== "win32") {
shell = vscode.env.shell;
}

return {
cwd: this.bundleUri.fsPath,
shell,
...options,
env: { ...process.env, ...options.env },
};
}

private async getComposeConfig(): Promise<ComposeConfig> {
try {
const { stdout, stderr: _stderr } = await this.runScript(
Expand Down
Loading

0 comments on commit c8a1869

Please sign in to comment.