Skip to content

Commit

Permalink
Add docker compose as a version manager
Browse files Browse the repository at this point in the history
  • Loading branch information
d-lebed committed Nov 26, 2024
1 parent 30f8547 commit 272316f
Show file tree
Hide file tree
Showing 20 changed files with 1,058 additions and 126 deletions.
9 changes: 9 additions & 0 deletions vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@
"rvm",
"shadowenv",
"mise",
"compose",
"custom"
],
"default": "auto"
Expand All @@ -347,6 +348,14 @@
"chrubyRubies": {
"description": "An array of extra directories to search for Ruby installations when using chruby. Equivalent to the RUBIES environment variable",
"type": "array"
},
"composeService": {
"description": "The name of the service in the compose file to use to start the Ruby LSP server",
"type": "string"
},
"composeCustomCommand": {
"description": "A shell command to start the ruby LSP server using compose. This overrides the composeService setting",
"type": "string"
}
},
"default": {
Expand Down
165 changes: 157 additions & 8 deletions vscode/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
SUPPORTED_LANGUAGE_IDS,
FEATURE_FLAGS,
featureEnabled,
PathConverterInterface,
} from "./common";
import { Ruby } from "./ruby";
import { WorkspaceChannel } from "./workspaceChannel";
Expand All @@ -60,7 +61,7 @@ function enabledFeatureFlags(): Record<string, boolean> {
// Get the executables to start the server based on the user's configuration
function getLspExecutables(
workspaceFolder: vscode.WorkspaceFolder,
env: NodeJS.ProcessEnv,
ruby: Ruby,
): ServerOptions {
let run: Executable;
let debug: Executable;
Expand All @@ -74,8 +75,8 @@ function getLspExecutables(
const executableOptions: ExecutableOptions = {
cwd: workspaceFolder.uri.fsPath,
env: bypassTypechecker
? { ...env, RUBY_LSP_BYPASS_TYPECHECKER: "true" }
: env,
? { ...ruby.env, RUBY_LSP_BYPASS_TYPECHECKER: "true" }
: ruby.env,
shell: true,
};

Expand Down Expand Up @@ -129,6 +130,9 @@ function getLspExecutables(
};
}

run = ruby.activateExecutable(run);
debug = ruby.activateExecutable(debug);

return { run, debug };
}

Expand Down Expand Up @@ -166,6 +170,32 @@ function collectClientOptions(
},
);

const pathConverter = ruby.pathConverter;

const pushAlternativePaths = (
path: string,
schemes: string[] = supportedSchemes,
) => {
schemes.forEach((scheme) => {
[
pathConverter.toLocalPath(path),
pathConverter.toRemotePath(path),
].forEach((convertedPath) => {
if (convertedPath !== path) {
SUPPORTED_LANGUAGE_IDS.forEach((language) => {
documentSelector.push({
scheme,
language,
pattern: `${convertedPath}/**/*`,
});
});
}
});
});
};

pushAlternativePaths(fsPath);

// Only the first language server we spawn should handle unsaved files, otherwise requests will be duplicated across
// all workspaces
if (isMainWorkspace) {
Expand All @@ -185,6 +215,8 @@ function collectClientOptions(
pattern: `${gemPath}/**/*`,
});

pushAlternativePaths(gemPath, [scheme]);

// Because of how default gems are installed, the gemPath location is actually not exactly where the files are
// located. With the regex, we are correcting the default gem path from this (where the files are not located)
// /opt/rubies/3.3.1/lib/ruby/gems/3.3.0
Expand All @@ -195,15 +227,50 @@ function collectClientOptions(
// Notice that we still need to add the regular path to the selector because some version managers will install
// gems under the non-corrected path
if (/lib\/ruby\/gems\/(?=\d)/.test(gemPath)) {
const correctedPath = gemPath.replace(
/lib\/ruby\/gems\/(?=\d)/,
"lib/ruby/",
);

documentSelector.push({
scheme,
language: "ruby",
pattern: `${gemPath.replace(/lib\/ruby\/gems\/(?=\d)/, "lib/ruby/")}/**/*`,
pattern: `${correctedPath}/**/*`,
});

pushAlternativePaths(correctedPath, [scheme]);
}
});
});

// Add other mapped paths to the document selector
pathConverter.pathMapping.forEach(([local, remote]) => {
if (
(documentSelector as { pattern: string }[]).some(
(selector) =>
selector.pattern?.startsWith(local) ||
selector.pattern?.startsWith(remote),
)
) {
return;
}

supportedSchemes.forEach((scheme) => {
SUPPORTED_LANGUAGE_IDS.forEach((language) => {
documentSelector.push({
language,
pattern: `${local}/**/*`,
});

documentSelector.push({
scheme,
language,
pattern: `${remote}/**/*`,
});
});
});
});

// This is a temporary solution as an escape hatch for users who cannot upgrade the `ruby-lsp` gem to a version that
// supports ERB
if (!configuration.get<boolean>("erbSupport")) {
Expand All @@ -212,9 +279,29 @@ function collectClientOptions(
});
}

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

// Map using pathMapping
const code2Protocol = (uri: vscode.Uri) => {
const remotePath = pathConverter.toRemotePath(uri.fsPath);
return vscode.Uri.file(remotePath).toString();
};

