diff --git a/CHANGELOG.md b/CHANGELOG.md index 543e509e0..d43b4b5e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,9 @@ #### :bug: Bug fix -- Fix Code Analyzer binary lookup for ReScript v12+ projects. +- Fix Code Analyzer cwd/binary lookup in monorepos (run from workspace root). +- Fix monorepo build detection by only watching the workspace root `.compiler.log`. +- Fix Start Build for ReScript v12+ projects by preferring `rescript.exe`. - Take namespace into account for incremental cleanup. https://github.com/rescript-lang/rescript-vscode/pull/1164 - Potential race condition in incremental compilation. https://github.com/rescript-lang/rescript-vscode/pull/1167 - Fix extension crash triggered by incremental compilation. https://github.com/rescript-lang/rescript-vscode/pull/1169 diff --git a/client/src/commands.ts b/client/src/commands.ts index 6a795c467..0bc131863 100644 --- a/client/src/commands.ts +++ b/client/src/commands.ts @@ -13,14 +13,12 @@ export { pasteAsRescriptJson } from "./commands/paste_as_rescript_json"; export { pasteAsRescriptJsx } from "./commands/paste_as_rescript_jsx"; export const codeAnalysisWithReanalyze = ( - targetDir: string | null, diagnosticsCollection: DiagnosticCollection, diagnosticsResultCodeActions: DiagnosticsResultCodeActionsMap, outputChannel: OutputChannel, codeAnalysisRunningStatusBarItem: StatusBarItem, ) => { runCodeAnalysisWithReanalyze( - targetDir, diagnosticsCollection, diagnosticsResultCodeActions, outputChannel, diff --git a/client/src/commands/code_analysis.ts b/client/src/commands/code_analysis.ts index 559f89c67..241d16c98 100644 --- a/client/src/commands/code_analysis.ts +++ b/client/src/commands/code_analysis.ts @@ -15,11 +15,12 @@ import { OutputChannel, StatusBarItem, } from "vscode"; +import { getBinaryPath, NormalizedPath, normalizePath } from "../utils"; import { - findProjectRootOfFileInDir, - getBinaryPath, - NormalizedPath, -} from "../utils"; + findBinary, + findBinary as findSharedBinary, +} from "../../../shared/src/findBinary"; +import { findProjectRootOfFile } from "../../../shared/src/projectRoots"; export let statusBarItem = { setToStopText: (codeAnalysisRunningStatusBarItem: StatusBarItem) => { @@ -203,40 +204,26 @@ let resultsToDiagnostics = ( }; }; -export const runCodeAnalysisWithReanalyze = ( - targetDir: string | null, +export const runCodeAnalysisWithReanalyze = async ( diagnosticsCollection: DiagnosticCollection, diagnosticsResultCodeActions: DiagnosticsResultCodeActionsMap, outputChannel: OutputChannel, codeAnalysisRunningStatusBarItem: StatusBarItem, ) => { let currentDocument = window.activeTextEditor.document; - let cwd = targetDir ?? path.dirname(currentDocument.uri.fsPath); - let projectRootPath: NormalizedPath | null = findProjectRootOfFileInDir( - currentDocument.uri.fsPath, + let projectRootPath: NormalizedPath | null = normalizePath( + findProjectRootOfFile(currentDocument.uri.fsPath), ); - - // Try v12+ path first: @rescript/{platform}-{arch}/bin/rescript-tools.exe - // Then fall back to legacy paths via getBinaryPath - let binaryPath: string | null = null; - if (projectRootPath != null) { - const v12Path = path.join( - projectRootPath, - "node_modules", - "@rescript", - `${process.platform}-${process.arch}`, - "bin", - "rescript-tools.exe", - ); - if (fs.existsSync(v12Path)) { - binaryPath = v12Path; - } - } + let binaryPath: string | null = await findBinary({ + projectRootPath, + binary: "rescript-tools.exe", + }); if (binaryPath == null) { - binaryPath = - getBinaryPath("rescript-tools.exe", projectRootPath) ?? - getBinaryPath("rescript-editor-analysis.exe", projectRootPath); + binaryPath = await findBinary({ + projectRootPath, + binary: "rescript-editor-analysis.exe", + }); } if (binaryPath === null) { @@ -244,12 +231,14 @@ export const runCodeAnalysisWithReanalyze = ( return; } + // Strip everything after the outermost node_modules segment to get the project root. + let cwd = + binaryPath.match(/^(.*?)[\\/]+node_modules([\\/]+|$)/)?.[1] ?? binaryPath; + statusBarItem.setToRunningText(codeAnalysisRunningStatusBarItem); let opts = ["reanalyze", "-json"]; - let p = cp.spawn(binaryPath, opts, { - cwd, - }); + let p = cp.spawn(binaryPath, opts, { cwd }); if (p.stdout == null) { statusBarItem.setToFailed(codeAnalysisRunningStatusBarItem); diff --git a/client/src/extension.ts b/client/src/extension.ts index c31a8d170..cefe346a5 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -155,7 +155,6 @@ export function activate(context: ExtensionContext) { client.onNotification("rescript/compilationFinished", () => { if (inCodeAnalysisState.active === true) { customCommands.codeAnalysisWithReanalyze( - inCodeAnalysisState.activatedFromDirectory, diagnosticsCollection, diagnosticsResultCodeActions, outputChannel, @@ -308,8 +307,7 @@ export function activate(context: ExtensionContext) { let inCodeAnalysisState: { active: boolean; - activatedFromDirectory: string | null; - } = { active: false, activatedFromDirectory: null }; + } = { active: false }; // This code actions provider yields the code actions potentially extracted // from the code analysis to the editor. @@ -442,20 +440,12 @@ export function activate(context: ExtensionContext) { inCodeAnalysisState.active = true; - // Pointing reanalyze to the dir of the current file path is fine, because - // reanalyze will walk upwards looking for a bsconfig.json in order to find - // the correct project root. - inCodeAnalysisState.activatedFromDirectory = path.dirname( - currentDocument.uri.fsPath, - ); - codeAnalysisRunningStatusBarItem.command = "rescript-vscode.stop_code_analysis"; codeAnalysisRunningStatusBarItem.show(); statusBarItem.setToStopText(codeAnalysisRunningStatusBarItem); customCommands.codeAnalysisWithReanalyze( - inCodeAnalysisState.activatedFromDirectory, diagnosticsCollection, diagnosticsResultCodeActions, outputChannel, @@ -465,7 +455,6 @@ export function activate(context: ExtensionContext) { commands.registerCommand("rescript-vscode.stop_code_analysis", () => { inCodeAnalysisState.active = false; - inCodeAnalysisState.activatedFromDirectory = null; diagnosticsCollection.clear(); diagnosticsResultCodeActions.clear(); diff --git a/client/src/utils.ts b/client/src/utils.ts index 17135d14a..c0c364ff3 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -2,6 +2,11 @@ import * as path from "path"; import * as fs from "fs"; import * as os from "os"; import { DocumentUri } from "vscode-languageclient"; +import { findBinary, type BinaryName } from "../../shared/src/findBinary"; +import { + findProjectRootOfFileInDir as findProjectRootOfFileInDirShared, + normalizePath as normalizePathShared, +} from "../../shared/src/projectRoots"; /* * Much of the code in here is duplicated from the server code. @@ -27,7 +32,7 @@ export type NormalizedPath = string & { __brand: "NormalizedPath" }; */ export function normalizePath(filePath: string | null): NormalizedPath | null { // `path.normalize` ensures we can assume string is now NormalizedPath - return filePath != null ? (path.normalize(filePath) as NormalizedPath) : null; + return normalizePathShared(filePath) as NormalizedPath | null; } type binaryName = "rescript-editor-analysis.exe" | "rescript-tools.exe"; @@ -83,25 +88,7 @@ export const createFileInTempDir = (prefix = "", extension = "") => { export let findProjectRootOfFileInDir = ( source: string, ): NormalizedPath | null => { - const normalizedSource = normalizePath(source); - if (normalizedSource == null) { - return null; - } - const dir = normalizePath(path.dirname(normalizedSource)); - if (dir == null) { - return null; - } - if ( - fs.existsSync(path.join(dir, "rescript.json")) || - fs.existsSync(path.join(dir, "bsconfig.json")) - ) { - return dir; - } else { - if (dir === normalizedSource) { - // reached top - return null; - } else { - return findProjectRootOfFileInDir(dir); - } - } + return normalizePath(findProjectRootOfFileInDirShared(source)); }; + +export { findBinary, BinaryName }; diff --git a/client/tsconfig.json b/client/tsconfig.json index b0924c856..1974b6b8d 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -4,9 +4,9 @@ "target": "es2019", "lib": ["ES2019"], "outDir": "out", - "rootDir": "src", + "rootDirs": ["src", "../shared/src"], "sourceMap": true }, - "include": ["src"], + "include": ["src", "../shared/src"], "exclude": ["node_modules"] } diff --git a/server/src/server.ts b/server/src/server.ts index b4445f68a..7459f1051 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1388,11 +1388,10 @@ async function onMessage(msg: p.Message) { const watchers = Array.from(workspaceFolders).flatMap( (projectRootPath) => [ { - globPattern: path.join( - projectRootPath, - "**", - c.compilerLogPartialPath, - ), + // Only watch the root compiler log for each workspace folder. + // In monorepos, `**/lib/bs/.compiler.log` matches every package and dependency, + // causing a burst of events per save. + globPattern: path.join(projectRootPath, c.compilerLogPartialPath), kind: p.WatchKind.Change | p.WatchKind.Create | p.WatchKind.Delete, }, { diff --git a/server/src/utils.ts b/server/src/utils.ts index a5662cf14..4502ebbd3 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -12,6 +12,11 @@ import * as os from "os"; import semver from "semver"; import { fileURLToPath, pathToFileURL } from "url"; +import { + findBinary as findSharedBinary, + type BinaryName, +} from "../../shared/src/findBinary"; +import { findProjectRootOfFileInDir as findProjectRootOfFileInDirShared } from "../../shared/src/projectRoots"; import * as codeActions from "./codeActions"; import * as c from "./constants"; import * as lookup from "./lookup"; @@ -85,23 +90,7 @@ export let createFileInTempDir = (extension = ""): NormalizedPath => { function findProjectRootOfFileInDir( source: NormalizedPath, ): NormalizedPath | null { - const dir = normalizePath(path.dirname(source)); - if (dir == null) { - return null; - } - if ( - fs.existsSync(path.join(dir, c.rescriptJsonPartialPath)) || - fs.existsSync(path.join(dir, c.bsconfigPartialPath)) - ) { - return dir; - } else { - if (dir === source) { - // reached top - return null; - } else { - return findProjectRootOfFileInDir(dir); - } - } + return normalizePath(findProjectRootOfFileInDirShared(source)); } /** @@ -216,98 +205,14 @@ export let getProjectFile = ( // We won't know which version is in the project root until we read and parse `{project_root}/node_modules/rescript/package.json` let findBinary = async ( projectRootPath: NormalizedPath | null, - binary: - | "bsc.exe" - | "rescript-editor-analysis.exe" - | "rescript" - | "rewatch.exe" - | "rescript.exe", + binary: BinaryName, ): Promise => { - if (config.extensionConfiguration.platformPath != null) { - const result = path.join( - config.extensionConfiguration.platformPath, - binary, - ); - return normalizePath(result); - } - - if (projectRootPath !== null) { - try { - const compilerInfo = path.resolve( - projectRootPath, - c.compilerInfoPartialPath, - ); - const contents = await fsAsync.readFile(compilerInfo, "utf8"); - const compileInfo = JSON.parse(contents); - if (compileInfo && compileInfo.bsc_path) { - const bsc_path = compileInfo.bsc_path; - if (binary === "bsc.exe") { - return normalizePath(bsc_path); - } else { - const binary_path = path.join(path.dirname(bsc_path), binary); - return normalizePath(binary_path); - } - } - } catch {} - } - - const rescriptDir = lookup.findFilePathFromProjectRoot( + const result = await findSharedBinary({ projectRootPath, - path.join("node_modules", "rescript"), - ); - if (rescriptDir == null) { - return null; - } - - let rescriptVersion = null; - let rescriptJSWrapperPath = null; - try { - const rescriptPackageJSONPath = path.join(rescriptDir, "package.json"); - const rescriptPackageJSON = JSON.parse( - await fsAsync.readFile(rescriptPackageJSONPath, "utf-8"), - ); - rescriptVersion = rescriptPackageJSON.version; - rescriptJSWrapperPath = rescriptPackageJSON.bin.rescript; - } catch (error) { - return null; - } - - let binaryPath: string | null = null; - if (binary == "rescript") { - // Can't use the native bsb/rescript since we might need the watcher -w - // flag, which is only in the JS wrapper - binaryPath = path.join(rescriptDir, rescriptJSWrapperPath); - } else if (semver.gte(rescriptVersion, "12.0.0-alpha.13")) { - // TODO: export `binPaths` from `rescript` package so that we don't need to - // copy the logic for figuring out `target`. - const target = `${process.platform}-${process.arch}`; - // Use realpathSync to resolve symlinks, which is necessary for package - // managers like Deno and pnpm that use symlinked node_modules structures. - const targetPackagePath = path.join( - fs.realpathSync(rescriptDir), - "..", - `@rescript/${target}/bin.js`, - ); - const { binPaths } = await import(targetPackagePath); - - if (binary == "bsc.exe") { - binaryPath = binPaths.bsc_exe; - } else if (binary == "rescript-editor-analysis.exe") { - binaryPath = binPaths.rescript_editor_analysis_exe; - } else if (binary == "rewatch.exe") { - binaryPath = binPaths.rewatch_exe; - } else if (binary == "rescript.exe") { - binaryPath = binPaths.rescript_exe; - } - } else { - binaryPath = path.join(rescriptDir, c.platformDir, binary); - } - - if (binaryPath != null && fs.existsSync(binaryPath)) { - return normalizePath(binaryPath); - } else { - return null; - } + binary, + platformPath: config.extensionConfiguration.platformPath ?? null, + }); + return normalizePath(result); }; export let findRescriptBinary = (projectRootPath: NormalizedPath | null) => diff --git a/server/tsconfig.json b/server/tsconfig.json index 5dab877e0..c74b81c04 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -7,9 +7,9 @@ "sourceMap": true, "strict": true, "outDir": "out", - "rootDir": "src", + "rootDirs": ["src", "../shared/src"], "esModuleInterop": true }, - "include": ["src"], + "include": ["src", "../shared/src"], "exclude": ["node_modules"] } diff --git a/shared/src/findBinary.ts b/shared/src/findBinary.ts new file mode 100644 index 000000000..09cd65d6a --- /dev/null +++ b/shared/src/findBinary.ts @@ -0,0 +1,133 @@ +import * as fs from "fs"; +import * as fsAsync from "fs/promises"; +import * as path from "path"; +import * as semver from "semver"; + +export type BinaryName = + | "bsc.exe" + | "rescript-editor-analysis.exe" + | "rescript-tools.exe" + | "rescript" + | "rewatch.exe" + | "rescript.exe"; + +type FindBinaryOptions = { + projectRootPath: string | null; + binary: BinaryName; + platformPath?: string | null; +}; + +const compilerInfoPartialPath = path.join("lib", "bs", "compiler-info.json"); +const platformDir = + process.arch === "arm64" ? process.platform + process.arch : process.platform; + +const normalizePath = (filePath: string | null): string | null => { + return filePath != null ? path.normalize(filePath) : null; +}; + +const findFilePathFromProjectRoot = ( + directory: string | null, + filePartialPath: string, +): string | null => { + if (directory == null) { + return null; + } + + const filePath = path.join(directory, filePartialPath); + if (fs.existsSync(filePath)) { + return normalizePath(filePath); + } + + const parentDirStr = path.dirname(directory); + if (parentDirStr === directory) { + return null; + } + + return findFilePathFromProjectRoot( + normalizePath(parentDirStr), + filePartialPath, + ); +}; + +export const findBinary = async ({ + projectRootPath, + binary, + platformPath, +}: FindBinaryOptions): Promise => { + if (platformPath != null) { + const result = path.join(platformPath, binary); + return normalizePath(result); + } + + if (projectRootPath !== null) { + try { + const compilerInfo = path.resolve( + projectRootPath, + compilerInfoPartialPath, + ); + const contents = await fsAsync.readFile(compilerInfo, "utf8"); + const compileInfo = JSON.parse(contents); + if (compileInfo && compileInfo.bsc_path) { + const bscPath = compileInfo.bsc_path; + if (binary === "bsc.exe") { + return normalizePath(bscPath); + } else { + const binaryPath = path.join(path.dirname(bscPath), binary); + return normalizePath(binaryPath); + } + } + } catch {} + } + + const rescriptDir = findFilePathFromProjectRoot( + projectRootPath, + path.join("node_modules", "rescript"), + ); + if (rescriptDir == null) { + return null; + } + + let rescriptVersion = null; + let rescriptJSWrapperPath = null; + try { + const rescriptPackageJSONPath = path.join(rescriptDir, "package.json"); + const rescriptPackageJSON = JSON.parse( + await fsAsync.readFile(rescriptPackageJSONPath, "utf-8"), + ); + rescriptVersion = rescriptPackageJSON.version; + rescriptJSWrapperPath = rescriptPackageJSON.bin.rescript; + } catch { + return null; + } + + let binaryPath: string | null = null; + if (binary === "rescript") { + binaryPath = path.join(rescriptDir, rescriptJSWrapperPath); + } else if (semver.gte(rescriptVersion, "12.0.0-alpha.13")) { + const target = `${process.platform}-${process.arch}`; + const targetPackagePath = path.join( + fs.realpathSync(rescriptDir), + "..", + `@rescript/${target}/bin.js`, + ); + const { binPaths } = await import(targetPackagePath); + + if (binary === "bsc.exe") { + binaryPath = binPaths.bsc_exe; + } else if (binary === "rescript-editor-analysis.exe") { + binaryPath = binPaths.rescript_editor_analysis_exe; + } else if (binary === "rewatch.exe") { + binaryPath = binPaths.rewatch_exe; + } else if (binary === "rescript.exe") { + binaryPath = binPaths.rescript_exe; + } + } else { + binaryPath = path.join(rescriptDir, platformDir, binary); + } + + if (binaryPath != null && fs.existsSync(binaryPath)) { + return normalizePath(binaryPath); + } + + return null; +}; diff --git a/shared/src/projectRoots.ts b/shared/src/projectRoots.ts new file mode 100644 index 000000000..2ddd8a08f --- /dev/null +++ b/shared/src/projectRoots.ts @@ -0,0 +1,40 @@ +import * as fs from "fs"; +import * as path from "path"; + +export const normalizePath = (filePath: string | null): string | null => { + return filePath != null ? path.normalize(filePath) : null; +}; + +export const findProjectRootOfFileInDir = (source: string): string | null => { + const normalizedSource = normalizePath(source); + if (normalizedSource == null) { + return null; + } + + const dir = normalizePath(path.dirname(normalizedSource)); + if (dir == null) { + return null; + } + + if ( + fs.existsSync(path.join(dir, "rescript.json")) || + fs.existsSync(path.join(dir, "bsconfig.json")) + ) { + return dir; + } + + if (dir === normalizedSource) { + return null; + } + + return findProjectRootOfFileInDir(dir); +}; + +export const findProjectRootOfFile = (source: string): string | null => { + const normalizedSource = normalizePath(source); + if (normalizedSource == null) { + return null; + } + + return findProjectRootOfFileInDir(normalizedSource); +}; diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 000000000..b0924c856 --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2019", + "lib": ["ES2019"], + "outDir": "out", + "rootDir": "src", + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/tsconfig.json b/tsconfig.json index 0f5dff06a..7cdfe7c07 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,9 @@ }, { "path": "./server" + }, + { + "path": "./shared" } ] }