From 8c39cca20dce31daa4862f580dc6a6a07225e3cf Mon Sep 17 00:00:00 2001 From: Cristiano Calcagno Date: Tue, 27 Jan 2026 11:22:12 +0100 Subject: [PATCH] Add reanalyze server support, monorepo build support, and comprehensive integration tests. ## Summary Add reanalyze server support, monorepo build support, and comprehensive integration tests. --- ## 1. Fixes ### ARM64 binary lookup fallback **File:** `shared/src/findBinary.ts` - For ARM64 architecture, try the arm64-specific directory first (e.g., `darwinarm64`), then fall back to the generic platform directory (e.g., `darwin`) for older ReScript versions that don't have arm64-specific directories ### Fixed "rescript" JS wrapper lookup **File:** `shared/src/findBinary.ts` - For the "rescript" JS wrapper (as opposed to native binaries like `bsc.exe`), don't use the `bsc_path` directory from `compiler-info.json` - fall through to find the JS wrapper in node_modules/.bin instead ### Build watcher process cleanup **File:** `server/src/utils.ts` - Added `killBuildWatcher()` function that properly kills the entire process group (not just the parent process) and cleans up lock files - Uses `process.kill(-proc.pid, "SIGTERM")` on Unix to kill the entire process group, ensuring child processes (like the native rescript binary) are also killed - The rescript compiler doesn't remove lock files on SIGTERM, so we now clean them up manually ### Build watcher stdin handling for older ReScript **File:** `server/src/utils.ts` - Use "pipe" for stdin instead of "ignore" because older ReScript versions (9.x, 10.x, 11.x) have a handler that exits when stdin closes: `process.stdin.on("close", exitProcess)` ### Server shutdown cleanup **File:** `server/src/server.ts` - Kill all build watchers and clean up lock files on LSP server shutdown --- ## 2. New Functionality ### Reanalyze Server Support (ReScript >= 12.1.0) **Files:** `client/src/commands/code_analysis.ts`, `client/src/commands.ts`, `client/src/extension.ts` - Added persistent reanalyze-server support for ReScript >= 12.1.0 (uses Unix socket for incremental analysis) - Server state management per monorepo root - Functions: `startReanalyzeServer()`, `stopReanalyzeServer()`, `stopAllReanalyzeServers()`, `showReanalyzeServerLog()` - Dedicated output channels for server logs per project - Automatic socket file cleanup - Servers are stopped when code analysis is stopped or extension deactivates ### New Command: Show Reanalyze Server Log **Files:** `client/src/extension.ts`, `package.json` - New command: `rescript-vscode.show_reanalyze_server_log` - Displays the reanalyze server output channel for debugging ### New Command: Start Build **Files:** `client/src/extension.ts`, `server/src/server.ts`, `package.json` - New command: `rescript-vscode.start_build` - Allows manually starting the build watcher without waiting for the prompt - LSP request handler: `rescript/startBuild` ### Monorepo Support **Files:** `shared/src/findBinary.ts`, `server/src/server.ts`, `server/src/utils.ts`, `client/src/commands/code_analysis.ts` - New function `getMonorepoRootFromBinaryPath()` to derive monorepo root from binary path - Build watcher now runs from monorepo root, not subpackage directory - Lock file detection at monorepo root level (checks both `lib/rescript.lock` and `.bsb.lock`) - Build prompt correctly uses monorepo root path when opening files from subpackages - Tracks `buildRootPath` separately from `projectRootPath` for proper cleanup ### ReScript Version-Aware Build Command **File:** `server/src/utils.ts` - Uses `rescript watch` for ReScript >= 12.0.0 - Uses `rescript build -w` for ReScript < 12.0.0 ### Improved Error Handling and Logging **Files:** Multiple - Better error messages when binaries are not found - Logging for build watcher process lifecycle (start, error, exit) - Logging for file open/close events --- ## 3. Tests ### Test Infrastructure **Files:** `client/package.json`, `client/.vscode-test.mjs`, `client/tsconfig.json` - Added test dependencies: `@types/mocha`, `@vscode/test-cli`, `@vscode/test-electron` - Test configuration for 4 test suites with different workspace folders - Added `skipLibCheck: true` to tsconfig for test type compatibility ### Test Helpers (`client/src/test/suite/helpers.ts`) Shared utilities for all test suites: - `getWorkspaceRoot()` - Get workspace folder path - `removeRescriptLockFile()`, `removeBsbLockFile()`, `removeMonorepoLockFiles()` - Lock file cleanup - `removeReanalyzeSocketFile()` - Socket file cleanup - `ensureExtensionActivated()` - Ensure extension is active - `openFile()` - Open file in editor - `findLspLogContent()` - Read LSP logs for verification - `waitFor()`, `sleep()` - Async utilities - `getCompilerLogPath()`, `getFileMtime()`, `waitForFileUpdate()` - File modification tracking - `insertCommentAndSave()`, `restoreContentAndSave()` - Edit and restore files - `startBuildWatcher()`, `startCodeAnalysis()`, `stopCodeAnalysis()` - Command execution - `showReanalyzeServerLog()`, `findBuildPromptInLogs()` - Verification helpers ### Example Project Tests (`client/src/test/suite/exampleProject.test.ts`) Tests against `analysis/examples/example-project/` (ReScript 12.1.0): 1. **Extension should be present** - Verify extension loads 2. **Commands should be registered** - Verify all new commands are registered 3. **Start Code Analysis should run** - Run code analysis on a ReScript file 4. **Start Build command should start build watcher** - Manual build start 5. **Build watcher recompiles on file save** - Edit file, verify compiler.log updates 6. **Code analysis with incremental updates** - Add dead code, verify new diagnostics appear 7. **Should prompt to start build when no lock file exists** - Verify build prompt ### Monorepo Root Tests (`client/src/test/suite/monorepoRoot.test.ts`) Tests against `analysis/examples/monorepo-project/` opened at root: 1. **Build watcher works when opening root package** - Build from root, verify compiler.log 2. **Build watcher works when opening subpackage file** - Open subpackage file, build still uses root compiler.log 3. **Code analysis works from subpackage** - Run reanalyze from lib package 4. **Should prompt to start build with monorepo root path** - Open subpackage, prompt uses root path ### Monorepo Subpackage Tests (`client/src/test/suite/monorepoSubpackage.test.ts`) Tests against `analysis/examples/monorepo-project/packages/app/` (VSCode opened on subpackage): 1. **Should prompt to start build with monorepo root path** - Even when opened from subpackage, prompt uses monorepo root 2. **Lock file created at monorepo root, not subpackage** - Verify lock file location 3. **Code analysis works when opened from subpackage** - Diagnostics shown correctly ### ReScript 9 Tests (`client/src/test/suite/rescript9.test.ts`) Tests against `analysis/examples/rescript9-project/` (ReScript 9.x): 1. **Extension should be present** - Extension loads with older ReScript 2. **Build watcher should start with 'rescript build -w'** - Not 'rescript watch' 3. **Build watcher recompiles on file save** - Verify incremental compilation 4. **Should prompt to start build when no lock file exists** - Uses `.bsb.lock` 5. **Lock file should be cleaned up on language server restart** - Verify cleanup ### Test Projects Added - `analysis/examples/monorepo-project/` - Monorepo with root package + packages/app + packages/lib - `analysis/examples/rescript9-project/` - ReScript 9.x project with bsconfig.json - Updated `analysis/examples/example-project/` to use ReScript 12.1.0 --- ## Other Changes ### .gitignore - Added `.vscode-test/` directory (VSCode test downloads) ### package.json (root) - Added two new commands to the extension manifest: - `rescript-vscode.show_reanalyze_server_log` - "ReScript: Show Code Analyzer Server Log" - `rescript-vscode.start_build` - "ReScript: Start Build" --- .gitignore | 5 +- .../example-project/.vscode/settings.json | 3 + .../examples/example-project/package.json | 8 +- analysis/examples/monorepo-project/.gitignore | 2 + .../monorepo-project/.vscode/settings.json | 3 + .../monorepo-project/package-lock.json | 152 ++++++++ .../examples/monorepo-project/package.json | 16 + .../packages/app/.vscode/settings.json | 3 + .../packages/app/package.json | 12 + .../packages/app/rescript.json | 13 + .../monorepo-project/packages/app/src/App.mjs | 22 ++ .../monorepo-project/packages/app/src/App.res | 16 + .../examples/monorepo-project/rescript.json | 16 + .../examples/monorepo-project/src/Root.mjs | 15 + .../examples/monorepo-project/src/Root.res | 11 + .../examples/rescript9-project/.gitignore | 2 + .../rescript9-project/.vscode/settings.json | 3 + .../examples/rescript9-project/bsconfig.json | 9 + .../rescript9-project/package-lock.json | 28 ++ .../examples/rescript9-project/package.json | 12 + .../rescript9-project/src/Hello.bs.js | 18 + .../examples/rescript9-project/src/Hello.res | 6 + client/.vscode-test.mjs | 65 ++++ client/package.json | 8 + client/src/commands.ts | 16 +- client/src/commands/code_analysis.ts | 330 ++++++++++++++++-- client/src/extension.ts | 66 +++- client/src/test/suite/exampleProject.test.ts | 328 +++++++++++++++++ client/src/test/suite/helpers.ts | 324 +++++++++++++++++ client/src/test/suite/monorepoRoot.test.ts | 274 +++++++++++++++ .../src/test/suite/monorepoSubpackage.test.ts | 153 ++++++++ client/src/test/suite/rescript9.test.ts | 241 +++++++++++++ client/tsconfig.json | 3 +- package.json | 8 + server/src/projectFiles.ts | 3 + server/src/server.ts | 183 +++++++++- server/src/utils.ts | 101 +++++- shared/src/findBinary.ts | 39 ++- 38 files changed, 2460 insertions(+), 57 deletions(-) create mode 100644 analysis/examples/example-project/.vscode/settings.json create mode 100644 analysis/examples/monorepo-project/.gitignore create mode 100644 analysis/examples/monorepo-project/.vscode/settings.json create mode 100644 analysis/examples/monorepo-project/package-lock.json create mode 100644 analysis/examples/monorepo-project/package.json create mode 100644 analysis/examples/monorepo-project/packages/app/.vscode/settings.json create mode 100644 analysis/examples/monorepo-project/packages/app/package.json create mode 100644 analysis/examples/monorepo-project/packages/app/rescript.json create mode 100644 analysis/examples/monorepo-project/packages/app/src/App.mjs create mode 100644 analysis/examples/monorepo-project/packages/app/src/App.res create mode 100644 analysis/examples/monorepo-project/rescript.json create mode 100644 analysis/examples/monorepo-project/src/Root.mjs create mode 100644 analysis/examples/monorepo-project/src/Root.res create mode 100644 analysis/examples/rescript9-project/.gitignore create mode 100644 analysis/examples/rescript9-project/.vscode/settings.json create mode 100644 analysis/examples/rescript9-project/bsconfig.json create mode 100644 analysis/examples/rescript9-project/package-lock.json create mode 100644 analysis/examples/rescript9-project/package.json create mode 100644 analysis/examples/rescript9-project/src/Hello.bs.js create mode 100644 analysis/examples/rescript9-project/src/Hello.res create mode 100644 client/.vscode-test.mjs create mode 100644 client/src/test/suite/exampleProject.test.ts create mode 100644 client/src/test/suite/helpers.ts create mode 100644 client/src/test/suite/monorepoRoot.test.ts create mode 100644 client/src/test/suite/monorepoSubpackage.test.ts create mode 100644 client/src/test/suite/rescript9.test.ts diff --git a/.gitignore b/.gitignore index 5dc814def..77951f065 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ rescript-tools.exe _opam/ _build/ -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo + +# VSCode test downloads +.vscode-test/ \ No newline at end of file diff --git a/analysis/examples/example-project/.vscode/settings.json b/analysis/examples/example-project/.vscode/settings.json new file mode 100644 index 000000000..6c4df7cf5 --- /dev/null +++ b/analysis/examples/example-project/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rescript.settings.logLevel": "log" +} diff --git a/analysis/examples/example-project/package.json b/analysis/examples/example-project/package.json index 7f60ced76..39e890488 100644 --- a/analysis/examples/example-project/package.json +++ b/analysis/examples/example-project/package.json @@ -2,12 +2,12 @@ "name": "tryit", "dependencies": { "@rescript/react": "^0.14.0", - "rescript": "12.0.2" + "rescript": "12.1.0" }, "scripts": { - "build": "rescript-legacy", - "start": "rescript-legacy build -w", - "clean": "rescript-legacy clean -with-deps", + "build": "rescript", + "start": "rescript build -w", + "clean": "rescript clean", "format": "rescript format" } } diff --git a/analysis/examples/monorepo-project/.gitignore b/analysis/examples/monorepo-project/.gitignore new file mode 100644 index 000000000..d66a081c5 --- /dev/null +++ b/analysis/examples/monorepo-project/.gitignore @@ -0,0 +1,2 @@ +lib +.merlin diff --git a/analysis/examples/monorepo-project/.vscode/settings.json b/analysis/examples/monorepo-project/.vscode/settings.json new file mode 100644 index 000000000..6c4df7cf5 --- /dev/null +++ b/analysis/examples/monorepo-project/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rescript.settings.logLevel": "log" +} diff --git a/analysis/examples/monorepo-project/package-lock.json b/analysis/examples/monorepo-project/package-lock.json new file mode 100644 index 000000000..c79b0d66a --- /dev/null +++ b/analysis/examples/monorepo-project/package-lock.json @@ -0,0 +1,152 @@ +{ + "name": "monorepo-project", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "monorepo-project", + "workspaces": [ + "packages/app", + "packages/lib" + ], + "dependencies": { + "rescript": "12.1.0" + } + }, + "node_modules/@monorepo/app": { + "resolved": "packages/app", + "link": true + }, + "node_modules/@monorepo/lib": { + "resolved": "packages/lib", + "link": true + }, + "node_modules/@rescript/darwin-arm64": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@rescript/darwin-arm64/-/darwin-arm64-12.1.0.tgz", + "integrity": "sha512-OuJMT+2h2Lp60n8ONFx1oBAAePSVkM9zl7E/EX4VD2xkQoVTPklz0BpHYOICnFJSCOOdbOhbsTBXdLpo3yvllg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/@rescript/darwin-x64": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@rescript/darwin-x64/-/darwin-x64-12.1.0.tgz", + "integrity": "sha512-r5Iv4ga+LaNq+6g9LODwZG4bwydd9UDXACP/HKxOfrP9XQCITlF/XqB1ZDJWyJOgJLZSJCd7erlG38YtB0VZKA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/@rescript/linux-arm64": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@rescript/linux-arm64/-/linux-arm64-12.1.0.tgz", + "integrity": "sha512-UTZv4GTjbyQ/T5LQDfQiGcumK3SzE1K7+ug6gWpDcGZ7ALc7hCS6BVEFL/LDs8iWVwAwkK/6r456s2zRnvS7wQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/@rescript/linux-x64": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@rescript/linux-x64/-/linux-x64-12.1.0.tgz", + "integrity": "sha512-c0PXuBL09JRSA4nQusYbR4mW5QJrBPqxDrqvIX+M79fk3d6jQmj5x4NsBwk5BavxvmbR/JU1JjYBlSAa3h22Vg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/@rescript/runtime": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@rescript/runtime/-/runtime-12.1.0.tgz", + "integrity": "sha512-bvr9RfvBD+JS/6foWCA4l2fLXmUXN0KGqylXQPHt09QxUghqgoCiaWVHaHSx5dOIk/jAPlGQ7zB5yVeMas/EFQ==" + }, + "node_modules/@rescript/win32-x64": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@rescript/win32-x64/-/win32-x64-12.1.0.tgz", + "integrity": "sha512-nQC42QByyAbryfkbyK67iskipUqXVwTPCFrqissY4jJoP0128gg0yG6DydJnV1stXphtFdMFHtmyYE1ffG7UBg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/rescript": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/rescript/-/rescript-12.1.0.tgz", + "integrity": "sha512-n/B43wzIEKV4OmlrWbrlQOL4zZaz0RM/Cc8PG2YvhQvQDW7nscHJliDq1AGeVwHoMX68MeaKKzLDOMOMU9Z6FA==", + "license": "SEE LICENSE IN LICENSE", + "workspaces": [ + "packages/playground", + "packages/@rescript/*", + "tests/dependencies/**", + "tests/analysis_tests/**", + "tests/docstring_tests", + "tests/gentype_tests/**", + "tests/tools_tests", + "scripts/res" + ], + "dependencies": { + "@rescript/runtime": "12.1.0" + }, + "bin": { + "bsc": "cli/bsc.js", + "bstracing": "cli/bstracing.js", + "rescript": "cli/rescript.js", + "rescript-legacy": "cli/rescript-legacy.js", + "rescript-tools": "cli/rescript-tools.js" + }, + "engines": { + "node": ">=20.11.0" + }, + "optionalDependencies": { + "@rescript/darwin-arm64": "12.1.0", + "@rescript/darwin-x64": "12.1.0", + "@rescript/linux-arm64": "12.1.0", + "@rescript/linux-x64": "12.1.0", + "@rescript/win32-x64": "12.1.0" + } + }, + "packages/app": { + "name": "@monorepo/app", + "version": "0.0.1", + "dependencies": { + "@monorepo/lib": "*" + } + }, + "packages/lib": { + "name": "@monorepo/lib", + "version": "0.0.1" + } + } +} diff --git a/analysis/examples/monorepo-project/package.json b/analysis/examples/monorepo-project/package.json new file mode 100644 index 000000000..b516ef9fa --- /dev/null +++ b/analysis/examples/monorepo-project/package.json @@ -0,0 +1,16 @@ +{ + "name": "monorepo-project", + "private": true, + "workspaces": [ + "packages/app", + "packages/lib" + ], + "dependencies": { + "rescript": "12.1.0" + }, + "scripts": { + "build": "rescript build", + "watch": "rescript watch", + "clean": "rescript clean" + } +} diff --git a/analysis/examples/monorepo-project/packages/app/.vscode/settings.json b/analysis/examples/monorepo-project/packages/app/.vscode/settings.json new file mode 100644 index 000000000..6c4df7cf5 --- /dev/null +++ b/analysis/examples/monorepo-project/packages/app/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rescript.settings.logLevel": "log" +} diff --git a/analysis/examples/monorepo-project/packages/app/package.json b/analysis/examples/monorepo-project/packages/app/package.json new file mode 100644 index 000000000..ce8a9af6d --- /dev/null +++ b/analysis/examples/monorepo-project/packages/app/package.json @@ -0,0 +1,12 @@ +{ + "name": "@monorepo/app", + "version": "0.0.1", + "scripts": { + "build": "rescript build", + "clean": "rescript clean", + "watch": "rescript watch" + }, + "dependencies": { + "@monorepo/lib": "*" + } +} diff --git a/analysis/examples/monorepo-project/packages/app/rescript.json b/analysis/examples/monorepo-project/packages/app/rescript.json new file mode 100644 index 000000000..81c0e2bd7 --- /dev/null +++ b/analysis/examples/monorepo-project/packages/app/rescript.json @@ -0,0 +1,13 @@ +{ + "name": "@monorepo/app", + "sources": { + "dir": "src", + "subdirs": true + }, + "package-specs": { + "module": "esmodule", + "in-source": true + }, + "suffix": ".mjs", + "dependencies": ["@monorepo/lib"] +} diff --git a/analysis/examples/monorepo-project/packages/app/src/App.mjs b/analysis/examples/monorepo-project/packages/app/src/App.mjs new file mode 100644 index 000000000..e91955003 --- /dev/null +++ b/analysis/examples/monorepo-project/packages/app/src/App.mjs @@ -0,0 +1,22 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Lib from "@monorepo/lib/src/Lib.mjs"; + +function main() { + let greeting = Lib.greet("World"); + console.log(greeting); + let sum = Lib.add(1, 2); + console.log("Sum: " + sum.toString()); +} + +function unusedAppFunction() { + return "Unused in app"; +} + +main(); + +export { + main, + unusedAppFunction, +} +/* Not a pure module */ diff --git a/analysis/examples/monorepo-project/packages/app/src/App.res b/analysis/examples/monorepo-project/packages/app/src/App.res new file mode 100644 index 000000000..22fb2dd11 --- /dev/null +++ b/analysis/examples/monorepo-project/packages/app/src/App.res @@ -0,0 +1,16 @@ +/* monorepo subpackage test */ +// App module - main application + +let main = () => { + let greeting = Lib.greet("World") + Console.log(greeting) + + let sum = Lib.add(1, 2) + Console.log("Sum: " ++ Int.toString(sum)) +} + +// This function is never used (dead code) +let unusedAppFunction = () => "Unused in app" + +// Run main +let _ = main() diff --git a/analysis/examples/monorepo-project/rescript.json b/analysis/examples/monorepo-project/rescript.json new file mode 100644 index 000000000..b22b4976e --- /dev/null +++ b/analysis/examples/monorepo-project/rescript.json @@ -0,0 +1,16 @@ +{ + "name": "monorepo-project", + "sources": { + "dir": "src", + "subdirs": true + }, + "package-specs": { + "module": "esmodule", + "in-source": true + }, + "suffix": ".mjs", + "dependencies": [ + "@monorepo/app", + "@monorepo/lib" + ] +} diff --git a/analysis/examples/monorepo-project/src/Root.mjs b/analysis/examples/monorepo-project/src/Root.mjs new file mode 100644 index 000000000..201f61b4b --- /dev/null +++ b/analysis/examples/monorepo-project/src/Root.mjs @@ -0,0 +1,15 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as App from "@monorepo/app/src/App.mjs"; + +App.main(); + +let rootValue = "Root package"; + +let unusedRootValue = 123; + +export { + rootValue, + unusedRootValue, +} +/* Not a pure module */ diff --git a/analysis/examples/monorepo-project/src/Root.res b/analysis/examples/monorepo-project/src/Root.res new file mode 100644 index 000000000..eb3da731e --- /dev/null +++ b/analysis/examples/monorepo-project/src/Root.res @@ -0,0 +1,11 @@ +// Root module - monorepo root + +let rootValue = "Root package" + +// Use something from the app package +let _ = App.main() + +// This is unused (dead code) +let unusedRootValue = 123 + +// let _ = App.unusedAppFunction \ No newline at end of file diff --git a/analysis/examples/rescript9-project/.gitignore b/analysis/examples/rescript9-project/.gitignore new file mode 100644 index 000000000..d66a081c5 --- /dev/null +++ b/analysis/examples/rescript9-project/.gitignore @@ -0,0 +1,2 @@ +lib +.merlin diff --git a/analysis/examples/rescript9-project/.vscode/settings.json b/analysis/examples/rescript9-project/.vscode/settings.json new file mode 100644 index 000000000..6c4df7cf5 --- /dev/null +++ b/analysis/examples/rescript9-project/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rescript.settings.logLevel": "log" +} diff --git a/analysis/examples/rescript9-project/bsconfig.json b/analysis/examples/rescript9-project/bsconfig.json new file mode 100644 index 000000000..d594590e1 --- /dev/null +++ b/analysis/examples/rescript9-project/bsconfig.json @@ -0,0 +1,9 @@ +{ + "name": "rescript9-project", + "sources": ["src"], + "package-specs": { + "module": "es6", + "in-source": true + }, + "suffix": ".bs.js" +} diff --git a/analysis/examples/rescript9-project/package-lock.json b/analysis/examples/rescript9-project/package-lock.json new file mode 100644 index 000000000..7d9d5c10a --- /dev/null +++ b/analysis/examples/rescript9-project/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "rescript9-project", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rescript9-project", + "version": "1.0.0", + "dependencies": { + "rescript": "9.1.4" + } + }, + "node_modules/rescript": { + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/rescript/-/rescript-9.1.4.tgz", + "integrity": "sha512-aXANK4IqecJzdnDpJUsU6pxMViCR5ogAxzuqS0mOr8TloMnzAjJFu63fjD6LCkWrKAhlMkFFzQvVQYaAaVkFXw==", + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE", + "bin": { + "bsc": "bsc", + "bsrefmt": "bsrefmt", + "bstracing": "lib/bstracing", + "rescript": "rescript" + } + } + } +} diff --git a/analysis/examples/rescript9-project/package.json b/analysis/examples/rescript9-project/package.json new file mode 100644 index 000000000..2571ffabe --- /dev/null +++ b/analysis/examples/rescript9-project/package.json @@ -0,0 +1,12 @@ +{ + "name": "rescript9-project", + "version": "1.0.0", + "scripts": { + "build": "rescript build", + "watch": "rescript build -w", + "clean": "rescript clean" + }, + "dependencies": { + "rescript": "9.1.4" + } +} diff --git a/analysis/examples/rescript9-project/src/Hello.bs.js b/analysis/examples/rescript9-project/src/Hello.bs.js new file mode 100644 index 000000000..0edc63d78 --- /dev/null +++ b/analysis/examples/rescript9-project/src/Hello.bs.js @@ -0,0 +1,18 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + + +function add(a, b) { + return a + b | 0; +} + +var greeting = "Hello from ReScript 9!"; + +var result = 3; + +export { + greeting , + add , + result , + +} +/* No side effect */ diff --git a/analysis/examples/rescript9-project/src/Hello.res b/analysis/examples/rescript9-project/src/Hello.res new file mode 100644 index 000000000..8dace8fd7 --- /dev/null +++ b/analysis/examples/rescript9-project/src/Hello.res @@ -0,0 +1,6 @@ +// Simple ReScript 9 test file +let greeting = "Hello from ReScript 9!" + +let add = (a, b) => a + b + +let result = add(1, 2) diff --git a/client/.vscode-test.mjs b/client/.vscode-test.mjs new file mode 100644 index 000000000..4bdfbda6b --- /dev/null +++ b/client/.vscode-test.mjs @@ -0,0 +1,65 @@ +import { defineConfig } from "@vscode/test-cli"; +import * as path from "path"; + +export default defineConfig([ + { + label: "example-project", + files: "out/client/src/test/suite/exampleProject.test.js", + version: "stable", + extensionDevelopmentPath: path.resolve(import.meta.dirname, ".."), + workspaceFolder: path.resolve( + import.meta.dirname, + "../analysis/examples/example-project", + ), + mocha: { + ui: "tdd", + timeout: 60000, + }, + launchArgs: ["--disable-extensions"], + }, + { + label: "monorepo-root", + files: "out/client/src/test/suite/monorepoRoot.test.js", + version: "stable", + extensionDevelopmentPath: path.resolve(import.meta.dirname, ".."), + workspaceFolder: path.resolve( + import.meta.dirname, + "../analysis/examples/monorepo-project", + ), + mocha: { + ui: "tdd", + timeout: 60000, + }, + launchArgs: ["--disable-extensions"], + }, + { + label: "monorepo-subpackage", + files: "out/client/src/test/suite/monorepoSubpackage.test.js", + version: "stable", + extensionDevelopmentPath: path.resolve(import.meta.dirname, ".."), + workspaceFolder: path.resolve( + import.meta.dirname, + "../analysis/examples/monorepo-project/packages/app", + ), + mocha: { + ui: "tdd", + timeout: 60000, + }, + launchArgs: ["--disable-extensions"], + }, + { + label: "rescript9-project", + files: "out/client/src/test/suite/rescript9.test.js", + version: "stable", + extensionDevelopmentPath: path.resolve(import.meta.dirname, ".."), + workspaceFolder: path.resolve( + import.meta.dirname, + "../analysis/examples/rescript9-project", + ), + mocha: { + ui: "tdd", + timeout: 60000, + }, + launchArgs: ["--disable-extensions"], + }, +]); diff --git a/client/package.json b/client/package.json index 6f5dc88b8..3a74fdb60 100644 --- a/client/package.json +++ b/client/package.json @@ -6,7 +6,15 @@ "keywords": [], "author": "ReScript Team", "license": "MIT", + "scripts": { + "test": "vscode-test" + }, "dependencies": { "vscode-languageclient": "8.1.0-next.5" + }, + "devDependencies": { + "@types/mocha": "^10.0.10", + "@vscode/test-cli": "^0.0.12", + "@vscode/test-electron": "^2.5.2" } } diff --git a/client/src/commands.ts b/client/src/commands.ts index 0bc131863..f6e97235b 100644 --- a/client/src/commands.ts +++ b/client/src/commands.ts @@ -3,8 +3,19 @@ import { DiagnosticCollection, OutputChannel, StatusBarItem } from "vscode"; import { DiagnosticsResultCodeActionsMap, runCodeAnalysisWithReanalyze, + reanalyzeServers, + stopReanalyzeServer, + stopAllReanalyzeServers, + showReanalyzeServerLog, } from "./commands/code_analysis"; +export { + reanalyzeServers, + stopReanalyzeServer, + stopAllReanalyzeServers, + showReanalyzeServerLog, +}; + export { createInterface } from "./commands/create_interface"; export { openCompiled } from "./commands/open_compiled"; export { switchImplIntf } from "./commands/switch_impl_intf"; @@ -12,13 +23,14 @@ export { dumpDebug, dumpDebugRetrigger } from "./commands/dump_debug"; export { pasteAsRescriptJson } from "./commands/paste_as_rescript_json"; export { pasteAsRescriptJsx } from "./commands/paste_as_rescript_jsx"; +// Returns the monorepo root path if a reanalyze server was started, null otherwise. export const codeAnalysisWithReanalyze = ( diagnosticsCollection: DiagnosticCollection, diagnosticsResultCodeActions: DiagnosticsResultCodeActionsMap, outputChannel: OutputChannel, codeAnalysisRunningStatusBarItem: StatusBarItem, -) => { - runCodeAnalysisWithReanalyze( +): Promise => { + return runCodeAnalysisWithReanalyze( diagnosticsCollection, diagnosticsResultCodeActions, outputChannel, diff --git a/client/src/commands/code_analysis.ts b/client/src/commands/code_analysis.ts index 241d16c98..1fe197af2 100644 --- a/client/src/commands/code_analysis.ts +++ b/client/src/commands/code_analysis.ts @@ -1,6 +1,7 @@ import * as cp from "child_process"; import * as fs from "fs"; import * as path from "path"; +import * as semver from "semver"; import { window, DiagnosticCollection, @@ -15,13 +16,260 @@ import { OutputChannel, StatusBarItem, } from "vscode"; -import { getBinaryPath, NormalizedPath, normalizePath } from "../utils"; +import { NormalizedPath, normalizePath } from "../utils"; import { findBinary, - findBinary as findSharedBinary, + getMonorepoRootFromBinaryPath, } from "../../../shared/src/findBinary"; import { findProjectRootOfFile } from "../../../shared/src/projectRoots"; +// Reanalyze server constants (matches rescript monorepo) +const REANALYZE_SOCKET_FILENAME = ".rescript-reanalyze.sock"; +const REANALYZE_SERVER_MIN_VERSION = "12.1.0"; + +// Server state per monorepo root +export interface ReanalyzeServerState { + process: cp.ChildProcess | null; + monorepoRoot: string; + socketPath: string; + startedByUs: boolean; + outputChannel: OutputChannel | null; +} + +// Map from monorepo root to server state +export const reanalyzeServers: Map = new Map(); + +// Check if ReScript version supports reanalyze-server +const supportsReanalyzeServer = async ( + monorepoRootPath: string | null, +): Promise => { + if (monorepoRootPath === null) return false; + + try { + const rescriptDir = path.join(monorepoRootPath, "node_modules", "rescript"); + const packageJsonPath = path.join(rescriptDir, "package.json"); + const packageJson = JSON.parse( + await fs.promises.readFile(packageJsonPath, "utf-8"), + ); + const version = packageJson.version; + + return ( + semver.valid(version) != null && + semver.gte(version, REANALYZE_SERVER_MIN_VERSION) + ); + } catch { + return false; + } +}; + +// Get socket path for a monorepo root +const getSocketPath = (monorepoRoot: string): string => { + return path.join(monorepoRoot, REANALYZE_SOCKET_FILENAME); +}; + +// Check if server is running (socket file exists) +const isServerRunning = (monorepoRoot: string): boolean => { + const socketPath = getSocketPath(monorepoRoot); + return fs.existsSync(socketPath); +}; + +// Start reanalyze server for a monorepo. +// Note: This should only be called after supportsReanalyzeServer() returns true, +// which ensures ReScript >= 12.1.0 where the reanalyze-server subcommand exists. +export const startReanalyzeServer = async ( + monorepoRoot: string, + binaryPath: string, + clientOutputChannel?: OutputChannel, +): Promise => { + // Check if already running (either by us or externally) + if (isServerRunning(monorepoRoot)) { + // Check if we have a record of starting it + const existing = reanalyzeServers.get(monorepoRoot); + if (existing) { + existing.outputChannel?.appendLine( + "[info] Server already running (started by us)", + ); + return existing; + } + // Server running but not started by us - just record it + clientOutputChannel?.appendLine( + `[info] Found existing reanalyze-server for ${path.basename(monorepoRoot)} (not started by extension)`, + ); + const state: ReanalyzeServerState = { + process: null, + monorepoRoot, + socketPath: getSocketPath(monorepoRoot), + startedByUs: false, + outputChannel: null, + }; + reanalyzeServers.set(monorepoRoot, state); + return state; + } + + // Create output channel for server logs + const outputChannel = window.createOutputChannel( + `ReScript Reanalyze Server (${path.basename(monorepoRoot)})`, + ); + + outputChannel.appendLine( + `[info] Starting reanalyze-server in ${monorepoRoot}`, + ); + + // Start the server + const serverProcess = cp.spawn(binaryPath, ["reanalyze-server"], { + cwd: monorepoRoot, + stdio: ["ignore", "pipe", "pipe"], + }); + + if (serverProcess.pid == null) { + outputChannel.appendLine("[error] Failed to start reanalyze-server"); + return null; + } + + const state: ReanalyzeServerState = { + process: serverProcess, + monorepoRoot, + socketPath: getSocketPath(monorepoRoot), + startedByUs: true, + outputChannel, + }; + + // Log stdout and stderr to output channel + serverProcess.stdout?.on("data", (data) => { + outputChannel.appendLine(`[stdout] ${data.toString().trim()}`); + }); + + serverProcess.stderr?.on("data", (data) => { + outputChannel.appendLine(`[stderr] ${data.toString().trim()}`); + }); + + serverProcess.on("error", (err) => { + outputChannel.appendLine(`[error] Server error: ${err.message}`); + }); + + serverProcess.on("exit", (code, signal) => { + outputChannel.appendLine( + `[info] Server exited with code ${code}, signal ${signal}`, + ); + reanalyzeServers.delete(monorepoRoot); + }); + + reanalyzeServers.set(monorepoRoot, state); + + // Wait briefly for socket file to be created (up to 3 seconds) + for (let i = 0; i < 30; i++) { + if (isServerRunning(monorepoRoot)) { + outputChannel.appendLine(`[info] Server socket ready`); + return state; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + outputChannel.appendLine( + "[warn] Server started but socket not found after 3 seconds", + ); + return state; +}; + +// Clean up socket file if it exists +const cleanupSocketFile = (socketPath: string): void => { + try { + if (fs.existsSync(socketPath)) { + fs.unlinkSync(socketPath); + } + } catch { + // Ignore errors during cleanup + } +}; + +// Stop reanalyze server for a monorepo (only if we started it) +export const stopReanalyzeServer = ( + monorepoRoot: string | null, + clientOutputChannel?: OutputChannel, +): void => { + if (monorepoRoot == null) return; + + const state = reanalyzeServers.get(monorepoRoot); + if (!state) return; + + // Only kill the process if we started it + if (state.startedByUs && state.process != null) { + state.process.kill(); + state.outputChannel?.appendLine("[info] Server stopped by extension"); + // Clean up socket file to prevent stale socket issues + cleanupSocketFile(state.socketPath); + } else if (!state.startedByUs) { + clientOutputChannel?.appendLine( + `[info] Leaving external reanalyze-server running for ${path.basename(monorepoRoot)}`, + ); + } + + reanalyzeServers.delete(monorepoRoot); +}; + +// Stop all servers we started +export const stopAllReanalyzeServers = (): void => { + for (const [_monorepoRoot, state] of reanalyzeServers) { + if (state.startedByUs && state.process != null) { + state.process.kill(); + state.outputChannel?.appendLine("[info] Server stopped by extension"); + // Clean up socket file to prevent stale socket issues + cleanupSocketFile(state.socketPath); + } + } + reanalyzeServers.clear(); +}; + +// Show server log for a monorepo +// Returns true if the output channel was shown, false otherwise +// This is an async function because it may need to find the binary to derive monorepo root +export const showReanalyzeServerLog = async ( + monorepoRoot: string | null, +): Promise => { + if (monorepoRoot == null) { + // Try to find any running server + const firstServer = reanalyzeServers.values().next().value; + if (firstServer?.outputChannel) { + firstServer.outputChannel.show(); + return true; + } else { + window.showInformationMessage( + "No reanalyze server is currently running.", + ); + return false; + } + } + + // First try direct lookup + let state = reanalyzeServers.get(monorepoRoot); + if (state?.outputChannel) { + state.outputChannel.show(); + return true; + } + + // If not found, try to derive monorepo root from binary path + // (the server is registered under monorepo root, not subpackage root) + const binaryPath = await findBinary({ + projectRootPath: monorepoRoot, + binary: "rescript-tools.exe", + }); + if (binaryPath != null) { + const derivedMonorepoRoot = getMonorepoRootFromBinaryPath(binaryPath); + if (derivedMonorepoRoot != null && derivedMonorepoRoot !== monorepoRoot) { + state = reanalyzeServers.get(derivedMonorepoRoot); + if (state?.outputChannel) { + state.outputChannel.show(); + return true; + } + } + } + + window.showInformationMessage( + `No reanalyze server log available for ${path.basename(monorepoRoot)}`, + ); + return false; +}; + export let statusBarItem = { setToStopText: (codeAnalysisRunningStatusBarItem: StatusBarItem) => { codeAnalysisRunningStatusBarItem.text = "$(debug-stop) Stop Code Analyzer"; @@ -204,46 +452,85 @@ let resultsToDiagnostics = ( }; }; +// Returns the monorepo root path if a reanalyze server was started, null otherwise. +// This allows the caller to track which server to stop later. export const runCodeAnalysisWithReanalyze = async ( diagnosticsCollection: DiagnosticCollection, diagnosticsResultCodeActions: DiagnosticsResultCodeActionsMap, outputChannel: OutputChannel, codeAnalysisRunningStatusBarItem: StatusBarItem, -) => { - let currentDocument = window.activeTextEditor.document; +): Promise => { + let currentDocument = window.activeTextEditor?.document; + if (!currentDocument) { + window.showErrorMessage("No active document found."); + return null; + } let projectRootPath: NormalizedPath | null = normalizePath( findProjectRootOfFile(currentDocument.uri.fsPath), ); - let binaryPath: string | null = await findBinary({ + + // findBinary walks up the directory tree to find node_modules/rescript, + // so it works correctly for monorepos (finds the workspace root's binary) + // Note: rescript-tools.exe (with reanalyze command) is only available in ReScript 12+ + const binaryPath: string | null = await findBinary({ projectRootPath, binary: "rescript-tools.exe", }); - if (binaryPath == null) { - binaryPath = await findBinary({ - projectRootPath, - binary: "rescript-editor-analysis.exe", - }); - } if (binaryPath === null) { - window.showErrorMessage("Binary executable not found."); - return; + outputChannel.appendLine( + `[error] rescript-tools.exe not found for project root: ${projectRootPath}. Code analysis requires ReScript 12 or later.`, + ); + window.showErrorMessage( + "Code analysis requires ReScript 12 or later (rescript-tools.exe not found).", + ); + return null; } - // Strip everything after the outermost node_modules segment to get the project root. - let cwd = - binaryPath.match(/^(.*?)[\\/]+node_modules([\\/]+|$)/)?.[1] ?? binaryPath; + // Derive monorepo root from binary path - the directory containing node_modules + // This handles monorepos correctly since findBinary walks up to find the binary + const monorepoRootPath: NormalizedPath | null = normalizePath( + getMonorepoRootFromBinaryPath(binaryPath), + ); + + if (monorepoRootPath === null) { + outputChannel.appendLine( + `[error] Could not determine workspace root from binary path: ${binaryPath}`, + ); + window.showErrorMessage("Could not determine workspace root."); + return null; + } + + // Check if we should use reanalyze-server (ReScript >= 12.1.0) + const useServer = await supportsReanalyzeServer(monorepoRootPath); + + if (useServer && monorepoRootPath) { + // Ensure server is running from workspace root + const serverState = await startReanalyzeServer( + monorepoRootPath, + binaryPath, + outputChannel, + ); + if (serverState) { + outputChannel.appendLine( + `[info] Using reanalyze-server for ${path.basename(monorepoRootPath)}`, + ); + } + } statusBarItem.setToRunningText(codeAnalysisRunningStatusBarItem); let opts = ["reanalyze", "-json"]; - let p = cp.spawn(binaryPath, opts, { cwd }); + let p = cp.spawn(binaryPath, opts, { cwd: monorepoRootPath }); if (p.stdout == null) { + outputChannel.appendLine( + `[error] Failed to spawn reanalyze process: stdout is null. Binary: ${binaryPath}, cwd: ${monorepoRootPath}`, + ); statusBarItem.setToFailed(codeAnalysisRunningStatusBarItem); - window.showErrorMessage("Something went wrong."); - return; + window.showErrorMessage("Failed to start code analysis process."); + return null; } let data = ""; @@ -292,7 +579,7 @@ export const runCodeAnalysisWithReanalyze = async ( outputChannel.appendLine( `> To reproduce, run "${binaryPath} ${opts.join( " ", - )}" in directory: "${cwd}"`, + )}" in directory: "${monorepoRootPath}"`, ); outputChannel.appendLine("\n"); } @@ -324,4 +611,7 @@ export const runCodeAnalysisWithReanalyze = async ( statusBarItem.setToStopText(codeAnalysisRunningStatusBarItem); }); + + // Return the monorepo root so the caller can track which server to stop + return monorepoRootPath; }; diff --git a/client/src/extension.ts b/client/src/extension.ts index cefe346a5..dd24726f7 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -32,6 +32,7 @@ import { } from "./commands/code_analysis"; import { pasteAsRescriptJson } from "./commands/paste_as_rescript_json"; import { pasteAsRescriptJsx } from "./commands/paste_as_rescript_jsx"; +import { findProjectRootOfFile } from "../../shared/src/projectRoots"; let client: LanguageClient; @@ -307,7 +308,8 @@ export function activate(context: ExtensionContext) { let inCodeAnalysisState: { active: boolean; - } = { active: false }; + currentMonorepoRoot: string | null; + } = { active: false, currentMonorepoRoot: null }; // This code actions provider yields the code actions potentially extracted // from the code analysis to the editor. @@ -431,13 +433,7 @@ export function activate(context: ExtensionContext) { ); // Starts the code analysis mode. - commands.registerCommand("rescript-vscode.start_code_analysis", () => { - // Save the directory this first ran from, and re-use that when continuously - // running the analysis. This is so that the target of the analysis does not - // change on subsequent runs, if there are multiple ReScript projects open - // in the editor. - let currentDocument = window.activeTextEditor.document; - + commands.registerCommand("rescript-vscode.start_code_analysis", async () => { inCodeAnalysisState.active = true; codeAnalysisRunningStatusBarItem.command = @@ -445,27 +441,76 @@ export function activate(context: ExtensionContext) { codeAnalysisRunningStatusBarItem.show(); statusBarItem.setToStopText(codeAnalysisRunningStatusBarItem); - customCommands.codeAnalysisWithReanalyze( + // Start code analysis and capture the monorepo root for server management + const monorepoRoot = await customCommands.codeAnalysisWithReanalyze( diagnosticsCollection, diagnosticsResultCodeActions, outputChannel, codeAnalysisRunningStatusBarItem, ); + inCodeAnalysisState.currentMonorepoRoot = monorepoRoot; }); commands.registerCommand("rescript-vscode.stop_code_analysis", () => { inCodeAnalysisState.active = false; + // Stop server if we started it for this project + customCommands.stopReanalyzeServer( + inCodeAnalysisState.currentMonorepoRoot, + outputChannel, + ); + inCodeAnalysisState.currentMonorepoRoot = null; + diagnosticsCollection.clear(); diagnosticsResultCodeActions.clear(); codeAnalysisRunningStatusBarItem.hide(); }); + // Show reanalyze server log + commands.registerCommand( + "rescript-vscode.show_reanalyze_server_log", + async () => { + let currentDocument = window.activeTextEditor?.document; + let projectRootPath: string | null = null; + + if (currentDocument) { + projectRootPath = findProjectRootOfFile(currentDocument.uri.fsPath); + } + + return await customCommands.showReanalyzeServerLog(projectRootPath); + }, + ); + commands.registerCommand("rescript-vscode.switch-impl-intf", () => { customCommands.switchImplIntf(client); }); + // Start build command + commands.registerCommand("rescript-vscode.start_build", async () => { + let currentDocument = window.activeTextEditor?.document; + if (!currentDocument) { + window.showErrorMessage("No active document found."); + return; + } + + try { + const result = (await client.sendRequest("rescript/startBuild", { + uri: currentDocument.uri.toString(), + })) as { success: boolean }; + + if (result.success) { + window.showInformationMessage("Build watcher started."); + } else { + window.showErrorMessage( + "Failed to start build. Check that a ReScript project is open.", + ); + } + } catch (e) { + window.showErrorMessage(`Failed to start build: ${String(e)}`); + } + }); + commands.registerCommand("rescript-vscode.restart_language_server", () => { client.stop().then(() => { client = createLanguageClient(); @@ -514,6 +559,9 @@ export function activate(context: ExtensionContext) { } export function deactivate(): Thenable | undefined { + // Stop all reanalyze servers we started + customCommands.stopAllReanalyzeServers(); + if (!client) { return undefined; } diff --git a/client/src/test/suite/exampleProject.test.ts b/client/src/test/suite/exampleProject.test.ts new file mode 100644 index 000000000..e3acc7a72 --- /dev/null +++ b/client/src/test/suite/exampleProject.test.ts @@ -0,0 +1,328 @@ +/** + * Example Project Tests (Code Analysis Server Test Suite) + * + * Run these tests: + * cd client && npm run test -- --label example-project + */ +import * as assert from "assert"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + getWorkspaceRoot, + removeRescriptLockFile, + removeReanalyzeSocketFile, + ensureExtensionActivated, + sleep, + getCompilerLogPath, + getFileMtime, + waitForFileUpdate, + insertCommentAndSave, + restoreContentAndSave, + startBuildWatcher, + startCodeAnalysis, + stopCodeAnalysis, + showReanalyzeServerLog, + findBuildPromptInLogs, +} from "./helpers"; + +suite("Code Analysis Server Test Suite", () => { + test("Extension should be present", async () => { + const extension = vscode.extensions.getExtension( + "chenglou92.rescript-vscode", + ); + assert.ok(extension, "ReScript extension should be present"); + console.log("Extension found:", extension.id); + }); + + test("Commands should be registered after activation", async () => { + const extension = vscode.extensions.getExtension( + "chenglou92.rescript-vscode", + ); + if (!extension) { + console.log("Extension not found, skipping command test"); + return; + } + + if (!extension.isActive) { + console.log("Activating extension..."); + await extension.activate(); + } + + const commands = await vscode.commands.getCommands(true); + + const expectedCommands = [ + "rescript-vscode.start_code_analysis", + "rescript-vscode.stop_code_analysis", + "rescript-vscode.show_reanalyze_server_log", + "rescript-vscode.start_build", + ]; + + for (const cmd of expectedCommands) { + assert.ok(commands.includes(cmd), `Command ${cmd} should be registered`); + } + console.log("All commands registered successfully!"); + }); + + test("Start Code Analysis should run on a ReScript file", async () => { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + console.log("No workspace folder found, skipping test"); + return; + } + console.log("Workspace root:", workspaceRoot); + + const resFilePath = path.join(workspaceRoot, "src", "Hello.res"); + console.log("Opening file:", resFilePath); + + const document = await vscode.workspace.openTextDocument(resFilePath); + await vscode.window.showTextDocument(document); + console.log("File opened successfully"); + + await ensureExtensionActivated(); + + console.log("Running start_code_analysis command..."); + await startCodeAnalysis(); + console.log("Code analysis command completed"); + + console.log("Running stop_code_analysis command..."); + await stopCodeAnalysis(); + console.log("Test completed successfully"); + }); + + test("Start Build command should start build watcher", async () => { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + console.log("No workspace folder found, skipping test"); + return; + } + console.log("Workspace root:", workspaceRoot); + + const resFilePath = path.join(workspaceRoot, "src", "Hello.res"); + console.log("Opening file:", resFilePath); + + const document = await vscode.workspace.openTextDocument(resFilePath); + await vscode.window.showTextDocument(document); + console.log("File opened successfully"); + + await ensureExtensionActivated(); + + console.log("Running start_build command..."); + await startBuildWatcher(); + + console.log( + "Test completed - check Language Server log for 'Starting build watcher' or 'Build watcher already running' message", + ); + }); + + test("Build watcher recompiles on file save", async () => { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + console.log("No workspace folder found, skipping test"); + return; + } + console.log("Workspace root:", workspaceRoot); + + const resFilePath = path.join(workspaceRoot, "src", "Hello.res"); + console.log("Opening file:", resFilePath); + + const document = await vscode.workspace.openTextDocument(resFilePath); + const editor = await vscode.window.showTextDocument(document); + console.log("File opened successfully"); + + await ensureExtensionActivated(); + + console.log("Starting build watcher..."); + await startBuildWatcher(); + console.log("Build watcher started"); + + const compilerLogPath = getCompilerLogPath(workspaceRoot); + const mtimeBefore = getFileMtime(compilerLogPath); + if (mtimeBefore) { + console.log(`compiler.log mtime before: ${mtimeBefore.toISOString()}`); + } else { + console.log("compiler.log does not exist yet"); + } + + console.log("Editing file..."); + const originalContent = document.getText(); + await insertCommentAndSave(editor, "/* test comment */\n"); + console.log("File saved with edit"); + + console.log("Waiting for compilation..."); + const mtimeAfter = await waitForFileUpdate(compilerLogPath, mtimeBefore); + if (mtimeAfter) { + console.log(`compiler.log mtime after: ${mtimeAfter.toISOString()}`); + } else { + console.log("compiler.log still does not exist"); + } + + assert.ok(mtimeAfter, "compiler.log should exist after file save"); + if (mtimeBefore && mtimeAfter) { + assert.ok( + mtimeAfter > mtimeBefore, + "compiler.log should be updated after file save", + ); + console.log("SUCCESS: compiler.log was updated after file save"); + } else if (!mtimeBefore && mtimeAfter) { + console.log("SUCCESS: compiler.log was created after file save"); + } + + console.log("Restoring original content..."); + await restoreContentAndSave(editor, originalContent); + console.log("Original content restored"); + + await sleep(1000); + console.log("Test completed"); + }); + + test("Code analysis with incremental updates", async () => { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + console.log("No workspace folder found, skipping test"); + return; + } + console.log("Workspace root:", workspaceRoot); + + // Remove stale socket file to ensure a fresh server is started + // Note: Only remove the socket file, not the lock file, to keep the build watcher running + removeReanalyzeSocketFile(workspaceRoot); + + const resFilePath = path.join(workspaceRoot, "src", "Hello.res"); + console.log("Step 1: Opening file:", resFilePath); + + const document = await vscode.workspace.openTextDocument(resFilePath); + const editor = await vscode.window.showTextDocument(document); + console.log("File opened successfully"); + + await ensureExtensionActivated(); + + console.log("Step 2: Starting build..."); + await startBuildWatcher(); + console.log("Build started"); + + console.log("Step 3: Starting code analysis..."); + await startCodeAnalysis(); + console.log("Code analysis started"); + + console.log("Step 3b: Opening reanalyze server log..."); + await showReanalyzeServerLog(); + + console.log("Step 4: Checking for diagnostics..."); + const diagnostics = vscode.languages.getDiagnostics(document.uri); + console.log(`Found ${diagnostics.length} diagnostics in Hello.res`); + assert.ok(diagnostics.length > 0, "Should have diagnostics for dead code"); + for (const diag of diagnostics.slice(0, 5)) { + console.log( + ` - Line ${diag.range.start.line + 1}: ${diag.message.substring(0, 80)}...`, + ); + } + if (diagnostics.length > 5) { + console.log(` ... and ${diagnostics.length - 5} more`); + } + const initialDiagnosticsCount = diagnostics.length; + + console.log("Step 5: Adding dead code..."); + const originalContent = document.getText(); + const deadCode = "let testDeadVariable12345 = 999\n"; + + const compilerLogPath = getCompilerLogPath(workspaceRoot); + const compilerLogMtimeBefore = getFileMtime(compilerLogPath); + if (compilerLogMtimeBefore) { + console.log( + `compiler.log mtime before: ${compilerLogMtimeBefore.toISOString()}`, + ); + } else { + console.log("compiler.log does not exist before edit"); + } + + await insertCommentAndSave(editor, deadCode); + console.log("Dead code added and saved"); + + console.log("Step 5a: Waiting for compilation..."); + const mtimeAfter = await waitForFileUpdate( + compilerLogPath, + compilerLogMtimeBefore, + ); + if (mtimeAfter) { + console.log(`compiler.log updated: ${mtimeAfter.toISOString()}`); + } else { + console.log("Warning: compilation may not have completed"); + } + + console.log("Step 5b: Re-running code analysis..."); + await vscode.window.showTextDocument(document); + await startCodeAnalysis(); + console.log("Code analysis re-run complete"); + + console.log("Step 6: Checking for updated diagnostics..."); + const updatedDiagnostics = vscode.languages.getDiagnostics(document.uri); + console.log( + `Found ${updatedDiagnostics.length} diagnostics after edit (was ${initialDiagnosticsCount})`, + ); + + assert.ok( + updatedDiagnostics.length > initialDiagnosticsCount, + `Diagnostics count should increase after adding dead code (was ${initialDiagnosticsCount}, now ${updatedDiagnostics.length})`, + ); + + const deadVarDiagnostic = updatedDiagnostics.find((d) => + d.message.includes("testDeadVariable12345"), + ); + assert.ok( + deadVarDiagnostic, + "Should find diagnostic for testDeadVariable12345", + ); + console.log( + `Found diagnostic for testDeadVariable12345: ${deadVarDiagnostic.message}`, + ); + + console.log("Step 7: Undoing change..."); + await restoreContentAndSave(editor, originalContent); + console.log("Change undone and saved"); + + await sleep(1000); + + console.log("Step 8: Stopping code analysis..."); + await stopCodeAnalysis(); + console.log("Code analysis stopped"); + + console.log("Step 9: Test completed - check Reanalyze Server log for:"); + console.log(" - [request #1] with 'files: X processed, 0 cached'"); + console.log( + " - [request #2] with 'files: X processed, Y cached' where Y > 0 (incremental)", + ); + }); + + test("Should prompt to start build when no lock file exists", async () => { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + console.log("No workspace folder found, skipping test"); + return; + } + console.log("Workspace root:", workspaceRoot); + + removeRescriptLockFile(workspaceRoot); + + const resFilePath = path.join(workspaceRoot, "src", "More.res"); + console.log("Opening file:", resFilePath); + + const document = await vscode.workspace.openTextDocument(resFilePath); + await vscode.window.showTextDocument(document); + console.log("File opened successfully"); + + await sleep(1000); + + const promptResult = findBuildPromptInLogs(); + if (promptResult.found) { + console.log( + `Found prompt message: "Prompting to start build for ${promptResult.path}"`, + ); + } + + assert.ok( + promptResult.found, + "Should find 'Prompting to start build' message in Language Server log", + ); + console.log("SUCCESS: Build prompt was shown"); + }); +}); diff --git a/client/src/test/suite/helpers.ts b/client/src/test/suite/helpers.ts new file mode 100644 index 000000000..32196b9ef --- /dev/null +++ b/client/src/test/suite/helpers.ts @@ -0,0 +1,324 @@ +/** + * Shared test helpers for ReScript VSCode extension tests + */ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; + +/** + * Get the workspace root folder path + */ +export function getWorkspaceRoot(): string { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders && workspaceFolders.length > 0) { + return workspaceFolders[0].uri.fsPath; + } + return ""; +} + +/** + * Remove ReScript 12+ lock file (lib/rescript.lock) and reanalyze socket file + */ +export function removeRescriptLockFile(workspaceRoot: string): void { + const filesToRemove = [ + path.join(workspaceRoot, "lib", "rescript.lock"), + // Also remove reanalyze socket file to ensure a fresh server is started + path.join(workspaceRoot, ".rescript-reanalyze.sock"), + ]; + + for (const file of filesToRemove) { + try { + if (fs.existsSync(file)) { + fs.unlinkSync(file); + console.log(`Removed ${path.basename(file)}`); + } + } catch (e) { + console.log(`Could not remove ${path.basename(file)}:`, e); + } + } +} + +/** + * Remove only the reanalyze socket file (not the lock file) + * Use this when you need a fresh reanalyze server but want to keep the build watcher running + */ +export function removeReanalyzeSocketFile(workspaceRoot: string): void { + const socketPath = path.join(workspaceRoot, ".rescript-reanalyze.sock"); + try { + if (fs.existsSync(socketPath)) { + fs.unlinkSync(socketPath); + console.log("Removed .rescript-reanalyze.sock"); + } + } catch (e) { + console.log("Could not remove .rescript-reanalyze.sock:", e); + } +} + +/** + * Remove ReScript 9/10/11 lock file (.bsb.lock) + */ +export function removeBsbLockFile(workspaceRoot: string): void { + const lockPath = path.join(workspaceRoot, ".bsb.lock"); + try { + if (fs.existsSync(lockPath)) { + fs.unlinkSync(lockPath); + console.log("Removed .bsb.lock"); + } + } catch (e) { + console.log("Could not remove .bsb.lock:", e); + } +} + +/** + * Remove monorepo lock files (both rewatch.lock and rescript.lock) + */ +export function removeMonorepoLockFiles(monorepoRoot: string): void { + const filesToRemove = [ + path.join(monorepoRoot, "lib", "rewatch.lock"), + path.join(monorepoRoot, "lib", "rescript.lock"), + // Also remove reanalyze socket file to ensure a fresh server is started + path.join(monorepoRoot, ".rescript-reanalyze.sock"), + ]; + + for (const file of filesToRemove) { + try { + if (fs.existsSync(file)) { + fs.unlinkSync(file); + console.log(`Removed ${path.basename(file)}`); + } + } catch (e) { + console.log(`Could not remove ${path.basename(file)}:`, e); + } + } +} + +/** + * Ensure the ReScript extension is activated + */ +export async function ensureExtensionActivated(): Promise< + vscode.Extension | undefined +> { + const extension = vscode.extensions.getExtension( + "chenglou92.rescript-vscode", + ); + if (extension && !extension.isActive) { + await extension.activate(); + } + return extension; +} + +/** + * Open a file in the editor and return the document + */ +export async function openFile(filePath: string): Promise { + const document = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(document); + return document; +} + +/** + * Find the LSP log file in the most recent test logs directory + */ +export function findLspLogContent(): string | null { + // __dirname is client/out/client/src/test/suite when running tests + const vscodeTestDir = path.resolve(__dirname, "../../../../../.vscode-test"); + const logsBaseDir = path.join(vscodeTestDir, "user-data", "logs"); + + try { + // Find the most recent log directory (format: YYYYMMDDTHHMMSS) + const logDirs = fs + .readdirSync(logsBaseDir) + .filter((d) => /^\d{8}T\d{6}$/.test(d)) + .sort() + .reverse(); + + for (const logDir of logDirs) { + const outputLoggingDir = path.join( + logsBaseDir, + logDir, + "window1", + "exthost", + ); + if (!fs.existsSync(outputLoggingDir)) continue; + + const outputDirs = fs + .readdirSync(outputLoggingDir) + .filter((d) => d.startsWith("output_logging_")); + + for (const outputDir of outputDirs) { + const lspLogPath = path.join( + outputLoggingDir, + outputDir, + "1-ReScript Language Server.log", + ); + if (fs.existsSync(lspLogPath)) { + console.log("Checking log file:", lspLogPath); + return fs.readFileSync(lspLogPath, "utf-8"); + } + } + } + } catch (e) { + console.log("Error reading logs:", e); + } + + return null; +} + +/** + * Wait for a condition to become true, polling at intervals + */ +export async function waitFor( + condition: () => boolean, + options: { timeout?: number; interval?: number; message?: string } = {}, +): Promise { + const { timeout = 5000, interval = 500, message = "condition" } = options; + const maxAttempts = Math.ceil(timeout / interval); + + for (let i = 0; i < maxAttempts; i++) { + await new Promise((resolve) => setTimeout(resolve, interval)); + const result = condition(); + console.log(`Checking ${message} (attempt ${i + 1}): ${result}`); + if (result) return true; + } + return false; +} + +/** + * Sleep for a given number of milliseconds + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Get the path to the compiler.log file + */ +export function getCompilerLogPath(workspaceRoot: string): string { + return path.join(workspaceRoot, "lib", "bs", ".compiler.log"); +} + +/** + * Get the mtime of a file, or null if it doesn't exist + */ +export function getFileMtime(filePath: string): Date | null { + try { + return fs.statSync(filePath).mtime; + } catch { + return null; + } +} + +/** + * Wait for a file's mtime to be updated (newer than the given mtime) + */ +export async function waitForFileUpdate( + filePath: string, + mtimeBefore: Date | null, + options: { timeout?: number; interval?: number } = {}, +): Promise { + const { timeout = 5000, interval = 500 } = options; + const maxAttempts = Math.ceil(timeout / interval); + + for (let i = 0; i < maxAttempts; i++) { + await sleep(interval); + const mtimeAfter = getFileMtime(filePath); + if (mtimeAfter && (!mtimeBefore || mtimeAfter > mtimeBefore)) { + return mtimeAfter; + } + } + return getFileMtime(filePath); +} + +/** + * Insert a comment at the beginning of a document and save + */ +export async function insertCommentAndSave( + editor: vscode.TextEditor, + comment: string, +): Promise { + await editor.edit((editBuilder) => { + editBuilder.insert(new vscode.Position(0, 0), comment); + }); + await editor.document.save(); +} + +/** + * Restore original content to a document and save + */ +export async function restoreContentAndSave( + editor: vscode.TextEditor, + originalContent: string, +): Promise { + const document = editor.document; + await editor.edit((editBuilder) => { + const fullRange = new vscode.Range( + new vscode.Position(0, 0), + document.lineAt(document.lineCount - 1).range.end, + ); + editBuilder.replace(fullRange, originalContent); + }); + await document.save(); +} + +/** + * Start the build watcher and wait for it to initialize + */ +export async function startBuildWatcher(waitMs: number = 1000): Promise { + await vscode.commands.executeCommand("rescript-vscode.start_build"); + await sleep(waitMs); +} + +/** + * Start code analysis and wait for it to initialize + */ +export async function startCodeAnalysis(waitMs: number = 1000): Promise { + await vscode.commands.executeCommand("rescript-vscode.start_code_analysis"); + await sleep(waitMs); +} + +/** + * Stop code analysis + */ +export async function stopCodeAnalysis(): Promise { + await vscode.commands.executeCommand("rescript-vscode.stop_code_analysis"); +} + +/** + * Show the reanalyze server log and assert it returns true + */ +export async function showReanalyzeServerLog(): Promise { + const result = await vscode.commands.executeCommand( + "rescript-vscode.show_reanalyze_server_log", + ); + console.log(`Show reanalyze server log result: ${result}`); + assert.strictEqual( + result, + true, + "Show reanalyze server log should return true when output channel is shown", + ); +} + +/** + * Result of searching for build prompt in logs + */ +export interface BuildPromptResult { + found: boolean; + path: string; +} + +/** + * Find "Prompting to start build" message in LSP logs + */ +export function findBuildPromptInLogs(): BuildPromptResult { + const logContent = findLspLogContent(); + if (logContent) { + const promptMatch = logContent.match( + /\[Info.*\] Prompting to start build for (.+)/, + ); + if (promptMatch) { + return { found: true, path: promptMatch[1] }; + } + } + return { found: false, path: "" }; +} diff --git a/client/src/test/suite/monorepoRoot.test.ts b/client/src/test/suite/monorepoRoot.test.ts new file mode 100644 index 000000000..aafc52cba --- /dev/null +++ b/client/src/test/suite/monorepoRoot.test.ts @@ -0,0 +1,274 @@ +/** + * Monorepo Root Tests (Monorepo Code Analysis Test Suite) + * + * Run these tests: + * cd client && npm run test -- --label monorepo-root + */ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + getWorkspaceRoot, + removeMonorepoLockFiles, + ensureExtensionActivated, + sleep, + getCompilerLogPath, + getFileMtime, + waitForFileUpdate, + insertCommentAndSave, + restoreContentAndSave, + startBuildWatcher, + startCodeAnalysis, + stopCodeAnalysis, + showReanalyzeServerLog, + findBuildPromptInLogs, +} from "./helpers"; + +suite("Monorepo Code Analysis Test Suite", () => { + test("Monorepo: Build watcher works when opening root package", async () => { + const monorepoRoot = getWorkspaceRoot(); + console.log("Monorepo root:", monorepoRoot); + + const rootResPath = path.join(monorepoRoot, "src", "Root.res"); + if (!fs.existsSync(rootResPath)) { + console.log("Monorepo project not found, skipping test"); + return; + } + + removeMonorepoLockFiles(monorepoRoot); + + console.log("Opening root file:", rootResPath); + const document = await vscode.workspace.openTextDocument(rootResPath); + const editor = await vscode.window.showTextDocument(document); + console.log("Root file opened successfully"); + + await ensureExtensionActivated(); + + console.log("Starting build watcher from root..."); + await startBuildWatcher(); + console.log("Build watcher started"); + + const compilerLogPath = getCompilerLogPath(monorepoRoot); + const mtimeBefore = getFileMtime(compilerLogPath); + if (mtimeBefore) { + console.log( + `Root compiler.log mtime before: ${mtimeBefore.toISOString()}`, + ); + } else { + console.log("Root compiler.log does not exist yet"); + } + + console.log("Editing root file..."); + const originalContent = document.getText(); + await insertCommentAndSave(editor, "/* monorepo root test */\n"); + console.log("Root file saved with edit"); + + const mtimeAfter = await waitForFileUpdate(compilerLogPath, mtimeBefore); + if (mtimeAfter) { + console.log(`Root compiler.log mtime after: ${mtimeAfter.toISOString()}`); + } else { + console.log("Root compiler.log still does not exist"); + } + + assert.ok(mtimeAfter, "Root compiler.log should exist after file save"); + if (mtimeBefore && mtimeAfter) { + assert.ok( + mtimeAfter > mtimeBefore, + "Root compiler.log should be updated after file save", + ); + console.log("SUCCESS: Root compiler.log was updated"); + } + + await restoreContentAndSave(editor, originalContent); + console.log("Original content restored"); + + await sleep(1000); + console.log("Monorepo root test completed"); + }); + + test("Monorepo: Build watcher works when opening subpackage file", async () => { + const monorepoRoot = getWorkspaceRoot(); + console.log("Monorepo root:", monorepoRoot); + + const appResPath = path.join( + monorepoRoot, + "packages", + "app", + "src", + "App.res", + ); + if (!fs.existsSync(appResPath)) { + console.log("Monorepo app package not found, skipping test"); + return; + } + + console.log("Opening subpackage file:", appResPath); + const document = await vscode.workspace.openTextDocument(appResPath); + const editor = await vscode.window.showTextDocument(document); + console.log("Subpackage file opened successfully"); + + await ensureExtensionActivated(); + + console.log("Starting build watcher from subpackage..."); + await startBuildWatcher(); + console.log("Build watcher started"); + + const rootCompilerLogPath = getCompilerLogPath(monorepoRoot); + const mtimeBefore = getFileMtime(rootCompilerLogPath); + if (mtimeBefore) { + console.log( + `Root compiler.log mtime before: ${mtimeBefore.toISOString()}`, + ); + } else { + console.log( + "Root compiler.log does not exist yet (expected for monorepo subpackage)", + ); + } + + console.log("Editing subpackage file..."); + const originalContent = document.getText(); + await insertCommentAndSave(editor, "/* monorepo subpackage test */\n"); + console.log("Subpackage file saved with edit"); + + const mtimeAfter = await waitForFileUpdate( + rootCompilerLogPath, + mtimeBefore, + ); + if (mtimeAfter) { + console.log(`Root compiler.log mtime after: ${mtimeAfter.toISOString()}`); + } else { + console.log("Root compiler.log still does not exist"); + } + + assert.ok( + mtimeAfter, + "Root compiler.log should exist after subpackage file save", + ); + if (mtimeBefore && mtimeAfter) { + assert.ok( + mtimeAfter > mtimeBefore, + "Root compiler.log should be updated after subpackage file save", + ); + console.log( + "SUCCESS: Root compiler.log was updated from subpackage edit", + ); + } + + await restoreContentAndSave(editor, originalContent); + console.log("Original content restored"); + + await sleep(1000); + console.log("Monorepo subpackage test completed"); + }); + + test("Monorepo: Code analysis works from subpackage", async () => { + const monorepoRoot = getWorkspaceRoot(); + console.log("Monorepo root:", monorepoRoot); + + const libResPath = path.join( + monorepoRoot, + "packages", + "lib", + "src", + "Lib.res", + ); + if (!fs.existsSync(libResPath)) { + console.log("Monorepo lib package not found, skipping test"); + return; + } + + console.log("Opening lib file:", libResPath); + const document = await vscode.workspace.openTextDocument(libResPath); + await vscode.window.showTextDocument(document); + console.log("Lib file opened successfully"); + + await ensureExtensionActivated(); + + console.log("Starting build..."); + await startBuildWatcher(); + + console.log("Starting code analysis..."); + await startCodeAnalysis(); + console.log("Code analysis started"); + + console.log("Opening reanalyze server log..."); + await showReanalyzeServerLog(); + + const diagnostics = vscode.languages.getDiagnostics(document.uri); + console.log(`Found ${diagnostics.length} diagnostics in Lib.res`); + for (const diag of diagnostics.slice(0, 5)) { + console.log( + ` - Line ${diag.range.start.line + 1}: ${diag.message.substring(0, 80)}...`, + ); + } + + assert.ok( + diagnostics.length > 0, + "Should have diagnostics for dead code in Lib.res", + ); + + const deadFuncDiagnostic = diagnostics.find((d) => + d.message.includes("unusedLibFunction"), + ); + assert.ok( + deadFuncDiagnostic, + "Should find diagnostic for unusedLibFunction in monorepo lib", + ); + console.log( + `Found diagnostic for unusedLibFunction: ${deadFuncDiagnostic?.message}`, + ); + + console.log("Stopping code analysis..."); + await stopCodeAnalysis(); + console.log("Code analysis stopped"); + + console.log("Monorepo code analysis test completed"); + }); + + test("Monorepo: Should prompt to start build when opening subpackage without lock file", async () => { + const monorepoRoot = getWorkspaceRoot(); + console.log("Monorepo root:", monorepoRoot); + + const appResPath = path.join( + monorepoRoot, + "packages", + "app", + "src", + "App.res", + ); + if (!fs.existsSync(appResPath)) { + console.log("Monorepo app package not found, skipping test"); + return; + } + + console.log("Removing lock file from monorepo root..."); + removeMonorepoLockFiles(monorepoRoot); + + console.log("Opening subpackage file:", appResPath); + const document = await vscode.workspace.openTextDocument(appResPath); + await vscode.window.showTextDocument(document); + console.log("Subpackage file opened successfully"); + + await sleep(1000); + + const promptResult = findBuildPromptInLogs(); + if (promptResult.found) { + console.log( + `Found prompt message: "Prompting to start build for ${promptResult.path}"`, + ); + } + + assert.ok( + promptResult.found, + "Should find 'Prompting to start build' message in Language Server log", + ); + + assert.ok( + promptResult.path.includes("monorepo-project") && + !promptResult.path.includes("packages"), + `Prompt path should be monorepo root, not subpackage. Got: ${promptResult.path}`, + ); + console.log("SUCCESS: Build prompt was shown for monorepo root path"); + }); +}); diff --git a/client/src/test/suite/monorepoSubpackage.test.ts b/client/src/test/suite/monorepoSubpackage.test.ts new file mode 100644 index 000000000..f18a4849a --- /dev/null +++ b/client/src/test/suite/monorepoSubpackage.test.ts @@ -0,0 +1,153 @@ +/** + * Monorepo Subpackage Tests (Monorepo Subpackage Test Suite) + * + * This test suite runs with VSCode opened on a subpackage (packages/app), + * not the monorepo root. It tests that the extension correctly detects + * monorepo structure even when opened from a subpackage. + * + * Run these tests: + * cd client && npm run test -- --label monorepo-subpackage + */ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + getWorkspaceRoot, + removeMonorepoLockFiles, + sleep, + startBuildWatcher, + startCodeAnalysis, + stopCodeAnalysis, + showReanalyzeServerLog, + findBuildPromptInLogs, +} from "./helpers"; + +suite("Monorepo Subpackage Test Suite", () => { + test("Subpackage workspace: Should prompt to start build with monorepo root path", async () => { + // In this test, workspaceRoot is packages/app (the subpackage) + const workspaceRoot = getWorkspaceRoot(); + console.log("Workspace root (subpackage):", workspaceRoot); + + // The monorepo root is 2 levels up from packages/app + const monorepoRoot = path.resolve(workspaceRoot, "../.."); + console.log("Monorepo root:", monorepoRoot); + + // Verify we're in the right setup - workspace should be a subpackage + assert.ok( + workspaceRoot.includes("packages"), + `Workspace should be in packages folder, got: ${workspaceRoot}`, + ); + + // Check if the subpackage has a rescript.json + const rescriptJsonPath = path.join(workspaceRoot, "rescript.json"); + if (!fs.existsSync(rescriptJsonPath)) { + console.log("Subpackage rescript.json not found, skipping test"); + return; + } + + // Remove lock file from MONOREPO ROOT to trigger the "Start Build" prompt + console.log("Removing lock file from monorepo root..."); + removeMonorepoLockFiles(monorepoRoot); + + // Open a .res file from the subpackage workspace + const resFilePath = path.join(workspaceRoot, "src", "App.res"); + console.log("Opening file:", resFilePath); + + const document = await vscode.workspace.openTextDocument(resFilePath); + await vscode.window.showTextDocument(document); + console.log("File opened successfully"); + + // Wait for LSP to process - since no lock file exists, it should prompt for build + await sleep(1000); + + // Read the Language Server log to verify the prompt was shown + const promptResult = findBuildPromptInLogs(); + if (promptResult.found) { + console.log( + `Found prompt message: "Prompting to start build for ${promptResult.path}"`, + ); + } + + // Assert that the prompt was shown + assert.ok( + promptResult.found, + "Should find 'Prompting to start build' message in Language Server log", + ); + + // Assert that the prompt path is the monorepo root, not the subpackage + // Even though we opened from packages/app, the build prompt should use monorepo root + assert.ok( + promptResult.path.includes("monorepo-project") && + !promptResult.path.includes("packages"), + `Prompt path should be monorepo root, not subpackage. Got: ${promptResult.path}`, + ); + console.log( + "SUCCESS: Build prompt correctly uses monorepo root path when opened from subpackage", + ); + + // Now start the build and verify the lock file is created at the monorepo root + console.log("Starting build from subpackage workspace..."); + await startBuildWatcher(1500); + console.log("Build started"); + + // Check that the lock file exists at the MONOREPO ROOT, not the subpackage + const monorepoLockPath = path.join(monorepoRoot, "lib", "rescript.lock"); + const subpackageLockPath = path.join(workspaceRoot, "lib", "rescript.lock"); + + const monorepoLockExists = fs.existsSync(monorepoLockPath); + const subpackageLockExists = fs.existsSync(subpackageLockPath); + + console.log( + `Monorepo lock file (${monorepoLockPath}): ${monorepoLockExists ? "EXISTS" : "NOT FOUND"}`, + ); + console.log( + `Subpackage lock file (${subpackageLockPath}): ${subpackageLockExists ? "EXISTS" : "NOT FOUND"}`, + ); + + // The lock file should exist at the monorepo root + assert.ok( + monorepoLockExists, + `Lock file should exist at monorepo root: ${monorepoLockPath}`, + ); + + // The lock file should NOT exist at the subpackage level + assert.ok( + !subpackageLockExists, + `Lock file should NOT exist at subpackage: ${subpackageLockPath}`, + ); + + console.log("SUCCESS: Lock file created at monorepo root, not subpackage"); + + // Remove any stale reanalyze socket file from a previous test run + const socketPath = path.join(monorepoRoot, ".rescript-reanalyze.sock"); + if (fs.existsSync(socketPath)) { + fs.unlinkSync(socketPath); + console.log("Removed stale socket file"); + } + + // Start code analysis + console.log("Starting code analysis..."); + await startCodeAnalysis(); + console.log("Code analysis started"); + + // Open the reanalyze server log - verify it returns true (output channel shown) + console.log("Opening reanalyze server log..."); + await showReanalyzeServerLog(); + + // Verify diagnostics are shown (code analysis is working from subpackage) + const diagnostics = vscode.languages.getDiagnostics(document.uri); + console.log(`Found ${diagnostics.length} diagnostics in App.res`); + assert.ok( + diagnostics.length > 0, + "Code analysis should find diagnostics in App.res when run from subpackage", + ); + + // Stop code analysis + console.log("Stopping code analysis..."); + await stopCodeAnalysis(); + console.log("Code analysis stopped"); + + console.log("Test complete - lock file will be cleaned up on LSP shutdown"); + }); +}); diff --git a/client/src/test/suite/rescript9.test.ts b/client/src/test/suite/rescript9.test.ts new file mode 100644 index 000000000..b2d1dd5ad --- /dev/null +++ b/client/src/test/suite/rescript9.test.ts @@ -0,0 +1,241 @@ +/** + * ReScript 9 Tests (ReScript 9 Build Test Suite) + * + * This tests that the build watcher works with older ReScript versions + * that use "rescript build -w" instead of "rescript watch". + * + * Run these tests: + * cd client && npm run test -- --label rescript9-project + */ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + getWorkspaceRoot, + removeBsbLockFile, + ensureExtensionActivated, + waitFor, + sleep, + getCompilerLogPath, + getFileMtime, + waitForFileUpdate, + insertCommentAndSave, + restoreContentAndSave, + startBuildWatcher, + findBuildPromptInLogs, +} from "./helpers"; + +suite("ReScript 9 Build Test Suite", () => { + test("ReScript 9: Extension should be present", async () => { + const extension = vscode.extensions.getExtension( + "chenglou92.rescript-vscode", + ); + assert.ok(extension, "ReScript extension should be present"); + console.log("Extension found:", extension.id); + }); + + test("ReScript 9: Build watcher should start with 'rescript build -w'", async () => { + const workspaceRoot = getWorkspaceRoot(); + console.log("Workspace root:", workspaceRoot); + + // Verify we're in the ReScript 9 project + const bsconfigPath = path.join(workspaceRoot, "bsconfig.json"); + if (!fs.existsSync(bsconfigPath)) { + console.log("bsconfig.json not found, skipping test"); + return; + } + + // Check ReScript version + const packageJsonPath = path.join( + workspaceRoot, + "node_modules", + "rescript", + "package.json", + ); + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); + console.log("ReScript version:", packageJson.version); + assert.ok( + packageJson.version.startsWith("9."), + `Expected ReScript 9.x, got ${packageJson.version}`, + ); + } + + // Open a ReScript file + const resFilePath = path.join(workspaceRoot, "src", "Hello.res"); + console.log("Opening file:", resFilePath); + + const document = await vscode.workspace.openTextDocument(resFilePath); + await vscode.window.showTextDocument(document); + console.log("File opened successfully"); + + await ensureExtensionActivated(); + + // Start the build watcher + console.log("Starting build watcher (should use 'rescript build -w')..."); + await startBuildWatcher(1500); + console.log("Build watcher started"); + + // Check if the lock file was created (.bsb.lock for ReScript 9) + const lockPath = path.join(workspaceRoot, ".bsb.lock"); + const lockExists = fs.existsSync(lockPath); + console.log(`.bsb.lock exists: ${lockExists}`); + assert.ok( + lockExists, + ".bsb.lock should exist after starting build watcher", + ); + + console.log("ReScript 9 build watcher test completed"); + }); + + test("ReScript 9: Build watcher recompiles on file save", async () => { + const workspaceRoot = getWorkspaceRoot(); + console.log("Workspace root:", workspaceRoot); + + // Open a ReScript file + const resFilePath = path.join(workspaceRoot, "src", "Hello.res"); + console.log("Opening file:", resFilePath); + + const document = await vscode.workspace.openTextDocument(resFilePath); + const editor = await vscode.window.showTextDocument(document); + console.log("File opened successfully"); + + await ensureExtensionActivated(); + + // Start the build watcher + console.log("Starting build watcher..."); + await startBuildWatcher(); + console.log("Build watcher started"); + + // Check compiler.log modification time before edit + const compilerLogPath = getCompilerLogPath(workspaceRoot); + const mtimeBefore = getFileMtime(compilerLogPath); + if (mtimeBefore) { + console.log(`compiler.log mtime before: ${mtimeBefore.toISOString()}`); + } else { + console.log("compiler.log does not exist yet"); + } + + // Edit the file and save + console.log("Editing file..."); + const originalContent = document.getText(); + await insertCommentAndSave(editor, "/* rescript 9 test */\n"); + console.log("File saved with edit"); + + // Wait for compilation + console.log("Waiting for compilation..."); + const mtimeAfter = await waitForFileUpdate(compilerLogPath, mtimeBefore, { + timeout: 3000, + }); + if (mtimeAfter) { + console.log(`compiler.log mtime after: ${mtimeAfter.toISOString()}`); + } else { + console.log("compiler.log still does not exist"); + } + + // Assert that compiler.log was updated + assert.ok(mtimeAfter, "compiler.log should exist after file save"); + if (mtimeBefore && mtimeAfter) { + assert.ok( + mtimeAfter > mtimeBefore, + "compiler.log should be updated after file save", + ); + console.log("SUCCESS: compiler.log was updated after file save"); + } else if (!mtimeBefore && mtimeAfter) { + console.log("SUCCESS: compiler.log was created after file save"); + } + + // Restore original content + console.log("Restoring original content..."); + await restoreContentAndSave(editor, originalContent); + console.log("Original content restored"); + + await sleep(1000); + console.log("ReScript 9 recompilation test completed"); + }); + + test("ReScript 9: Should prompt to start build when no lock file exists", async () => { + const workspaceRoot = getWorkspaceRoot(); + console.log("Workspace root:", workspaceRoot); + + // Remove lock file to trigger the "Start Build" prompt + removeBsbLockFile(workspaceRoot); + + // Open a .res file to trigger the LSP + const resFilePath = path.join(workspaceRoot, "src", "Hello.res"); + console.log("Opening file:", resFilePath); + + const document = await vscode.workspace.openTextDocument(resFilePath); + await vscode.window.showTextDocument(document); + console.log("File opened successfully"); + + // Wait for LSP to process and potentially show the prompt + await sleep(1000); + + // Read the Language Server log to verify the prompt was shown + const promptResult = findBuildPromptInLogs(); + if (promptResult.found) { + console.log("Found prompt message in logs"); + } + + assert.ok( + promptResult.found, + "Should find 'Prompting to start build' message in Language Server log", + ); + console.log("SUCCESS: Build prompt was shown for ReScript 9 project"); + }); + + test("ReScript 9: Lock file should be cleaned up on language server restart", async () => { + const workspaceRoot = getWorkspaceRoot(); + console.log("Workspace root:", workspaceRoot); + + // First restart language server to ensure clean state + console.log("Restarting language server to ensure clean state..."); + await vscode.commands.executeCommand( + "rescript-vscode.restart_language_server", + ); + await sleep(2000); + + // Open a ReScript file + const resFilePath = path.join(workspaceRoot, "src", "Hello.res"); + console.log("Opening file:", resFilePath); + + const document = await vscode.workspace.openTextDocument(resFilePath); + await vscode.window.showTextDocument(document); + console.log("File opened successfully"); + + // Start the build watcher + console.log("Starting build watcher..."); + await vscode.commands.executeCommand("rescript-vscode.start_build"); + + // Wait for lock file to appear (poll up to 5 seconds) + await sleep(500); + const lockPath = path.join(workspaceRoot, ".bsb.lock"); + const lockExistsBefore = await waitFor(() => fs.existsSync(lockPath), { + timeout: 5000, + interval: 500, + message: ".bsb.lock", + }); + assert.ok(lockExistsBefore, ".bsb.lock should exist before restart"); + + // Restart language server (this should kill the build watcher and clean up lock file) + console.log("Restarting language server..."); + await vscode.commands.executeCommand( + "rescript-vscode.restart_language_server", + ); + + // Wait for restart to complete + await sleep(2000); + + // Verify lock file is cleaned up + const lockExistsAfter = fs.existsSync(lockPath); + console.log(`.bsb.lock exists after restart: ${lockExistsAfter}`); + assert.ok( + !lockExistsAfter, + ".bsb.lock should be cleaned up after language server restart", + ); + + console.log("SUCCESS: Lock file was cleaned up on language server restart"); + }); +}); diff --git a/client/tsconfig.json b/client/tsconfig.json index 1974b6b8d..04ed11eee 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -5,7 +5,8 @@ "lib": ["ES2019"], "outDir": "out", "rootDirs": ["src", "../shared/src"], - "sourceMap": true + "sourceMap": true, + "skipLibCheck": true }, "include": ["src", "../shared/src"], "exclude": ["node_modules"] diff --git a/package.json b/package.json index fa51db7df..9eea5f2a9 100644 --- a/package.json +++ b/package.json @@ -73,10 +73,18 @@ "command": "rescript-vscode.stop_code_analysis", "title": "ReScript: Stop Code Analyzer" }, + { + "command": "rescript-vscode.show_reanalyze_server_log", + "title": "ReScript: Show Code Analyzer Server Log" + }, { "command": "rescript-vscode.restart_language_server", "title": "ReScript: Restart Language Server" }, + { + "command": "rescript-vscode.start_build", + "title": "ReScript: Start Build" + }, { "command": "rescript-vscode.switch-impl-intf", "title": "ReScript: Switch implementation/interface", diff --git a/server/src/projectFiles.ts b/server/src/projectFiles.ts index a8684360a..f39edd2fc 100644 --- a/server/src/projectFiles.ts +++ b/server/src/projectFiles.ts @@ -16,6 +16,9 @@ export interface projectFiles { namespaceName: string | null; bsbWatcherByEditor: null | cp.ChildProcess; + // The root path where the build watcher runs (could be monorepo root) + // Used for lock file cleanup when killing the watcher + buildRootPath: NormalizedPath | null; // 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 diff --git a/server/src/server.ts b/server/src/server.ts index 7459f1051..6bd4bfd56 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -142,6 +142,13 @@ let openCompiledFileRequest = new v.RequestType< void >("textDocument/openCompiled"); +// Request to start the build watcher for a project +let startBuildRequest = new v.RequestType< + p.TextDocumentIdentifier, + { success: boolean }, + void +>("rescript/startBuild"); + export let getCurrentCompilerDiagnosticsForFile = ( fileUri: utils.FileURI, ): p.Diagnostic[] => { @@ -436,6 +443,7 @@ let openedFile = async (fileUri: utils.FileURI, fileContent: string) => { rescriptVersion: await utils.findReScriptVersionForProjectRoot(projectRootPath), bsbWatcherByEditor: null, + buildRootPath: null, bscBinaryLocation: await utils.findBscExeBinary(projectRootPath), editorAnalysisLocation: await utils.findEditorAnalysisBinary(projectRootPath), @@ -452,21 +460,37 @@ let openedFile = async (fileUri: utils.FileURI, fileContent: string) => { } let root = projectsFiles.get(projectRootPath)!; root.openFiles.add(filePath); - // check if .bsb.lock is still there. If not, start a bsb -w ourselves + // check if a lock file exists. If not, start a build watcher ourselves // because otherwise the diagnostics info we'll display might be stale - let bsbLockPath = path.join(projectRootPath, c.bsbLock); + // ReScript < 12: .bsb.lock in project root + // ReScript >= 12: lib/rescript.lock + // For monorepos, the lock file is at the monorepo root, not the subpackage + let rescriptBinaryPath = await findRescriptBinary(projectRootPath); + let buildRootPath = projectRootPath; + if (rescriptBinaryPath != null) { + const monorepoRootPath = + utils.getMonorepoRootFromBinaryPath(rescriptBinaryPath); + if (monorepoRootPath != null) { + buildRootPath = monorepoRootPath; + } + } + let bsbLockPath = path.join(buildRootPath, c.bsbLock); + let rescriptLockPath = path.join(buildRootPath, c.rescriptLockPartialPath); + let hasLockFile = + fs.existsSync(bsbLockPath) || fs.existsSync(rescriptLockPath); if ( projectRootState.hasPromptedToStartBuild === false && config.extensionConfiguration.askToStartBuild === true && - !fs.existsSync(bsbLockPath) + !hasLockFile ) { // TODO: sometime stale .bsb.lock dangling. bsb -w knows .bsb.lock is // stale. Use that logic // TODO: close watcher when lang-server shuts down - if ((await findRescriptBinary(projectRootPath)) != null) { + if (rescriptBinaryPath != null) { + getLogger().info(`Prompting to start build for ${buildRootPath}`); let payload: clientSentBuildAction = { title: c.startBuildAction, - projectRootPath: projectRootPath, + projectRootPath: buildRootPath, }; let params = { type: p.MessageType.Info, @@ -510,6 +534,7 @@ let openedFile = async (fileUri: utils.FileURI, fileContent: string) => { let closedFile = async (fileUri: utils.FileURI) => { let filePath = utils.uriToNormalizedPath(fileUri); + getLogger().log(`Closing file ${filePath}`); if (config.extensionConfiguration.incrementalTypechecking?.enable) { ic.handleClosedFile(filePath); @@ -522,15 +547,27 @@ let closedFile = async (fileUri: utils.FileURI) => { let root = projectsFiles.get(projectRootPath); if (root != null) { root.openFiles.delete(filePath); + getLogger().log( + `Open files remaining for ${projectRootPath}: ${root.openFiles.size}`, + ); // clear diagnostics too if no open files open in said project if (root.openFiles.size === 0) { await deleteProjectConfigCache(projectRootPath); deleteProjectDiagnostics(projectRootPath); if (root.bsbWatcherByEditor !== null) { - root.bsbWatcherByEditor.kill(); + getLogger().info( + `Killing build watcher for ${projectRootPath} (all files closed)`, + ); + utils.killBuildWatcher( + root.bsbWatcherByEditor, + root.buildRootPath ?? undefined, + ); root.bsbWatcherByEditor = null; + root.buildRootPath = null; } } + } else { + getLogger().log(`No project state found for ${projectRootPath}`); } } }; @@ -1229,6 +1266,106 @@ async function createInterface(msg: p.RequestMessage): Promise { } } +// Shared function to start build watcher for a project +// Returns true if watcher was started or already running, false on failure +async function startBuildWatcher( + projectRootPath: utils.NormalizedPath, + rescriptBinaryPath: utils.NormalizedPath, + options?: { + createProjectStateIfMissing?: boolean; + monorepoRootPath?: utils.NormalizedPath | null; + }, +): Promise { + let root = projectsFiles.get(projectRootPath); + + // Create project state if missing and option is set + if (root == null && options?.createProjectStateIfMissing) { + const namespaceName = utils.getNamespaceNameFromConfigFile(projectRootPath); + root = { + openFiles: new Set(), + filesWithDiagnostics: new Set(), + filesDiagnostics: {}, + namespaceName: + namespaceName.kind === "success" ? namespaceName.result : null, + rescriptVersion: + await utils.findReScriptVersionForProjectRoot(projectRootPath), + bsbWatcherByEditor: null, + buildRootPath: null, + bscBinaryLocation: await utils.findBscExeBinary(projectRootPath), + editorAnalysisLocation: + await utils.findEditorAnalysisBinary(projectRootPath), + hasPromptedToStartBuild: true, // Don't prompt since we're starting the build + }; + projectsFiles.set(projectRootPath, root); + } + + if (root == null) { + return false; + } + + // If a build watcher is already running, return success + if (root.bsbWatcherByEditor != null) { + getLogger().info(`Build watcher already running for ${projectRootPath}`); + return true; + } + + // Use monorepo root for cwd (monorepo support), fall back to project root + const buildCwd = options?.monorepoRootPath ?? projectRootPath; + + getLogger().info( + `Starting build watcher for ${projectRootPath} in ${buildCwd} (ReScript ${root.rescriptVersion ?? "unknown"})`, + ); + let bsbProcess = utils.runBuildWatcherUsingValidBuildPath( + rescriptBinaryPath, + buildCwd, + root.rescriptVersion, + ); + root.bsbWatcherByEditor = bsbProcess; + root.buildRootPath = buildCwd; + + return true; +} + +async function handleStartBuildRequest( + msg: p.RequestMessage, +): Promise { + let params = msg.params as p.TextDocumentIdentifier; + let filePath = utils.uriToNormalizedPath(params.uri as utils.FileURI); + let projectRootPath = utils.findProjectRootOfFile(filePath); + + if (projectRootPath == null) { + return { + jsonrpc: c.jsonrpcVersion, + id: msg.id, + result: { success: false }, + }; + } + + let rescriptBinaryPath = await findRescriptBinary(projectRootPath); + if (rescriptBinaryPath == null) { + return { + jsonrpc: c.jsonrpcVersion, + id: msg.id, + result: { success: false }, + }; + } + + // Derive monorepo root from binary path for monorepo support + const monorepoRootPath = + utils.getMonorepoRootFromBinaryPath(rescriptBinaryPath); + + const success = await startBuildWatcher(projectRootPath, rescriptBinaryPath, { + createProjectStateIfMissing: true, + monorepoRootPath, + }); + + return { + jsonrpc: c.jsonrpcVersion, + id: msg.id, + result: { success }, + }; +} + function openCompiledFile(msg: p.RequestMessage): p.Message { let params = msg.params as p.TextDocumentIdentifier; let filePath = utils.uriToNormalizedPath(params.uri as utils.FileURI); @@ -1622,6 +1759,19 @@ async function onMessage(msg: p.Message) { clearInterval(pullConfigurationPeriodically); } + // Kill all build watchers on shutdown + for (const [projectPath, projectState] of projectsFiles) { + if (projectState.bsbWatcherByEditor != null) { + getLogger().info(`Killing build watcher for ${projectPath}`); + utils.killBuildWatcher( + projectState.bsbWatcherByEditor, + projectState.buildRootPath ?? undefined, + ); + projectState.bsbWatcherByEditor = null; + projectState.buildRootPath = null; + } + } + let response: p.ResponseMessage = { jsonrpc: c.jsonrpcVersion, id: msg.id, @@ -1658,6 +1808,8 @@ async function onMessage(msg: p.Message) { send(await createInterface(msg)); } else if (msg.method === openCompiledFileRequest.method) { send(openCompiledFile(msg)); + } else if (msg.method === startBuildRequest.method) { + send(await handleStartBuildRequest(msg)); } else if (msg.method === p.InlayHintRequest.method) { let params = msg.params as InlayHintParams; let extName = path.extname(params.textDocument.uri); @@ -1739,19 +1891,22 @@ async function onMessage(msg: p.Message) { ); return; } - // TODO: sometime stale .bsb.lock dangling + // TODO: sometime stale lock file dangling // TODO: close watcher when lang-server shuts down. However, by Node's // default, these subprocesses are automatically killed when this // language-server process exits let rescriptBinaryPath = await findRescriptBinary(projectRootPath); if (rescriptBinaryPath != null) { - let bsbProcess = utils.runBuildWatcherUsingValidBuildPath( - rescriptBinaryPath, - projectRootPath, - ); - let root = projectsFiles.get(projectRootPath)!; - root.bsbWatcherByEditor = bsbProcess; - // bsbProcess.on("message", (a) => console.log(a)); + // Derive monorepo root from binary path for monorepo support + const monorepoRootPath = + utils.getMonorepoRootFromBinaryPath(rescriptBinaryPath); + // Note: projectRootPath here might be the monorepo root (buildRootPath from prompt), + // which may not have project state if the file was opened from a subpackage. + // Use createProjectStateIfMissing to handle this case. + await startBuildWatcher(projectRootPath, rescriptBinaryPath, { + createProjectStateIfMissing: true, + monorepoRootPath, + }); } } } diff --git a/server/src/utils.ts b/server/src/utils.ts index 4502ebbd3..e249f3398 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -11,9 +11,11 @@ import fsAsync from "fs/promises"; import * as os from "os"; import semver from "semver"; import { fileURLToPath, pathToFileURL } from "url"; +import { getLogger } from "./logger"; import { findBinary as findSharedBinary, + getMonorepoRootFromBinaryPath as getMonorepoRootFromBinaryPathShared, type BinaryName, } from "../../shared/src/findBinary"; import { findProjectRootOfFileInDir as findProjectRootOfFileInDirShared } from "../../shared/src/projectRoots"; @@ -218,6 +220,11 @@ let findBinary = async ( export let findRescriptBinary = (projectRootPath: NormalizedPath | null) => findBinary(projectRootPath, "rescript"); +export let getMonorepoRootFromBinaryPath = ( + binaryPath: string | null, +): NormalizedPath | null => + normalizePath(getMonorepoRootFromBinaryPathShared(binaryPath)); + export let findBscExeBinary = (projectRootPath: NormalizedPath | null) => findBinary(projectRootPath, "bsc.exe"); @@ -609,10 +616,23 @@ export let getCompiledFilePath = ( export let runBuildWatcherUsingValidBuildPath = ( buildPath: p.DocumentUri, projectRootPath: p.DocumentUri, + rescriptVersion?: string | null, ) => { let cwdEnv = { cwd: projectRootPath, }; + // ReScript >= 12.0.0 uses "rescript watch" instead of "rescript build -w" + let useWatchCommand = + rescriptVersion != null && + semver.valid(rescriptVersion) != null && + semver.gte(rescriptVersion, "12.0.0"); + let args = useWatchCommand ? ["watch"] : ["build", "-w"]; + + getLogger().info( + `Running build watcher: ${buildPath} ${args.join(" ")} in ${projectRootPath}`, + ); + + let proc: childProcess.ChildProcess; if (process.platform === "win32") { /* - a node.js script in node_modules/.bin on windows is wrapped in a @@ -626,9 +646,86 @@ export let runBuildWatcherUsingValidBuildPath = ( (since the path might have spaces), which `execFile` would have done for you under the hood */ - return childProcess.exec(`"${buildPath}".cmd build -w`, cwdEnv); + proc = childProcess.exec(`"${buildPath}".cmd ${args.join(" ")}`, cwdEnv); } else { - return childProcess.execFile(buildPath, ["build", "-w"], cwdEnv); + // Use spawn with detached:true so we can kill the entire process group later + // This ensures child processes (like native rescript binary) are also killed + // Use "pipe" for stdin instead of "ignore" because older ReScript versions (9.x, 10.x, 11.x) + // have a handler that exits when stdin closes: `process.stdin.on("close", exitProcess)` + proc = childProcess.spawn(buildPath, args, { + ...cwdEnv, + detached: true, + stdio: ["pipe", "pipe", "pipe"], + }); + } + + proc.on("error", (err) => { + getLogger().error(`Build watcher error: ${err.message}`); + }); + + proc.on("exit", (code, signal) => { + getLogger().info( + `Build watcher exited with code ${code}, signal ${signal}`, + ); + }); + + if (proc.stdout) { + proc.stdout.on("data", (data) => { + getLogger().log(`[build stdout] ${data.toString().trim()}`); + }); + } + + if (proc.stderr) { + proc.stderr.on("data", (data) => { + getLogger().log(`[build stderr] ${data.toString().trim()}`); + }); + } + + return proc; +}; + +/** + * Kill a build watcher process and all its children, and clean up the lock file. + * On Unix, kills the entire process group if the process was started with detached:true. + * Also removes the lock file since the rescript compiler doesn't clean it up on SIGTERM. + */ +export let killBuildWatcher = ( + proc: childProcess.ChildProcess, + buildRootPath?: string, +): void => { + if (proc.pid == null) { + return; + } + try { + if (process.platform !== "win32") { + // Kill the entire process group (negative PID) + // This ensures child processes spawned by the JS wrapper are also killed + process.kill(-proc.pid, "SIGTERM"); + } else { + proc.kill(); + } + } catch (e) { + // Process might already be dead + getLogger().log(`Error killing build watcher: ${e}`); + } + + // Clean up lock files since the rescript compiler doesn't remove them on SIGTERM + // ReScript >= 12 uses lib/rescript.lock, older versions use .bsb.lock + if (buildRootPath != null) { + const lockFiles = [ + path.join(buildRootPath, "lib", "rescript.lock"), + path.join(buildRootPath, ".bsb.lock"), + ]; + for (const lockFilePath of lockFiles) { + try { + if (fs.existsSync(lockFilePath)) { + fs.unlinkSync(lockFilePath); + getLogger().log(`Removed lock file: ${lockFilePath}`); + } + } catch (e) { + getLogger().log(`Error removing lock file: ${e}`); + } + } } }; diff --git a/shared/src/findBinary.ts b/shared/src/findBinary.ts index 09cd65d6a..6c89efecf 100644 --- a/shared/src/findBinary.ts +++ b/shared/src/findBinary.ts @@ -18,8 +18,11 @@ type FindBinaryOptions = { }; const compilerInfoPartialPath = path.join("lib", "bs", "compiler-info.json"); -const platformDir = - process.arch === "arm64" ? process.platform + process.arch : process.platform; +// For arm64, try the arm64-specific directory first (e.g., darwinarm64), +// then fall back to the generic platform directory (e.g., darwin) for older ReScript versions +const platformDirArm64 = + process.arch === "arm64" ? process.platform + process.arch : null; +const platformDirGeneric = process.platform; const normalizePath = (filePath: string | null): string | null => { return filePath != null ? path.normalize(filePath) : null; @@ -71,10 +74,12 @@ export const findBinary = async ({ const bscPath = compileInfo.bsc_path; if (binary === "bsc.exe") { return normalizePath(bscPath); - } else { + } else if (binary !== "rescript") { + // For native binaries (not "rescript" JS wrapper), use the bsc_path directory const binaryPath = path.join(path.dirname(bscPath), binary); return normalizePath(binaryPath); } + // For "rescript", fall through to find the JS wrapper below } } catch {} } @@ -122,7 +127,17 @@ export const findBinary = async ({ binaryPath = binPaths.rescript_exe; } } else { - binaryPath = path.join(rescriptDir, platformDir, binary); + // For older ReScript versions (< 12.0.0-alpha.13), try arm64-specific directory first, + // then fall back to generic platform directory (older versions don't have arm64 directories) + if (platformDirArm64 != null) { + const arm64Path = path.join(rescriptDir, platformDirArm64, binary); + if (fs.existsSync(arm64Path)) { + binaryPath = arm64Path; + } + } + if (binaryPath == null) { + binaryPath = path.join(rescriptDir, platformDirGeneric, binary); + } } if (binaryPath != null && fs.existsSync(binaryPath)) { @@ -131,3 +146,19 @@ export const findBinary = async ({ return null; }; + +/** + * Derives the monorepo root directory from a binary path. + * For a path like `/monorepo/node_modules/.bin/rescript`, returns `/monorepo`. + * This is useful for monorepo support where the binary is in the monorepo root's + * node_modules, but the project root (nearest rescript.json) might be a subpackage. + */ +export const getMonorepoRootFromBinaryPath = ( + binaryPath: string | null, +): string | null => { + if (binaryPath == null) { + return null; + } + const match = binaryPath.match(/^(.*?)[\\/]+node_modules[\\/]+/); + return match ? normalizePath(match[1]) : null; +};