Skip to content
Open
42 changes: 41 additions & 1 deletion R/languageServer.R
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,44 @@ if (identical(Sys.getenv("VSCR_LSP_SYNTHETIC_WORKSPACE"), "TRUE")) {
options(languageserver.index_mode = "off")
}

languageserver::run(port = port, debug = debug)
tools::Rd2txt_options(underline_titles = FALSE)
tools::Rd2txt_options(itemBullet = "* ")
languageserver:::lsp_settings$update_from_options()
if (isTRUE(debug)) {
languageserver:::lsp_settings$set("debug", TRUE)
languageserver:::lsp_settings$set("log_file", NULL)
}

normalize_character <- function(value) {
if (is.list(value)) {
value <- unlist(value, use.names = FALSE)
}
if (!is.character(value)) {
return(character())
}
unique(value[nzchar(value)])
}

server <- languageserver:::LanguageServer$new("localhost", port)
server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) {
attached_packages <- normalize_character(params$attachedPackages)
loaded_namespaces <- normalize_character(params$loadedNamespaces)

for (workspace in self$workspaces$values()) {
workspace$startup_packages <- if (length(attached_packages)) {
# languageserver resolves package conflicts from the end of this list.
rev(attached_packages)
Comment on lines +68 to +71

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Apply the current session state to workspaces created after this request

This updates only the workspaces that already exist when r/syncSessionState runs. In single-server mode, adding another folder to an existing multi-root workspace creates a new Workspace through workspace/didChangeWorkspaceFolders, and that workspace starts with the default packages. Since the session/package state is unchanged, the TypeScript deduplication does not send another sync. I reproduced this by syncing dplyr, then invoking the real workspace-folder notification handler: the existing workspace offered mutate, while the newly added workspace did not. Please retain the active state on the server and apply it when adding a workspace, or explicitly replay it after workspace-folder additions so completion remains available across all documents handled by the single server.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked at d53815d: this case is still unresolved. The R handler still updates only self$workspaces$values() at synchronization time, and no replay is triggered for added folders. Using this revision's handler with languageserver 0.3.20, I synced dplyr, invoked workspace_did_change_workspace_folders to add a folder, and requested workspace completion: mutate was present for the existing workspace and absent for the added workspace. Please retain/apply the active state when a workspace is created, or replay synchronization after folder additions, and add coverage for that path.

} else {
languageserver:::workspace_startup_packages()
}
workspace$update_loaded_packages()

for (pkg in unique(c(attached_packages, loaded_namespaces))) {
try(workspace$get_namespace(pkg), silent = TRUE)
}
}

self$deliver(languageserver:::Response$new(id, result = TRUE))
}

server$run()
4 changes: 3 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export let globalPlotManager: PlotManager | undefined = undefined;
export let rmdPreviewManager: rmarkdown.RMarkdownPreviewManager | undefined = undefined;
export let rmdKnitManager: rmarkdown.RMarkdownKnitManager | undefined = undefined;
export let sessionStatusBarItem: vscode.StatusBarItem | undefined = undefined;
export let rLanguageService: languageService.LanguageService | undefined = undefined;

// Called (once) when the extension is activated
export async function activate(context: vscode.ExtensionContext): Promise<apiImplementation.RExtensionImplementation> {
Expand Down Expand Up @@ -187,7 +188,8 @@ export async function activate(context: vscode.ExtensionContext): Promise<apiImp
void vscode.window.showInformationMessage('The R language server extension has been integrated into vscode-R. You need to disable or uninstall REditorSupport.r-lsp and reload window to use the new version.');
void vscode.commands.executeCommand('workbench.extensions.search', '@installed r-lsp');
} else {
context.subscriptions.push(new languageService.LanguageService());
rLanguageService = new languageService.LanguageService();
context.subscriptions.push(rLanguageService);
}
}

Expand Down
128 changes: 111 additions & 17 deletions src/languageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ import { CommonOptions } from 'child_process';
import { boundSessionForDocument, onDidBindSessionDocument, Session } from './session';
import { SessionSignatureHelpProvider } from './signatureHelp';

interface SessionState {
attachedPackages: string[];
loadedNamespaces: string[];
}

interface SessionWorkspaceData {
search: string[];
loaded_namespaces: string[];
}

