diff --git a/R/session/vsc.R b/R/session/vsc.R index 9561e4a76..5a13f993b 100644 --- a/R/session/vsc.R +++ b/R/session/vsc.R @@ -10,6 +10,12 @@ request_lock_file <- file.path(dir_watcher, "request.lock") settings_file <- file.path(dir_watcher, "settings.json") user_options <- names(options()) +logger <- if (getOption("vsc.debug", FALSE)) { + function(...) cat(..., "\n", sep = "") +} else { + function(...) invisible() +} + load_settings <- function() { if (!file.exists(settings_file)) { return(FALSE) @@ -20,6 +26,7 @@ load_settings <- function() { } mapping <- quote(list( + vsc.use_webserver = session$useWebServer, vsc.use_httpgd = plot$useHttpgd, vsc.show_object_size = workspaceViewer$showObjectSize, vsc.rstudioapi = session$emulateRStudioAPI, @@ -59,8 +66,133 @@ if (is.null(getOption("help_type"))) { options(help_type = "html") } +use_webserver <- isTRUE(getOption("vsc.use_webserver", FALSE)) +if (use_webserver) { + if (requireNamespace("httpuv", quietly = TRUE)) { + request_handlers <- list( + hover = function(expr, ...) { + tryCatch({ + expr <- parse(text = expr, keep.source = FALSE)[[1]] + obj <- eval(expr, .GlobalEnv) + list(str = capture_str(obj)) + }, error = function(e) NULL) + }, + + complete = function(expr, trigger, ...) { + obj <- tryCatch({ + expr <- parse(text = expr, keep.source = FALSE)[[1]] + eval(expr, .GlobalEnv) + }, error = function(e) NULL) + + if (is.null(obj)) { + return(NULL) + } + + if (trigger == "$") { + names <- if (is.object(obj)) { + .DollarNames(obj, pattern = "") + } else if (is.recursive(obj)) { + names(obj) + } else { + NULL + } + + result <- lapply(names, function(name) { + item <- obj[[name]] + list( + name = name, + type = typeof(item), + str = try_capture_str(item) + ) + }) + return(result) + } + + if (trigger == "@" && isS4(obj)) { + names <- slotNames(obj) + result <- lapply(names, function(name) { + item <- slot(obj, name) + list( + name = name, + type = typeof(item), + str = try_capture_str(item) + ) + }) + return(result) + } + } + ) + + server <- getOption("vsc.server") + if (!is.null(server) && server$isRunning()) { + host <- server$getHost() + port <- server$getPort() + token <- attr(server, "token") + } else { + host <- "127.0.0.1" + port <- httpuv::randomPort() + token <- sprintf("%d:%d:%.6f", pid, port, Sys.time()) + server <- httpuv::startServer(host, port, + list( + onHeaders = function(req) { + logger("http request ", + req[["REMOTE_ADDR"]], ":", + req[["REMOTE_PORT"]], " ", + req[["REQUEST_METHOD"]], " ", + req[["HTTP_USER_AGENT"]] + ) + + if (!nzchar(req[["REMOTE_ADDR"]]) || identical(req[["REMOTE_PORT"]], "0")) { + return(NULL) + } + + if (!identical(req[["HTTP_AUTHORIZATION"]], token)) { + return(list( + status = 401L, + headers = list( + "Content-Type" = "text/plain" + ), + body = "Unauthorized" + )) + } + + if (!identical(req[["HTTP_CONTENT_TYPE"]], "application/json")) { + return(list( + status = 400L, + headers = list( + "Content-Type" = "text/plain" + ), + body = "Bad request" + )) + } + }, + call = function(req) { + content <- req$rook.input$read_lines() + request <- jsonlite::fromJSON(content, simplifyVector = FALSE) + handler <- request_handlers[[request$type]] + response <- if (is.function(handler)) do.call(handler, request) + + list( + status = 200L, + headers = list( + "Content-Type" = "application/json" + ), + body = jsonlite::toJSON(response, auto_unbox = TRUE, force = TRUE) + ) + } + ) + ) + attr(server, "token") <- token + options(vsc.server = server) + } + } else { + message("{httpuv} is required to use WebServer from the session watcher.") + use_webserver <- FALSE + } +} + get_timestamp <- function() { - format.default(Sys.time(), nsmall = 6, scientific = FALSE) + sprintf("%.6f", Sys.time()) } scalar <- function(x) { @@ -512,7 +644,12 @@ attach <- function() { version = R.version.string, start_time = format(file.info(tempdir)$ctime) ), - plot_url = if (identical(names(dev.cur()), "httpgd")) httpgd::hgd_url() + plot_url = if (identical(names(dev.cur()), "httpgd")) httpgd::hgd_url(), + server = if (use_webserver) list( + host = host, + port = port, + token = token + ) else NULL ) } @@ -792,4 +929,4 @@ print.hsearch <- function(x, ...) { invisible(NULL) } -reg.finalizer(globalenv(), function(e) .vsc$request("detach"), onexit = TRUE) +reg.finalizer(.GlobalEnv, function(e) .vsc$request("detach"), onexit = TRUE) diff --git a/package.json b/package.json index b0689ca95..35a04ff39 100644 --- a/package.json +++ b/package.json @@ -1720,6 +1720,11 @@ "default": true, "description": "Enable R session watcher. Required for workspace viewer and most features to work with an R session. Restart required to take effect." }, + "r.session.useWebServer": { + "type": "boolean", + "default": false, + "markdownDescription": "Enable experimental use of web server in the R session to handle session requests from the extension. Changes the option `vsc.use_webserver` in R. Requires `#r.sessionWatcher#` to be set to `true`. Requires the `httpuv` R package." + }, "r.session.watchGlobalEnvironment": { "type": "boolean", "default": true, diff --git a/src/completions.ts b/src/completions.ts index ab20a7d7a..17546f42e 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -13,6 +13,7 @@ import { cleanLine } from './lineCache'; import { globalRHelp } from './extension'; import { config } from './util'; import { getChunks } from './rmarkdown'; +import { CompletionItemKind } from 'vscode-languageclient'; // Get with names(roxygen2:::default_tags()) @@ -30,7 +31,7 @@ const roxygenTagCompletionItems = [ export class HoverProvider implements vscode.HoverProvider { - provideHover(document: vscode.TextDocument, position: vscode.Position): vscode.Hover | null { + async provideHover(document: vscode.TextDocument, position: vscode.Position): Promise { if(!session.workspaceData?.globalenv){ return null; } @@ -43,15 +44,36 @@ export class HoverProvider implements vscode.HoverProvider { } } - const wordRange = document.getWordRangeAtPosition(position); - const text = document.getText(wordRange); - // use juggling check here for both - // null and undefined - // eslint-disable-next-line eqeqeq - if (session.workspaceData.globalenv[text]?.str == null) { - return null; + let hoverRange = document.getWordRangeAtPosition(position); + let hoverText = null; + + if (session.server) { + const exprRegex = /([a-zA-Z0-9._$@ ])+(? { const items: vscode.CompletionItem[] = []; if (token.isCancellationRequested || !session.workspaceData?.globalenv) { return items; @@ -144,22 +166,38 @@ export class LiveCompletionItemProvider implements vscode.CompletionItemProvider }); } else if(trigger === '$' || trigger === '@') { const symbolPosition = new vscode.Position(position.line, position.character - 1); - const symbolRange = document.getWordRangeAtPosition(symbolPosition); - const symbol = document.getText(symbolRange); - const doc = new vscode.MarkdownString('Element of `' + symbol + '`'); - const obj = session.workspaceData.globalenv[symbol]; - let names: string[] | undefined; - if (obj !== undefined) { - if (completionContext.triggerCharacter === '$') { - names = obj.names; - } else if (completionContext.triggerCharacter === '@') { - names = obj.slots; + if (session.server) { + const re = /([a-zA-Z0-9._$@ ])+(? { + const item = new vscode.CompletionItem(e.name, (e.type === 'closure' || e.type === 'builtin') ? CompletionItemKind.Function : vscode.CompletionItemKind.Variable); + item.detail = detail; + item.documentation = new vscode.MarkdownString(`\`\`\`r\n${e.str}\n\`\`\``); + item.sortText = `0-${index.toString().padStart(len, '0')}`; + index++; + return item; + }); +} + function getCompletionItems(names: string[], kind: vscode.CompletionItemKind, detail: string, documentation: vscode.MarkdownString): vscode.CompletionItem[] { const len = names.length.toString().length; let index = 0; diff --git a/src/session.ts b/src/session.ts index 5406751a7..be941901a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,6 +3,8 @@ import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; +import { Agent } from 'http'; +import fetch from 'node-fetch'; import { commands, StatusBarItem, Uri, ViewColumn, Webview, window, workspace, env, WebviewPanelOnDidChangeViewStateEvent, WebviewPanel } from 'vscode'; import { runTextInTerm } from './rTerminal'; @@ -33,6 +35,12 @@ export interface WorkspaceData { globalenv: GlobalEnv; } +export interface SessionServer { + host: string; + port: number; + token: string; +} + export let workspaceData: WorkspaceData; let resDir: string; export let requestFile: string; @@ -45,6 +53,8 @@ let rVer: string; let pid: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any let info: any; +const httpAgent = new Agent({ keepAlive: true }); +export let server: SessionServer | undefined; export let workspaceFile: string; let workspaceLockFile: string; let workspaceTimeStamp: number; @@ -728,6 +738,7 @@ export async function writeSuccessResponse(responseSessionDir: string): Promise< type ISessionRequest = { plot_url?: string, + server?: SessionServer } & IRequest; async function updateRequest(sessionStatusBarItem: StatusBarItem) { @@ -772,6 +783,11 @@ async function updateRequest(sessionStatusBarItem: StatusBarItem) { sessionStatusBarItem.tooltip = `${info?.version}\nProcess ID: ${pid}\nCommand: ${info?.command}\nStart time: ${info?.start_time}\nClick to attach to active terminal.`; sessionStatusBarItem.show(); updateSessionWatcher(); + + if (request.server) { + server = request.server; + } + purgeAddinPickerItems(); await setContext('rSessionActive', true); if (request.plot_url) { @@ -832,6 +848,7 @@ export async function cleanupSession(pidArg: string): Promise { sessionStatusBarItem.text = 'R: (not attached)'; sessionStatusBarItem.tooltip = 'Click to attach active terminal.'; } + server = undefined; workspaceData.globalenv = {}; workspaceData.loaded_namespaces = []; workspaceData.search = []; @@ -863,3 +880,35 @@ async function watchProcess(pid: string): Promise { } while (res); return pid; } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function sessionRequest(server: SessionServer, data: any): Promise { + try { + const response = await fetch(`http://${server.host}:${server.port}`, { + agent: httpAgent, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: server.token + }, + body: JSON.stringify(data), + follow: 0, + timeout: 500, + }); + + if (!response.ok) { + throw new Error(`Error! status: ${response.status}`); + } + + return response.json(); + } catch (error) { + if (error instanceof Error) { + console.log('error message: ', error.message); + } else { + console.log('unexpected error: ', error); + } + + return undefined; + } +}