diff --git a/.vscode/launch.json b/.vscode/launch.json index 641bd40e8..8c9ceb4bc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,7 @@ "--extensionDevelopmentPath=${workspaceRoot}" ], "outFiles": [ - "${workspaceRoot}/client/out/*.js" + "${workspaceRoot}/client/out/**/*.js" ], "preLaunchTask": { "type": "npm", @@ -25,7 +25,7 @@ "port": 6009, "restart": true, "outFiles": [ - "${workspaceRoot}/server/out/*.js" + "${workspaceRoot}/server/out/**/*.js" ] }, { diff --git a/.vscodeignore b/.vscodeignore index 00b201d30..a20fda66d 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -18,4 +18,5 @@ tools.opam client/node_modules server/node_modules _opam -_build \ No newline at end of file +_build +Makefile diff --git a/CHANGELOG.md b/CHANGELOG.md index a32a86574..ab829dc8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,15 @@ ## master -## 1.42.0 +#### :rocket: New Feature -#### :bug: Bug Fix +- Experimental support for type checking without saving the file :tada:. https://github.com/rescript-lang/rescript-vscode/pull/939 -- Fix issue with unlabelled arg code swallowing completions. https://github.com/rescript-lang/rescript-vscode/pull/937 +## 1.42.0 #### :bug: Bug Fix +- Fix issue with unlabelled arg code swallowing completions. https://github.com/rescript-lang/rescript-vscode/pull/937 - Fix issue where completion inside of switch expression would not work in some cases. https://github.com/rescript-lang/rescript-vscode/pull/936 - Fix bug that made empty prop expressions in JSX not complete if in the middle of a JSX element. https://github.com/rescript-lang/rescript-vscode/pull/935 diff --git a/analysis/src/Cmt.ml b/analysis/src/Cmt.ml index 7380c9fec..228d64b59 100644 --- a/analysis/src/Cmt.ml +++ b/analysis/src/Cmt.ml @@ -16,13 +16,25 @@ let fullFromUri ~uri = let moduleName = BuildSystem.namespacedName package.namespace (FindFiles.getName path) in - match Hashtbl.find_opt package.pathsForModule moduleName with - | Some paths -> - let cmt = getCmtPath ~uri paths in - fullForCmt ~moduleName ~package ~uri cmt - | None -> - prerr_endline ("can't find module " ^ moduleName); - None) + let incrementalCmtPath = + package.rootPath ^ "/lib/bs/___incremental" ^ "/" ^ moduleName + ^ + match Files.classifySourceFile path with + | Resi -> ".cmti" + | _ -> ".cmt" + in + match fullForCmt ~moduleName ~package ~uri incrementalCmtPath with + | Some cmtInfo -> + if Debug.verbose () then Printf.printf "[cmt] Found incremental cmt\n"; + Some cmtInfo + | None -> ( + match Hashtbl.find_opt package.pathsForModule moduleName with + | Some paths -> + let cmt = getCmtPath ~uri paths in + fullForCmt ~moduleName ~package ~uri cmt + | None -> + prerr_endline ("can't find module " ^ moduleName); + None)) let fullsFromModule ~package ~moduleName = if Hashtbl.mem package.pathsForModule moduleName then diff --git a/package.json b/package.json index ef9bc033f..8931307dd 100644 --- a/package.json +++ b/package.json @@ -176,6 +176,21 @@ "default": true, "description": "Enable signature help for function calls." }, + "rescript.settings.incrementalTypechecking.enabled": { + "type": "boolean", + "default": false, + "description": "(beta/experimental) Enable incremental type checking." + }, + "rescript.settings.incrementalTypechecking.acrossFiles": { + "type": "boolean", + "default": false, + "description": "(beta/experimental) Enable incremental type checking across files, so that unsaved file A gets access to unsaved file B." + }, + "rescript.settings.incrementalTypechecking.debugLogging": { + "type": "boolean", + "default": false, + "description": "(debug) Enable debug logging (ends up in the extension output)." + }, "rescript.settings.binaryPath": { "type": [ "string", @@ -230,9 +245,9 @@ }, "scripts": { "clean": "rm -rf client/out server/out", - "vscode:prepublish": "npm run clean && npm run compile && npm run bundle", - "compile": "tsc", - "watch": "tsc -w", + "vscode:prepublish": "npm run clean && npm run bundle", + "compile": "tsc -b", + "watch": "tsc -b -w", "postinstall": "cd server && npm i && cd ../client && npm i && cd ../tools && npm i && cd ../tools/tests && npm i && cd ../../analysis/tests && npm i && cd ../reanalyze/examples/deadcode && npm i && cd ../termination && npm i", "bundle-server": "esbuild server/src/cli.ts --bundle --sourcemap --outfile=server/out/cli.js --format=cjs --platform=node --loader:.node=file --minify", "bundle-client": "esbuild client/src/extension.ts --bundle --external:vscode --sourcemap --outfile=client/out/extension.js --format=cjs --platform=node --loader:.node=file --minify", diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 000000000..33db6743e --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,49 @@ +import { Message } from "vscode-languageserver-protocol"; + +export type send = (msg: Message) => void; + +export interface extensionConfiguration { + allowBuiltInFormatter: boolean; + askToStartBuild: boolean; + inlayHints: { + enable: boolean; + maxLength: number | null; + }; + codeLens: boolean; + binaryPath: string | null; + platformPath: string | null; + signatureHelp: { + enabled: boolean; + }; + incrementalTypechecking: { + enabled: boolean; + acrossFiles: boolean; + debugLogging: boolean; + }; +} + +// All values here are temporary, and will be overridden as the server is +// initialized, and the current config is received from the client. +let config: { extensionConfiguration: extensionConfiguration } = { + extensionConfiguration: { + allowBuiltInFormatter: false, + askToStartBuild: true, + inlayHints: { + enable: false, + maxLength: 25, + }, + codeLens: false, + binaryPath: null, + platformPath: null, + signatureHelp: { + enabled: true, + }, + incrementalTypechecking: { + enabled: false, + acrossFiles: true, + debugLogging: true, + }, + }, +}; + +export default config; diff --git a/server/src/incrementalCompilation.ts b/server/src/incrementalCompilation.ts new file mode 100644 index 000000000..baf450a15 --- /dev/null +++ b/server/src/incrementalCompilation.ts @@ -0,0 +1,617 @@ +import * as path from "path"; +import fs from "fs"; +import * as utils from "./utils"; +import { pathToFileURL } from "url"; +import readline from "readline"; +import { performance } from "perf_hooks"; +import * as p from "vscode-languageserver-protocol"; +import * as cp from "node:child_process"; +import config, { send } from "./config"; +import * as c from "./constants"; +import * as chokidar from "chokidar"; + +function debug() { + return config.extensionConfiguration.incrementalTypechecking.debugLogging; +} + +const INCREMENTAL_FOLDER_NAME = "___incremental"; +const INCREMENTAL_FILE_FOLDER_LOCATION = `lib/bs/${INCREMENTAL_FOLDER_NAME}`; + +type IncrementallyCompiledFileInfo = { + file: { + /** File type. */ + extension: ".res" | ".resi"; + /** Path to the source file. */ + sourceFilePath: string; + /** Name of the source file. */ + sourceFileName: string; + /** Module name of the source file. */ + moduleName: string; + /** Namespaced module name of the source file. */ + moduleNameNamespaced: string; + /** Path to where the incremental file is saved. */ + incrementalFilePath: string; + /** Location of the original type file. */ + originalTypeFileLocation: string; + }; + /** Cache for build.ninja assets. */ + buildNinja: { + /** When build.ninja was last modified. Used as a cache key. */ + fileMtime: number; + /** The raw, extracted needed info from build.ninja. Needs processing. */ + rawExtracted: Array; + } | null; + /** Info of the currently active incremental compilation. `null` if no incremental compilation is active. */ + compilation: { + /** The timeout of the currently active compilation for this incremental file. */ + timeout: NodeJS.Timeout; + /** The trigger token for the currently active compilation. */ + triggerToken: number; + } | null; + /** Listeners for when compilation of this file is killed. List always cleared after each invocation. */ + killCompilationListeners: Array<() => void>; + /** Project specific information. */ + project: { + /** The root path of the project. */ + rootPath: string; + /** Computed location of bsc. */ + bscBinaryLocation: string; + /** The arguments needed for bsc, derived from the project configuration/build.ninja. */ + callArgs: Promise | null>; + /** The location of the incremental folder for this project. */ + incrementalFolderPath: string; + /** The ReScript version. */ + rescriptVersion: string; + }; +}; + +const incrementallyCompiledFileInfo: Map< + string, + IncrementallyCompiledFileInfo +> = new Map(); +const hasReportedFeatureFailedError: Set = new Set(); +const originalTypeFileToFilePath: Map = new Map(); + +let incrementalFilesWatcher = chokidar + .watch([], { + awaitWriteFinish: { + stabilityThreshold: 1, + }, + }) + .on("all", (e, changedPath) => { + if (e !== "change" && e !== "unlink") return; + const filePath = originalTypeFileToFilePath.get(changedPath); + if (filePath != null) { + const entry = incrementallyCompiledFileInfo.get(filePath); + if (entry != null) { + if (debug()) { + console.log( + "[watcher] Cleaning up incremental files for " + filePath + ); + } + if (entry.compilation != null) { + if (debug()) { + console.log("[watcher] Was compiling, killing"); + } + clearTimeout(entry.compilation.timeout); + entry.killCompilationListeners.forEach((cb) => cb()); + entry.compilation = null; + } + cleanUpIncrementalFiles( + entry.file.sourceFilePath, + entry.project.rootPath + ); + } + } + }); + +export function removeIncrementalFileFolder( + projectRootPath: string, + onAfterRemove?: () => void +) { + fs.rm( + path.resolve(projectRootPath, INCREMENTAL_FILE_FOLDER_LOCATION), + { force: true, recursive: true }, + (_) => { + onAfterRemove?.(); + } + ); +} + +export function recreateIncrementalFileFolder(projectRootPath: string) { + if (debug()) { + console.log("Recreating incremental file folder"); + } + removeIncrementalFileFolder(projectRootPath, () => { + fs.mkdir( + path.resolve(projectRootPath, INCREMENTAL_FILE_FOLDER_LOCATION), + (_) => {} + ); + }); +} + +export function cleanUpIncrementalFiles( + filePath: string, + projectRootPath: string +) { + const ext = filePath.endsWith(".resi") ? ".resi" : ".res"; + const namespace = utils.getNamespaceNameFromConfigFile(projectRootPath); + const fileNameNoExt = path.basename(filePath, ext); + const moduleNameNamespaced = + namespace.kind === "success" && namespace.result !== "" + ? `${fileNameNoExt}-${namespace.result}` + : fileNameNoExt; + + if (debug()) { + console.log("Cleaning up incremental file assets for: " + fileNameNoExt); + } + + fs.unlink( + path.resolve( + projectRootPath, + INCREMENTAL_FILE_FOLDER_LOCATION, + path.basename(filePath) + ), + (_) => {} + ); + + [ + moduleNameNamespaced + ".ast", + moduleNameNamespaced + ".cmt", + moduleNameNamespaced + ".cmti", + moduleNameNamespaced + ".cmi", + moduleNameNamespaced + ".cmj", + ].forEach((file) => { + fs.unlink( + path.resolve(projectRootPath, INCREMENTAL_FILE_FOLDER_LOCATION, file), + (_) => {} + ); + }); +} +function getBscArgs( + entry: IncrementallyCompiledFileInfo +): Promise | null> { + const buildNinjaPath = path.resolve( + entry.project.rootPath, + "lib/bs/build.ninja" + ); + const cacheEntry = entry.buildNinja; + let stat: fs.Stats | null = null; + if (cacheEntry != null) { + try { + stat = fs.statSync(buildNinjaPath); + } catch { + if (debug()) { + console.log("Did not find build.ninja, cannot proceed."); + } + } + if (stat != null) { + if (cacheEntry.fileMtime >= stat.mtimeMs) { + return Promise.resolve(cacheEntry.rawExtracted); + } + } else { + return Promise.resolve(null); + } + } + return new Promise((resolve, _reject) => { + function resolveResult(result: Array) { + if (stat != null) { + entry.buildNinja = { + fileMtime: stat.mtimeMs, + rawExtracted: result, + }; + } + resolve(result); + } + const fileStream = fs.createReadStream(buildNinjaPath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + let captureNextLine = false; + let done = false; + let stopped = false; + const captured: Array = []; + rl.on("line", (line) => { + if (stopped) { + return; + } + if (captureNextLine) { + captured.push(line); + captureNextLine = false; + } + if (done) { + fileStream.destroy(); + rl.close(); + resolveResult(captured); + stopped = true; + return; + } + if (line.startsWith("rule astj")) { + captureNextLine = true; + } + if (line.startsWith("rule mij")) { + captureNextLine = true; + done = true; + } + }); + rl.on("close", () => { + resolveResult(captured); + }); + }); +} +function argsFromCommandString(cmdString: string): Array> { + const s = cmdString + .trim() + .split("command = ")[1] + .split(" ") + .map((v) => v.trim()) + .filter((v) => v !== ""); + const args: Array> = []; + + for (let i = 0; i <= s.length - 1; i++) { + const item = s[i]; + const nextIndex = i + 1; + const nextItem = s[nextIndex] ?? ""; + if (item.startsWith("-") && nextItem.startsWith("-")) { + // Single entry arg + args.push([item]); + } else if (item.startsWith("-") && nextItem.startsWith("'")) { + // Quoted arg, take until ending ' + const arg = [nextItem.slice(1)]; + for (let x = nextIndex + 1; x <= s.length - 1; x++) { + let subItem = s[x]; + let break_ = false; + if (subItem.endsWith("'")) { + subItem = subItem.slice(0, subItem.length - 1); + i = x; + break_ = true; + } + arg.push(subItem); + if (break_) { + break; + } + } + args.push([item, arg.join(" ")]); + } else if (item.startsWith("-")) { + args.push([item, nextItem]); + } + } + return args; +} +function removeAnsiCodes(s: string): string { + const ansiEscape = /\x1B[@-_][0-?]*[ -/]*[@-~]/g; + return s.replace(ansiEscape, ""); +} +function triggerIncrementalCompilationOfFile( + filePath: string, + fileContent: string, + send: send, + onCompilationFinished?: () => void +) { + let incrementalFileCacheEntry = incrementallyCompiledFileInfo.get(filePath); + if (incrementalFileCacheEntry == null) { + // New file + const projectRootPath = utils.findProjectRootOfFile(filePath); + if (projectRootPath == null) { + if (debug()) + console.log("Did not find project root path for " + filePath); + return; + } + const namespaceName = utils.getNamespaceNameFromConfigFile(projectRootPath); + if (namespaceName.kind === "error") { + if (debug()) + console.log("Getting namespace config errored for " + filePath); + return; + } + const bscBinaryLocation = utils.findBscExeBinary(projectRootPath); + if (bscBinaryLocation == null) { + if (debug()) + console.log("Could not find bsc binary location for " + filePath); + return; + } + const ext = filePath.endsWith(".resi") ? ".resi" : ".res"; + const moduleName = path.basename(filePath, ext); + const moduleNameNamespaced = + namespaceName.result !== "" + ? `${moduleName}-${namespaceName.result}` + : moduleName; + + const incrementalFolderPath = path.join( + projectRootPath, + INCREMENTAL_FILE_FOLDER_LOCATION + ); + + let rescriptVersion = ""; + try { + rescriptVersion = cp + .execFileSync(bscBinaryLocation, ["-version"]) + .toString() + .trim(); + } catch (e) { + console.error(e); + } + if (rescriptVersion.startsWith("ReScript ")) { + rescriptVersion = rescriptVersion.replace("ReScript ", ""); + } + + let originalTypeFileLocation = path.resolve( + projectRootPath, + "lib/bs", + path.relative(projectRootPath, filePath) + ); + + const parsed = path.parse(originalTypeFileLocation); + parsed.ext = ext === ".res" ? ".cmt" : ".cmti"; + parsed.base = ""; + originalTypeFileLocation = path.format(parsed); + + incrementalFileCacheEntry = { + file: { + originalTypeFileLocation, + extension: ext, + moduleName, + moduleNameNamespaced, + sourceFileName: moduleName + ext, + sourceFilePath: filePath, + incrementalFilePath: path.join(incrementalFolderPath, moduleName + ext), + }, + project: { + rootPath: projectRootPath, + callArgs: Promise.resolve([]), + bscBinaryLocation, + incrementalFolderPath, + rescriptVersion, + }, + buildNinja: null, + compilation: null, + killCompilationListeners: [], + }; + + incrementalFileCacheEntry.project.callArgs = figureOutBscArgs( + incrementalFileCacheEntry + ); + // Set up watcher for relevant cmt/cmti + incrementalFilesWatcher.add([ + incrementalFileCacheEntry.file.originalTypeFileLocation, + ]); + originalTypeFileToFilePath.set( + incrementalFileCacheEntry.file.originalTypeFileLocation, + incrementalFileCacheEntry.file.sourceFilePath + ); + incrementallyCompiledFileInfo.set(filePath, incrementalFileCacheEntry); + } + + if (incrementalFileCacheEntry == null) return; + const entry = incrementalFileCacheEntry; + if (entry.compilation != null) { + clearTimeout(entry.compilation.timeout); + entry.killCompilationListeners.forEach((cb) => cb()); + entry.killCompilationListeners = []; + } + const triggerToken = performance.now(); + const timeout = setTimeout(() => { + compileContents(entry, fileContent, send, onCompilationFinished); + }, 20); + + if (entry.compilation != null) { + entry.compilation.timeout = timeout; + entry.compilation.triggerToken = triggerToken; + } else { + entry.compilation = { + timeout, + triggerToken, + }; + } +} +function verifyTriggerToken(filePath: string, triggerToken: number): boolean { + return ( + incrementallyCompiledFileInfo.get(filePath)?.compilation?.triggerToken === + triggerToken + ); +} +async function figureOutBscArgs(entry: IncrementallyCompiledFileInfo) { + const res = await getBscArgs(entry); + if (res == null) return null; + const [astBuildCommand, fullBuildCommand] = res; + + const astArgs = argsFromCommandString(astBuildCommand); + const buildArgs = argsFromCommandString(fullBuildCommand); + + let callArgs: Array = []; + + if (config.extensionConfiguration.incrementalTypechecking.acrossFiles) { + callArgs.push( + "-I", + path.resolve(entry.project.rootPath, INCREMENTAL_FILE_FOLDER_LOCATION) + ); + } + + buildArgs.forEach(([key, value]: Array) => { + if (key === "-I") { + callArgs.push( + "-I", + path.resolve(entry.project.rootPath, "lib/bs", value) + ); + } else if (key === "-bs-v") { + callArgs.push("-bs-v", Date.now().toString()); + } else if (key === "-bs-package-output") { + return; + } else if (value == null || value === "") { + callArgs.push(key); + } else { + callArgs.push(key, value); + } + }); + + astArgs.forEach(([key, value]: Array) => { + if (key.startsWith("-bs-jsx")) { + callArgs.push(key, value); + } else if (key.startsWith("-ppx")) { + callArgs.push(key, value); + } + }); + + callArgs.push("-color", "never"); + if (parseInt(entry.project.rescriptVersion.split(".")[0] ?? "10") >= 11) { + // Only available in v11+ + callArgs.push("-ignore-parse-errors"); + } + + callArgs = callArgs.filter((v) => v != null && v !== ""); + callArgs.push(entry.file.incrementalFilePath); + return callArgs; +} +async function compileContents( + entry: IncrementallyCompiledFileInfo, + fileContent: string, + send: (msg: p.Message) => void, + onCompilationFinished?: () => void +) { + const triggerToken = entry.compilation?.triggerToken; + const callArgs = await entry.project.callArgs; + if (callArgs == null) { + if (debug()) { + console.log( + "Could not figure out call args. Maybe build.ninja does not exist yet?" + ); + } + return; + } + + const startTime = performance.now(); + if (!fs.existsSync(entry.project.incrementalFolderPath)) { + try { + fs.mkdirSync(entry.project.incrementalFolderPath); + } catch {} + } + + try { + fs.writeFileSync(entry.file.incrementalFilePath, fileContent); + + const process = cp.execFile( + entry.project.bscBinaryLocation, + callArgs, + { cwd: entry.project.rootPath }, + (error, _stdout, stderr) => { + if (!error?.killed) { + if (debug()) + console.log( + `Recompiled ${entry.file.sourceFileName} in ${ + (performance.now() - startTime) / 1000 + }s` + ); + } else { + if (debug()) + console.log( + `Compilation of ${entry.file.sourceFileName} was killed.` + ); + } + let hasIgnoredErrorMessages = false; + if ( + !error?.killed && + triggerToken != null && + verifyTriggerToken(entry.file.sourceFilePath, triggerToken) + ) { + if (debug()) { + console.log("Resetting compilation status."); + } + // Reset compilation status as this compilation finished + entry.compilation = null; + const { result } = utils.parseCompilerLogOutput(`${stderr}\n#Done()`); + const res = (Object.values(result)[0] ?? []) + .map((d) => ({ + ...d, + message: removeAnsiCodes(d.message), + })) + // Filter out a few unwanted parser errors since we run the parser in ignore mode + .filter((d) => { + if ( + !d.message.startsWith("Uninterpreted extension 'rescript.") && + !d.message.includes( + `/${INCREMENTAL_FOLDER_NAME}/${entry.file.sourceFileName}` + ) + ) { + hasIgnoredErrorMessages = true; + return true; + } + return false; + }); + + if ( + res.length === 0 && + stderr !== "" && + !hasIgnoredErrorMessages && + !hasReportedFeatureFailedError.has(entry.project.rootPath) + ) { + try { + hasReportedFeatureFailedError.add(entry.project.rootPath); + const logfile = path.resolve( + entry.project.incrementalFolderPath, + "error.log" + ); + fs.writeFileSync(logfile, stderr); + let params: p.ShowMessageParams = { + type: p.MessageType.Warning, + message: `[Incremental typechecking] Something might have gone wrong with incremental type checking. Check out the [error log](file://${logfile}) and report this issue please.`, + }; + let message: p.NotificationMessage = { + jsonrpc: c.jsonrpcVersion, + method: "window/showMessage", + params: params, + }; + send(message); + } catch (e) { + console.error(e); + } + } + + const notification: p.NotificationMessage = { + jsonrpc: c.jsonrpcVersion, + method: "textDocument/publishDiagnostics", + params: { + uri: pathToFileURL(entry.file.sourceFilePath), + diagnostics: res, + }, + }; + send(notification); + } + onCompilationFinished?.(); + } + ); + entry.killCompilationListeners.push(() => { + process.kill("SIGKILL"); + }); + } catch (e) { + console.error(e); + } +} + +export function handleUpdateOpenedFile( + filePath: string, + fileContent: string, + send: send, + onCompilationFinished?: () => void +) { + if (debug()) { + console.log("Updated: " + filePath); + } + triggerIncrementalCompilationOfFile( + filePath, + fileContent, + send, + onCompilationFinished + ); +} + +export function handleClosedFile(filePath: string) { + if (debug()) { + console.log("Closed: " + filePath); + } + const entry = incrementallyCompiledFileInfo.get(filePath); + if (entry == null) return; + cleanUpIncrementalFiles(filePath, entry.project.rootPath); + incrementallyCompiledFileInfo.delete(filePath); + originalTypeFileToFilePath.delete(entry.file.originalTypeFileLocation); + incrementalFilesWatcher.unwatch([entry.file.originalTypeFileLocation]); +} diff --git a/server/src/server.ts b/server/src/server.ts index 9cd7e68a8..d25976550 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -22,25 +22,12 @@ import * as c from "./constants"; import * as chokidar from "chokidar"; import { assert } from "console"; import { fileURLToPath } from "url"; -import { ChildProcess } from "child_process"; +import * as cp from "node:child_process"; import { WorkspaceEdit } from "vscode-languageserver"; import { filesDiagnostics } from "./utils"; import { onErrorReported } from "./errorReporter"; - -interface extensionConfiguration { - allowBuiltInFormatter: boolean; - askToStartBuild: boolean; - inlayHints: { - enable: boolean; - maxLength: number | null; - }; - codeLens: boolean; - binaryPath: string | null; - platformPath: string | null; - signatureHelp: { - enabled: boolean; - }; -} +import * as ic from "./incrementalCompilation"; +import config, { extensionConfiguration } from "./config"; // This holds client capabilities specific to our extension, and not necessarily // related to the LS protocol. It's for enabling/disabling features that might @@ -51,22 +38,6 @@ export interface extensionClientCapabilities { } let extensionClientCapabilities: extensionClientCapabilities = {}; -// All values here are temporary, and will be overridden as the server is -// initialized, and the current config is received from the client. -let extensionConfiguration: extensionConfiguration = { - allowBuiltInFormatter: false, - askToStartBuild: true, - inlayHints: { - enable: false, - maxLength: 25, - }, - codeLens: false, - binaryPath: null, - platformPath: null, - signatureHelp: { - enabled: true, - }, -}; // Below here is some state that's not important exactly how long it lives. let hasPromptedAboutBuiltInFormatter = false; let pullConfigurationPeriodically: NodeJS.Timeout | null = null; @@ -85,7 +56,7 @@ let projectsFiles: Map< filesWithDiagnostics: Set; filesDiagnostics: filesDiagnostics; - bsbWatcherByEditor: null | ChildProcess; + bsbWatcherByEditor: null | cp.ChildProcess; // This keeps track of whether we've prompted the user to start a build // automatically, if there's no build currently running for the project. We @@ -104,42 +75,15 @@ let codeActionsFromDiagnostics: codeActions.filesCodeActions = {}; let send: (msg: p.Message) => void = (_) => {}; let findRescriptBinary = (projectRootPath: p.DocumentUri | null) => - extensionConfiguration.binaryPath == null + config.extensionConfiguration.binaryPath == null ? lookup.findFilePathFromProjectRoot( projectRootPath, path.join(c.nodeModulesBinDir, c.rescriptBinName) ) - : utils.findBinary(extensionConfiguration.binaryPath, c.rescriptBinName); - -let findPlatformPath = (projectRootPath: p.DocumentUri | null) => { - if (extensionConfiguration.platformPath != null) { - return extensionConfiguration.platformPath; - } - - let rescriptDir = lookup.findFilePathFromProjectRoot( - projectRootPath, - path.join("node_modules", "rescript") - ); - if (rescriptDir == null) { - return null; - } - - let platformPath = path.join(rescriptDir, c.platformDir); - - // Workaround for darwinarm64 which has no folder yet in ReScript <= 9.1.4 - if ( - process.platform == "darwin" && - process.arch == "arm64" && - !fs.existsSync(platformPath) - ) { - platformPath = path.join(rescriptDir, process.platform); - } - - return platformPath; -}; - -let findBscExeBinary = (projectRootPath: p.DocumentUri | null) => - utils.findBinary(findPlatformPath(projectRootPath), c.bscExeName); + : utils.findBinary( + config.extensionConfiguration.binaryPath, + c.rescriptBinName + ); let createInterfaceRequest = new v.RequestType< p.TextDocumentIdentifier, @@ -247,6 +191,9 @@ let deleteProjectDiagnostics = (projectRootPath: string) => { }); projectsFiles.delete(projectRootPath); + if (config.extensionConfiguration.incrementalTypechecking.enabled) { + ic.removeIncrementalFileFolder(projectRootPath); + } } }; let sendCompilationFinishedMessage = () => { @@ -264,13 +211,13 @@ let compilerLogsWatcher = chokidar stabilityThreshold: 1, }, }) - .on("all", (_e, changedPath) => { + .on("all", (_e, _changedPath) => { sendUpdatedDiagnostics(); sendCompilationFinishedMessage(); - if (extensionConfiguration.inlayHints?.enable === true) { + if (config.extensionConfiguration.inlayHints?.enable === true) { sendInlayHintsRefresh(); } - if (extensionConfiguration.codeLens === true) { + if (config.extensionConfiguration.codeLens === true) { sendCodeLensRefresh(); } }); @@ -292,6 +239,9 @@ let openedFile = (fileUri: string, fileContent: string) => { if (projectRootPath != null) { let projectRootState = projectsFiles.get(projectRootPath); if (projectRootState == null) { + if (config.extensionConfiguration.incrementalTypechecking.enabled) { + ic.recreateIncrementalFileFolder(projectRootPath); + } projectRootState = { openFiles: new Set(), filesWithDiagnostics: new Set(), @@ -315,7 +265,7 @@ let openedFile = (fileUri: string, fileContent: string) => { let bsbLockPath = path.join(projectRootPath, c.bsbLock); if ( projectRootState.hasPromptedToStartBuild === false && - extensionConfiguration.askToStartBuild === true && + config.extensionConfiguration.askToStartBuild === true && !fs.existsSync(bsbLockPath) ) { // TODO: sometime stale .bsb.lock dangling. bsb -w knows .bsb.lock is @@ -349,12 +299,12 @@ let openedFile = (fileUri: string, fileContent: string) => { params: { type: p.MessageType.Error, message: - extensionConfiguration.binaryPath == null + config.extensionConfiguration.binaryPath == null ? `Can't find ReScript binary in ${path.join( projectRootPath, c.nodeModulesBinDir )} or parent directories. Did you install it? It's required to use "rescript" > 9.1` - : `Can't find ReScript binary in the directory ${extensionConfiguration.binaryPath}`, + : `Can't find ReScript binary in the directory ${config.extensionConfiguration.binaryPath}`, }, }; send(request); @@ -365,9 +315,14 @@ let openedFile = (fileUri: string, fileContent: string) => { // call the listener which calls it } }; + let closedFile = (fileUri: string) => { let filePath = fileURLToPath(fileUri); + if (config.extensionConfiguration.incrementalTypechecking.enabled) { + ic.handleClosedFile(filePath); + } + stupidFileContentCache.delete(filePath); let projectRootPath = utils.findProjectRootOfFile(filePath); @@ -389,10 +344,21 @@ let closedFile = (fileUri: string) => { } } }; + let updateOpenedFile = (fileUri: string, fileContent: string) => { let filePath = fileURLToPath(fileUri); assert(stupidFileContentCache.has(filePath)); stupidFileContentCache.set(filePath, fileContent); + if (config.extensionConfiguration.incrementalTypechecking.enabled) { + ic.handleUpdateOpenedFile(filePath, fileContent, send, () => { + if (config.extensionConfiguration.codeLens) { + sendCodeLensRefresh(); + } + if (config.extensionConfiguration.inlayHints) { + sendInlayHintsRefresh(); + } + }); + } }; let getOpenedFileContent = (fileUri: string) => { let filePath = fileURLToPath(fileUri); @@ -452,7 +418,7 @@ function inlayHint(msg: p.RequestMessage) { filePath, params.range.start.line, params.range.end.line, - extensionConfiguration.inlayHints.maxLength, + config.extensionConfiguration.inlayHints.maxLength, ], msg ); @@ -773,13 +739,13 @@ function format(msg: p.RequestMessage): Array { let code = getOpenedFileContent(params.textDocument.uri); let projectRootPath = utils.findProjectRootOfFile(filePath); - let bscExeBinaryPath = findBscExeBinary(projectRootPath); + let bscExeBinaryPath = utils.findBscExeBinary(projectRootPath); let formattedResult = utils.formatCode( bscExeBinaryPath, filePath, code, - extensionConfiguration.allowBuiltInFormatter + config.extensionConfiguration.allowBuiltInFormatter ); if (formattedResult.kind === "success") { let max = code.length; @@ -828,6 +794,10 @@ function format(msg: p.RequestMessage): Array { } let updateDiagnosticSyntax = (fileUri: string, fileContent: string) => { + if (config.extensionConfiguration.incrementalTypechecking.enabled) { + // The incremental typechecking already sends syntax diagnostics. + return; + } let filePath = fileURLToPath(fileUri); let extension = path.extname(filePath); let tmpname = utils.createFileInTempDir(extension); @@ -1089,7 +1059,7 @@ function onMessage(msg: p.Message) { ?.extensionConfiguration as extensionConfiguration | undefined; if (initialConfiguration != null) { - extensionConfiguration = initialConfiguration; + config.extensionConfiguration = initialConfiguration; } // These are static configuration options the client can set to enable certain @@ -1144,13 +1114,14 @@ function onMessage(msg: p.Message) { // TODO: Support range for full, and add delta support full: true, }, - inlayHintProvider: extensionConfiguration.inlayHints?.enable, - codeLensProvider: extensionConfiguration.codeLens + inlayHintProvider: config.extensionConfiguration.inlayHints?.enable, + codeLensProvider: config.extensionConfiguration.codeLens ? { workDoneProgress: false, } : undefined, - signatureHelpProvider: extensionConfiguration.signatureHelp?.enabled + signatureHelpProvider: config.extensionConfiguration.signatureHelp + ?.enabled ? { triggerCharacters: ["("], retriggerCharacters: ["=", ","], @@ -1278,7 +1249,7 @@ function onMessage(msg: p.Message) { extensionConfiguration | null | undefined ]; if (configuration != null) { - extensionConfiguration = configuration; + config.extensionConfiguration = configuration; } } } else if ( diff --git a/server/src/utils.ts b/server/src/utils.ts index 662c7ef99..4d5549f20 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -13,6 +13,7 @@ import * as codeActions from "./codeActions"; import * as c from "./constants"; import * as lookup from "./lookup"; import { reportError } from "./errorReporter"; +import config from "./config"; let tempFilePrefix = "rescript_format_file_" + process.pid + "_"; let tempFileId = 0; @@ -661,3 +662,33 @@ export let rangeContainsRange = ( } return true; }; + +let findPlatformPath = (projectRootPath: p.DocumentUri | null) => { + if (config.extensionConfiguration.platformPath != null) { + return config.extensionConfiguration.platformPath; + } + + let rescriptDir = lookup.findFilePathFromProjectRoot( + projectRootPath, + path.join("node_modules", "rescript") + ); + if (rescriptDir == null) { + return null; + } + + let platformPath = path.join(rescriptDir, c.platformDir); + + // Workaround for darwinarm64 which has no folder yet in ReScript <= 9.1.4 + if ( + process.platform == "darwin" && + process.arch == "arm64" && + !fs.existsSync(platformPath) + ) { + platformPath = path.join(rescriptDir, process.platform); + } + + return platformPath; +}; + +export let findBscExeBinary = (projectRootPath: p.DocumentUri | null) => + findBinary(findPlatformPath(projectRootPath), c.bscExeName);