export class LanguageService implements Disposable {
private readonly clients: Map<string, LanguageClient> = new Map();
private readonly initSet: Set<string> = new Set();
Expand All @@ -22,6 +32,8 @@ export class LanguageService implements Disposable {
private readonly clientUpdates = new Map<string, Promise<void>>();
private readonly listeners: Disposable[] = [];
private disposed = false;
private readonly clientScopes: Map<string, string> = new Map();
private readonly sessionStates: Map<string, { state: SessionState; stateKey: string; sessionId: string }> = new Map();

constructor() {
this.outputChannel = window.createOutputChannel('R Language Server');
Expand All @@ -35,6 +47,68 @@ export class LanguageService implements Disposable {
return this.stopLanguageService();
}

syncSessionState(data?: SessionWorkspaceData, resource?: Uri, sessionId = '', active = true): void {
// Bound virtual documents keep their owner's packages when focus moves.
if (sessionId) {
this.syncSessionScope(`session:${sessionId}`, data, sessionId);
}
if (active || !data) {
this.syncSessionScope(this.getSessionScope(resource), data, sessionId);
}
}

private syncSessionScope(scope: string, data: SessionWorkspaceData | undefined, sessionId: string): void {
const current = this.sessionStates.get(scope);
if (!data) {
if (!current || (sessionId && current.sessionId !== sessionId)) {
return;
}
this.sessionStates.delete(scope);
this.applySessionStateToScope(scope, {
attachedPackages: [],
loadedNamespaces: [],
});
return;
}

const state: SessionState = {
attachedPackages: data.search
.filter(value => value.startsWith('package:'))
.map(value => value.substring(8)),
loadedNamespaces: data.loaded_namespaces,
};
const stateKey = this.getSessionStateKey(state);
if (current?.stateKey === stateKey && current.sessionId === sessionId) {
return;
}

this.sessionStates.set(scope, { state, stateKey, sessionId });
this.applySessionStateToScope(scope, state);
}

private applySessionStateToScope(scope: string, state: SessionState): void {
for (const [key, client] of this.clients) {
if (this.clientScopes.get(key) === scope) {
void this.applySessionState(client, state);
}
}
}

private async applySessionState(client: LanguageClient, state: SessionState): Promise<void> {
try {
await client.sendRequest('r/syncSessionState', state);
} catch {
// Keep language-service features available if session synchronization fails.
}
}

private getSessionStateKey(state: SessionState): string {
return [
state.attachedPackages.join('\u0000'),
state.loadedNamespaces.join('\u0000'),
].join('\u0001');
}

private spawnServer(client: LanguageClient, rPath: string, args: readonly string[], options: CommonOptions & { cwd: string }): DisposableProcess {
const childProcess = spawn(rPath, args, options);
const pid = childProcess.pid || -1;
Expand Down Expand Up @@ -64,9 +138,9 @@ export class LanguageService implements Disposable {
return childProcess;
}

private async createClient(selector: DocumentFilter[],
private async createClient(key: string, selector: DocumentFilter[],
cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel,
resource?: Uri, target?: Session): Promise<LanguageClient> {
resource?: Uri, sessionScope: string = 'global', target?: Session): Promise<LanguageClient> {

let client: LanguageClient;
const virtualOnly = selector.every(filter => 'scheme' in filter
Expand Down Expand Up @@ -227,11 +301,24 @@ export class LanguageService implements Disposable {
}

extensionContext.subscriptions.push(client);
if (target) {
this.syncSessionScope(sessionScope, target.workspaceData, target.sessionId);
}
await client.start();
await this.registerClient(key, client, sessionScope);
return client;
}


private async registerClient(key: string, client: LanguageClient, sessionScope: string): Promise<void> {
this.clients.set(key, client);
this.clientScopes.set(key, sessionScope);
const sessionState = this.sessionStates.get(sessionScope)?.state;
if (sessionState) {
await this.applySessionState(client, sessionState);
Comment on lines +316 to +318

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Register the client before awaiting its initial state synchronization

The caller adds this client to clients/clientScopes only after createClient returns. If a package update or disconnect arrives while this initial sendRequest is pending, syncSessionState updates/deletes the cache but cannot deliver the change to this client. The client is then registered with the old server state, and identical later workspace refreshes are skipped by the state-key check. In a controlled reproduction, synchronizing [base], then [dplyr, base] before the first request completed left the cache at [dplyr, base] but sent only [base]. Please register the client and its scope before the asynchronous sync, or reconcile the latest state (including a cleared state) before making startup complete.

}
}

private checkClient(name: string): boolean {
if (this.initSet.has(name)) {
return true;
Expand All @@ -244,6 +331,14 @@ export class LanguageService implements Disposable {
return false;
}

private getSessionScope(resource?: Uri): string {
if (this.config.get<boolean>('lsp.multiServer') !== true) {
return 'global';
}
const folder = resource ? workspace.getWorkspaceFolder(resource) : undefined;
return folder ? this.getKey(folder.uri) : 'unscoped';
}

private getKey(uri: Uri): string {
switch (uri.scheme) {
case 'untitled':
Expand Down Expand Up @@ -272,25 +367,26 @@ export class LanguageService implements Disposable {

if (document.uri.scheme === 'vscode-interactive-input') {
const key = document.uri.toString();
const scope = target ? `session:${target.sessionId}` : this.getSessionScope(folder?.uri);
if (!this.checkClient(key)) {
const selector = [{ scheme: 'vscode-interactive-input', language: 'r', pattern: document.uri.fsPath }];
const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, target);
this.clients.set(key, client); this.initSet.delete(key);
await this.createClient(key, selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, scope, target);
this.initSet.delete(key);
}
return;
}

// Each notebook uses a server started from parent folder
if (document.uri.scheme === 'vscode-notebook-cell') {
const key = this.getKey(document.uri);
const scope = target ? `session:${target.sessionId}` : this.getSessionScope(folder?.uri);
if (!this.checkClient(key)) {
console.log(`Start language server for ${document.uri.toString(true)}`);
const documentSelector: DocumentFilter[] = [
{ scheme: 'vscode-notebook-cell', language: 'r', pattern: `${document.uri.fsPath}` },
];
const client = await this.createClient(documentSelector,
target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, target);
this.clients.set(key, client);
await this.createClient(key, documentSelector,
target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, scope, target);
this.initSet.delete(key);
}
return;
Expand All @@ -307,8 +403,7 @@ export class LanguageService implements Disposable {
{ scheme: 'file', language: 'r', pattern: pattern },
{ scheme: 'file', language: 'rmd', pattern: pattern },
];
const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri);
this.clients.set(key, client);
await this.createClient(key, documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri, key);
this.initSet.delete(key);
}

Expand All @@ -323,8 +418,7 @@ export class LanguageService implements Disposable {
{ scheme: 'untitled', language: 'r' },
{ scheme: 'untitled', language: 'rmd' },
];
const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri);
this.clients.set(key, client);
await this.createClient(key, documentSelector, os.homedir(), undefined, this.outputChannel, document.uri, 'unscoped');
this.initSet.delete(key);
}
return;
Expand All @@ -338,9 +432,8 @@ export class LanguageService implements Disposable {
const documentSelector: DocumentFilter[] = [
{ scheme: 'file', pattern: document.uri.fsPath },
];
const client = await this.createClient(documentSelector,
dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri);
this.clients.set(key, client);
await this.createClient(key, documentSelector,
dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri, 'unscoped');
this.initSet.delete(key);
}
return;
Expand Down Expand Up @@ -372,6 +465,7 @@ export class LanguageService implements Disposable {
const client = this.clients.get(key);
if (client) {
this.clients.delete(key);
this.clientScopes.delete(key);
this.initSet.delete(key);
void client.stop();
}
Expand All @@ -389,7 +483,7 @@ export class LanguageService implements Disposable {
// repeatedly. Restart its shared server only once per owner.
if (restart && clientTargets.get(key) !== target) {
const client = this.clients.get(key);
this.clients.delete(key); this.initSet.delete(key);
this.clients.delete(key); this.clientScopes.delete(key); this.initSet.delete(key);
await client?.stop();
}
await didOpenTextDocument(document);
Expand Down Expand Up @@ -417,6 +511,7 @@ export class LanguageService implements Disposable {
const client = this.clients.get(key);
if (client) {
this.clients.delete(key);
this.clientScopes.delete(key);
this.initSet.delete(key);
void client.stop();
}
Expand Down Expand Up @@ -444,8 +539,7 @@ export class LanguageService implements Disposable {

const workspaceFolder = workspace.workspaceFolders?.[0];
const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : os.homedir();
const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri);
this.clients.set('global', client);
await this.createClient('global', documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri, 'global');
this.startMultiLanguageService(true);
}
}
Expand Down
8 changes: 3 additions & 5 deletions src/rTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,9 +366,9 @@ export function deleteTerminal(term: vscode.Terminal): void {
}
}

function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefined {
export function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefined {
if (term === rTerm && rTermResource) {
return rTermResource;
return vscode.workspace.getWorkspaceFolder(rTermResource)?.uri;
}

const creationOptions = term.creationOptions;
Expand All @@ -377,9 +377,7 @@ function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefined {
const cwdResource = typeof cwd === 'string'
? vscode.workspace.workspaceFolders?.find(folder => folder.uri.fsPath === cwd)?.uri ?? vscode.Uri.file(cwd)
: cwd;
return cwdResource
? getCurrentWorkspaceFolder(cwdResource)?.uri ?? cwdResource
: getCurrentWorkspaceFolder()?.uri;
return cwdResource ? vscode.workspace.getWorkspaceFolder(cwdResource)?.uri : undefined;
}

export function findTerminal(): vscode.Terminal | undefined {
Expand Down
Loading
Loading