const protocol2Code = (uri: string) => {
const remoteUri = vscode.Uri.parse(uri);
const localPath = pathConverter.toLocalPath(remoteUri.fsPath);
return vscode.Uri.file(localPath);
};

return {
documentSelector,
workspaceFolder,
uriConverters: {
code2Protocol,
protocol2Code,
},
diagnosticCollectionName: LSP_NAME,
outputChannel,
revealOutputChannelOn: RevealOutputChannelOn.Never,
Expand Down Expand Up @@ -317,6 +404,7 @@ export default class Client extends LanguageClient implements ClientInterface {
private readonly baseFolder;
private readonly workspaceOutputChannel: WorkspaceChannel;
private readonly virtualDocuments = new Map<string, string>();
private readonly pathConverter: PathConverterInterface;

#context: vscode.ExtensionContext;
#formatter: string;
Expand All @@ -334,7 +422,7 @@ export default class Client extends LanguageClient implements ClientInterface {
) {
super(
LSP_NAME,
getLspExecutables(workspaceFolder, ruby.env),
getLspExecutables(workspaceFolder, ruby),
collectClientOptions(
vscode.workspace.getConfiguration("rubyLsp"),
workspaceFolder,
Expand All @@ -349,6 +437,7 @@ export default class Client extends LanguageClient implements ClientInterface {
this.registerFeature(new ExperimentalCapabilities());
this.workspaceOutputChannel = outputChannel;
this.virtualDocuments = virtualDocuments;
this.pathConverter = ruby.pathConverter;

// Middleware are part of client options, but because they must reference `this`, we cannot make it a part of the
// `super` call (TypeScript does not allow accessing `this` before invoking `super`)
Expand Down Expand Up @@ -429,7 +518,9 @@ export default class Client extends LanguageClient implements ClientInterface {
range?: Range,
): Promise<{ ast: string } | null> {
return this.sendRequest("rubyLsp/textDocument/showSyntaxTree", {
textDocument: { uri: uri.toString() },
textDocument: {
uri: this.pathConverter.toRemoteUri(uri).toString(),
},
range,
});
}
Expand Down Expand Up @@ -625,10 +716,12 @@ export default class Client extends LanguageClient implements ClientInterface {
token,
_next,
) => {
const remoteUri = this.pathConverter.toRemoteUri(document.uri);

const response: vscode.TextEdit[] | null = await this.sendRequest(
"textDocument/onTypeFormatting",
{
textDocument: { uri: document.uri.toString() },
textDocument: { uri: remoteUri.toString() },
position,
ch,
options,
Expand Down Expand Up @@ -696,9 +789,65 @@ export default class Client extends LanguageClient implements ClientInterface {
token?: vscode.CancellationToken,
) => Promise<T>,
) => {
return this.benchmarkMiddleware(type, param, () =>
this.workspaceOutputChannel.trace(
`Sending request: ${JSON.stringify(type)} with params: ${JSON.stringify(param)}`,
);

const result = (await this.benchmarkMiddleware(type, param, () =>
next(type, param, token),
)) as any;

this.workspaceOutputChannel.trace(
`Received response for ${JSON.stringify(type)}: ${JSON.stringify(result)}`,
);

const request = typeof type === "string" ? type : type.method;

try {
switch (request) {
case "rubyLsp/workspace/dependencies":
return result.map((dep: { path: string }) => {
return {
...dep,
path: this.pathConverter.toLocalPath(dep.path),
};
});

case "textDocument/codeAction":
return result.map((action: { uri: string }) => {
const remotePath = vscode.Uri.parse(action.uri).fsPath;
const localPath = this.pathConverter.toLocalPath(remotePath);

return {
...action,
uri: vscode.Uri.file(localPath).toString(),
};
});

case "textDocument/hover":
if (
result?.contents?.kind === "markdown" &&
result.contents.value
) {
result.contents.value = result.contents.value.replace(
/\((file:\/\/.+?)#/gim,
(_match: string, path: string) => {
const remotePath = vscode.Uri.parse(path).fsPath;
const localPath =
this.pathConverter.toLocalPath(remotePath);
return `(${vscode.Uri.file(localPath).toString()}#`;
},
);
}
break;
}
} catch (error) {
this.workspaceOutputChannel.error(
`Error while processing response for ${request}: ${error}`,
);
}

return result;
},
sendNotification: async <TR>(
type: string | MessageSignature,
Expand Down
11 changes: 10 additions & 1 deletion vscode/src/common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { exec } from "child_process";
import { exec, spawn as originalSpawn } from "child_process";
import { createHash } from "crypto";
import { promisify } from "util";

Expand Down Expand Up @@ -63,11 +63,20 @@ export interface WorkspaceInterface {
error: boolean;
}

export interface PathConverterInterface {
pathMapping: [string, string][];
toRemotePath: (localPath: string) => string;
toLocalPath: (remotePath: string) => string;
toRemoteUri: (localUri: vscode.Uri) => vscode.Uri;
}

// Event emitter used to signal that the language status items need to be refreshed
export const STATUS_EMITTER = new vscode.EventEmitter<
WorkspaceInterface | undefined
>();

export const spawn = originalSpawn;

export const asyncExec = promisify(exec);
export const LSP_NAME = "Ruby LSP";
export const LOG_CHANNEL = vscode.window.createOutputChannel(LSP_NAME, {
Expand Down
Loading

0 comments on commit 272316f

Please sign in to comment.