diff --git a/client/src/commands/code_analysis.ts b/client/src/commands/code_analysis.ts index e739573f0..61bb4f156 100644 --- a/client/src/commands/code_analysis.ts +++ b/client/src/commands/code_analysis.ts @@ -14,7 +14,11 @@ import { OutputChannel, StatusBarItem, } from "vscode"; -import { findProjectRootOfFileInDir, getBinaryPath } from "../utils"; +import { + findProjectRootOfFileInDir, + getBinaryPath, + NormalizedPath, +} from "../utils"; export let statusBarItem = { setToStopText: (codeAnalysisRunningStatusBarItem: StatusBarItem) => { @@ -208,7 +212,7 @@ export const runCodeAnalysisWithReanalyze = ( let currentDocument = window.activeTextEditor.document; let cwd = targetDir ?? path.dirname(currentDocument.uri.fsPath); - let projectRootPath: string | null = findProjectRootOfFileInDir( + let projectRootPath: NormalizedPath | null = findProjectRootOfFileInDir( currentDocument.uri.fsPath, ); diff --git a/client/src/commands/dump_debug.ts b/client/src/commands/dump_debug.ts index a845848c3..58e4054c2 100644 --- a/client/src/commands/dump_debug.ts +++ b/client/src/commands/dump_debug.ts @@ -11,6 +11,7 @@ import { createFileInTempDir, findProjectRootOfFileInDir, getBinaryPath, + NormalizedPath, } from "../utils"; import * as path from "path"; @@ -136,7 +137,8 @@ export const dumpDebug = async ( const { line: endLine, character: endChar } = editor.selection.end; const filePath = editor.document.uri.fsPath; - let projectRootPath: string | null = findProjectRootOfFileInDir(filePath); + let projectRootPath: NormalizedPath | null = + findProjectRootOfFileInDir(filePath); const binaryPath = getBinaryPath( "rescript-editor-analysis.exe", projectRootPath, diff --git a/client/src/utils.ts b/client/src/utils.ts index 1474888ee..17135d14a 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -9,6 +9,27 @@ import { DocumentUri } from "vscode-languageclient"; * to the server itself. */ +/** + * Branded type for normalized file paths. + * + * All paths should be normalized to ensure consistent lookups and prevent + * path format mismatches (e.g., trailing slashes, relative vs absolute paths). + * + * Use `normalizePath()` to convert a regular path to a `NormalizedPath`. + */ +export type NormalizedPath = string & { __brand: "NormalizedPath" }; + +/** + * Normalizes a file path and returns it as a `NormalizedPath`. + * + * @param filePath - The path to normalize (can be null) + * @returns The normalized path, or null if input was null + */ +export function normalizePath(filePath: string | null): NormalizedPath | null { + // `path.normalize` ensures we can assume string is now NormalizedPath + return filePath != null ? (path.normalize(filePath) as NormalizedPath) : null; +} + type binaryName = "rescript-editor-analysis.exe" | "rescript-tools.exe"; const platformDir = @@ -29,7 +50,7 @@ export const getLegacyBinaryProdPath = (b: binaryName) => export const getBinaryPath = ( binaryName: "rescript-editor-analysis.exe" | "rescript-tools.exe", - projectRootPath: string | null = null, + projectRootPath: NormalizedPath | null = null, ): string | null => { const binaryFromCompilerPackage = path.join( projectRootPath ?? "", @@ -60,16 +81,23 @@ export const createFileInTempDir = (prefix = "", extension = "") => { }; export let findProjectRootOfFileInDir = ( - source: DocumentUri, -): null | DocumentUri => { - let dir = path.dirname(source); + source: string, +): NormalizedPath | null => { + const normalizedSource = normalizePath(source); + if (normalizedSource == null) { + return null; + } + const dir = normalizePath(path.dirname(normalizedSource)); + if (dir == null) { + return null; + } if ( fs.existsSync(path.join(dir, "rescript.json")) || fs.existsSync(path.join(dir, "bsconfig.json")) ) { return dir; } else { - if (dir === source) { + if (dir === normalizedSource) { // reached top return null; } else { diff --git a/server/src/bsc-args/rewatch.ts b/server/src/bsc-args/rewatch.ts index e4dc4ab60..4cd229d5a 100644 --- a/server/src/bsc-args/rewatch.ts +++ b/server/src/bsc-args/rewatch.ts @@ -17,14 +17,14 @@ export type RewatchCompilerArgs = { async function getRuntimePath( entry: IncrementallyCompiledFileInfo, -): Promise { +): Promise { return utils.getRuntimePathFromWorkspaceRoot(entry.project.workspaceRootPath); } export async function getRewatchBscArgs( send: (msg: p.Message) => void, - bscBinaryLocation: string | null, - projectsFiles: Map, + bscBinaryLocation: utils.NormalizedPath | null, + projectsFiles: Map, entry: IncrementallyCompiledFileInfo, ): Promise { const rewatchCacheEntry = entry.buildRewatch; @@ -103,7 +103,8 @@ export async function getRewatchBscArgs( includePrerelease: true, }) ) { - let rescriptRuntime: string | null = await getRuntimePath(entry); + let rescriptRuntime: utils.NormalizedPath | null = + await getRuntimePath(entry); if (rescriptRuntime !== null) { env["RESCRIPT_RUNTIME"] = rescriptRuntime; diff --git a/server/src/codeActions.ts b/server/src/codeActions.ts index fe15b4928..e361f1cac 100644 --- a/server/src/codeActions.ts +++ b/server/src/codeActions.ts @@ -3,18 +3,17 @@ // OCaml binary. import * as p from "vscode-languageserver-protocol"; import * as utils from "./utils"; -import { fileURLToPath } from "url"; export type fileCodeActions = { range: p.Range; codeAction: p.CodeAction }; export type filesCodeActions = { - [key: string]: fileCodeActions[]; + [key: utils.FileURI]: fileCodeActions[]; }; interface findCodeActionsConfig { diagnostic: p.Diagnostic; diagnosticMessage: string[]; - file: string; + file: utils.FileURI; range: p.Range; addFoundActionsHere: filesCodeActions; } @@ -190,7 +189,7 @@ interface codeActionExtractorConfig { line: string; index: number; array: string[]; - file: string; + file: utils.FileURI; range: p.Range; diagnostic: p.Diagnostic; codeActions: filesCodeActions; @@ -327,7 +326,7 @@ let handleUndefinedRecordFieldsAction = ({ }: { recordFieldNames: string[]; codeActions: filesCodeActions; - file: string; + file: utils.FileURI; range: p.Range; diagnostic: p.Diagnostic; todoValue: string; @@ -631,7 +630,7 @@ let simpleAddMissingCases: codeActionExtractor = async ({ .join("") .trim(); - let filePath = fileURLToPath(file); + let filePath = utils.uriToNormalizedPath(file); let newSwitchCode = await utils.runAnalysisAfterSanityCheck(filePath, [ "codemod", diff --git a/server/src/find-runtime.ts b/server/src/find-runtime.ts index a863321dd..0f35032bc 100644 --- a/server/src/find-runtime.ts +++ b/server/src/find-runtime.ts @@ -1,6 +1,7 @@ import { readdir, stat as statAsync, readFile } from "fs/promises"; import { join, resolve } from "path"; import { compilerInfoPartialPath } from "./constants"; +import { NormalizedPath, normalizePath } from "./utils"; // Efficient parallel folder traversal to find node_modules directories async function findNodeModulesDirs( @@ -92,14 +93,18 @@ async function findRescriptRuntimeInAlternativeLayout( return results; } -async function findRuntimePath(project: string): Promise { +async function findRuntimePath( + project: NormalizedPath, +): Promise { // Try a compiler-info.json file first const compilerInfo = resolve(project, compilerInfoPartialPath); try { const contents = await readFile(compilerInfo, "utf8"); const compileInfo: { runtime_path?: string } = JSON.parse(contents); if (compileInfo && compileInfo.runtime_path) { - return [compileInfo.runtime_path]; + // We somewhat assume the user to pass down a normalized path, but we cannot be sure of this. + const normalizedRuntimePath = normalizePath(compileInfo.runtime_path); + return normalizedRuntimePath ? [normalizedRuntimePath] : []; } } catch { // Ignore errors, fallback to node_modules search @@ -146,7 +151,10 @@ async function findRuntimePath(project: string): Promise { }), ).then((results) => results.flatMap((x) => x)); - return rescriptRuntimeDirs.map((runtime) => resolve(runtime)); + return rescriptRuntimeDirs.map( + // `resolve` ensures we can assume string is now NormalizedPath + (runtime) => resolve(runtime) as NormalizedPath, + ); } /** @@ -156,7 +164,7 @@ async function findRuntimePath(project: string): Promise { * (see getRuntimePathFromWorkspaceRoot in utils.ts). */ export async function findRescriptRuntimesInProject( - project: string, -): Promise { + project: NormalizedPath, +): Promise { return await findRuntimePath(project); } diff --git a/server/src/incrementalCompilation.ts b/server/src/incrementalCompilation.ts index ee1b82ba3..b7e328978 100644 --- a/server/src/incrementalCompilation.ts +++ b/server/src/incrementalCompilation.ts @@ -1,8 +1,6 @@ import * as path from "path"; import fs from "fs"; import * as utils from "./utils"; -import { pathToFileURL } from "url"; -import readline from "readline"; import { performance } from "perf_hooks"; import * as p from "vscode-languageserver-protocol"; import * as cp from "node:child_process"; @@ -14,7 +12,8 @@ import { fileCodeActions } from "./codeActions"; import { projectsFiles } from "./projectFiles"; import { getRewatchBscArgs, RewatchCompilerArgs } from "./bsc-args/rewatch"; import { BsbCompilerArgs, getBsbBscArgs } from "./bsc-args/bsb"; -import { workspaceFolders } from "./server"; +import { getCurrentCompilerDiagnosticsForFile } from "./server"; +import { NormalizedPath } from "./utils"; export function debug() { return ( @@ -32,8 +31,8 @@ export type IncrementallyCompiledFileInfo = { file: { /** File type. */ extension: ".res" | ".resi"; - /** Path to the source file. */ - sourceFilePath: string; + /** Path to the source file (normalized). */ + sourceFilePath: NormalizedPath; /** Name of the source file. */ sourceFileName: string; /** Module name of the source file. */ @@ -41,9 +40,9 @@ export type IncrementallyCompiledFileInfo = { /** Namespaced module name of the source file. */ moduleNameNamespaced: string; /** Path to where the incremental file is saved. */ - incrementalFilePath: string; + incrementalFilePath: NormalizedPath; /** Location of the original type file. */ - originalTypeFileLocation: string; + originalTypeFileLocation: NormalizedPath; }; buildSystem: "bsb" | "rewatch"; /** Cache for build.ninja assets. */ @@ -55,7 +54,7 @@ export type IncrementallyCompiledFileInfo = { } | null; /** Cache for rewatch compiler args. */ buildRewatch: { - lastFile: string; + lastFile: NormalizedPath; compilerArgs: RewatchCompilerArgs; } | null; /** Info of the currently active incremental compilation. `null` if no incremental compilation is active. */ @@ -69,29 +68,30 @@ export type IncrementallyCompiledFileInfo = { killCompilationListeners: Array<() => void>; /** Project specific information. */ project: { - /** The root path of the project. */ - rootPath: string; + /** The root path of the project (normalized to match projectsFiles keys). */ + rootPath: NormalizedPath; /** The root path of the workspace (if a monorepo) */ - workspaceRootPath: string; + workspaceRootPath: NormalizedPath; /** Computed location of bsc. */ - bscBinaryLocation: string; + bscBinaryLocation: NormalizedPath; /** The arguments needed for bsc, derived from the project configuration/build.ninja. */ callArgs: Promise | null>; /** The location of the incremental folder for this project. */ - incrementalFolderPath: string; + incrementalFolderPath: NormalizedPath; }; /** Any code actions for this incremental file. */ codeActions: Array; }; const incrementallyCompiledFileInfo: Map< - string, + NormalizedPath, IncrementallyCompiledFileInfo > = new Map(); -const hasReportedFeatureFailedError: Set = new Set(); -const originalTypeFileToFilePath: Map = new Map(); +const hasReportedFeatureFailedError: Set = new Set(); +const originalTypeFileToFilePath: Map = + new Map(); -export function incrementalCompilationFileChanged(changedPath: string) { +export function incrementalCompilationFileChanged(changedPath: NormalizedPath) { const filePath = originalTypeFileToFilePath.get(changedPath); if (filePath != null) { const entry = incrementallyCompiledFileInfo.get(filePath); @@ -116,7 +116,7 @@ export function incrementalCompilationFileChanged(changedPath: string) { } export function removeIncrementalFileFolder( - projectRootPath: string, + projectRootPath: NormalizedPath, onAfterRemove?: () => void, ) { fs.rm( @@ -128,7 +128,7 @@ export function removeIncrementalFileFolder( ); } -export function recreateIncrementalFileFolder(projectRootPath: string) { +export function recreateIncrementalFileFolder(projectRootPath: NormalizedPath) { if (debug()) { console.log("Recreating incremental file folder"); } @@ -142,8 +142,8 @@ export function recreateIncrementalFileFolder(projectRootPath: string) { } export function cleanUpIncrementalFiles( - filePath: string, - projectRootPath: string, + filePath: NormalizedPath, + projectRootPath: NormalizedPath, ) { const ext = filePath.endsWith(".resi") ? ".resi" : ".res"; const namespace = utils.getNamespaceNameFromConfigFile(projectRootPath); @@ -242,7 +242,7 @@ function removeAnsiCodes(s: string): string { return s.replace(ansiEscape, ""); } function triggerIncrementalCompilationOfFile( - filePath: string, + filePath: NormalizedPath, fileContent: string, send: send, onCompilationFinished?: () => void, @@ -256,7 +256,9 @@ function triggerIncrementalCompilationOfFile( console.log("Did not find project root path for " + filePath); return; } - const project = projectsFiles.get(projectRootPath); + // projectRootPath is already normalized (NormalizedPath) from findProjectRootOfFile + // Use getProjectFile to verify the project exists + const project = utils.getProjectFile(projectRootPath); if (project == null) { if (debug()) console.log("Did not find open project for " + filePath); return; @@ -267,7 +269,8 @@ function triggerIncrementalCompilationOfFile( utils.computeWorkspaceRootPathFromLockfile(projectRootPath); // If null, it means either a lockfile was found (local package) or no parent project root exists // In both cases, we default to projectRootPath - const workspaceRootPath = computedWorkspaceRoot ?? projectRootPath; + const workspaceRootPath: NormalizedPath = + computedWorkspaceRoot ?? projectRootPath; // Determine if lockfile was found for debug logging // If computedWorkspaceRoot is null and projectRootPath is not null, check if parent exists @@ -299,21 +302,24 @@ function triggerIncrementalCompilationOfFile( ? `${moduleName}-${project.namespaceName}` : moduleName; - const incrementalFolderPath = path.join( + // projectRootPath is already NormalizedPath, appending a constant string still makes it a NormalizedPath + const incrementalFolderPath: NormalizedPath = path.join( projectRootPath, INCREMENTAL_FILE_FOLDER_LOCATION, - ); + ) as NormalizedPath; + // projectRootPath is already NormalizedPath, appending a constant string still makes it a NormalizedPath let originalTypeFileLocation = path.resolve( projectRootPath, c.compilerDirPartialPath, path.relative(projectRootPath, filePath), - ); + ) as NormalizedPath; const parsed = path.parse(originalTypeFileLocation); parsed.ext = ext === ".res" ? ".cmt" : ".cmti"; parsed.base = ""; - originalTypeFileLocation = path.format(parsed); + // As originalTypeFileLocation was a NormalizedPath, path.format ensures we can assume string is now NormalizedPath + originalTypeFileLocation = path.format(parsed) as NormalizedPath; incrementalFileCacheEntry = { file: { @@ -323,10 +329,14 @@ function triggerIncrementalCompilationOfFile( moduleNameNamespaced, sourceFileName: moduleName + ext, sourceFilePath: filePath, - incrementalFilePath: path.join(incrementalFolderPath, moduleName + ext), + // As incrementalFolderPath was a NormalizedPath, path.join ensures we can assume string is now NormalizedPath + incrementalFilePath: path.join( + incrementalFolderPath, + moduleName + ext, + ) as NormalizedPath, }, project: { - workspaceRootPath: workspaceRootPath ?? projectRootPath, + workspaceRootPath, rootPath: projectRootPath, callArgs: Promise.resolve([]), bscBinaryLocation, @@ -373,7 +383,10 @@ function triggerIncrementalCompilationOfFile( }; } } -function verifyTriggerToken(filePath: string, triggerToken: number): boolean { +function verifyTriggerToken( + filePath: NormalizedPath, + triggerToken: number, +): boolean { return ( incrementallyCompiledFileInfo.get(filePath)?.compilation?.triggerToken === triggerToken @@ -578,7 +591,7 @@ async function compileContents( const change = Object.values(ca.codeAction.edit.changes)[0]; ca.codeAction.edit.changes = { - [pathToFileURL(entry.file.sourceFilePath).toString()]: change, + [utils.pathToURI(entry.file.sourceFilePath)]: change, }; } }); @@ -645,12 +658,33 @@ async function compileContents( } } + const fileUri = utils.pathToURI(entry.file.sourceFilePath); + + // Get compiler diagnostics from main build (if any) and combine with incremental diagnostics + const compilerDiagnosticsForFile = + getCurrentCompilerDiagnosticsForFile(fileUri); + const allDiagnostics = [...res, ...compilerDiagnosticsForFile]; + + // Update filesWithDiagnostics to track this file + // entry.project.rootPath is guaranteed to match a key in projectsFiles + // (see triggerIncrementalCompilationOfFile where the entry is created) + const projectFile = projectsFiles.get(entry.project.rootPath); + + if (projectFile != null) { + if (allDiagnostics.length > 0) { + projectFile.filesWithDiagnostics.add(fileUri); + } else { + // Only remove if there are no diagnostics at all + projectFile.filesWithDiagnostics.delete(fileUri); + } + } + const notification: p.NotificationMessage = { jsonrpc: c.jsonrpcVersion, method: "textDocument/publishDiagnostics", params: { - uri: pathToFileURL(entry.file.sourceFilePath), - diagnostics: res, + uri: fileUri, + diagnostics: allDiagnostics, }, }; send(notification); @@ -667,7 +701,7 @@ async function compileContents( } export function handleUpdateOpenedFile( - filePath: string, + filePath: utils.NormalizedPath, fileContent: string, send: send, onCompilationFinished?: () => void, @@ -683,7 +717,7 @@ export function handleUpdateOpenedFile( ); } -export function handleClosedFile(filePath: string) { +export function handleClosedFile(filePath: NormalizedPath) { if (debug()) { console.log("Closed: " + filePath); } @@ -695,7 +729,7 @@ export function handleClosedFile(filePath: string) { } export function getCodeActionsFromIncrementalCompilation( - filePath: string, + filePath: NormalizedPath, ): Array | null { const entry = incrementallyCompiledFileInfo.get(filePath); if (entry != null) { diff --git a/server/src/lookup.ts b/server/src/lookup.ts index 38dff3761..6ab3bc6c8 100644 --- a/server/src/lookup.ts +++ b/server/src/lookup.ts @@ -1,9 +1,9 @@ import * as fs from "fs"; import * as path from "path"; -import * as p from "vscode-languageserver-protocol"; import { BuildSchema, ModuleFormat, ModuleFormatObject } from "./buildSchema"; import * as c from "./constants"; +import { NormalizedPath, normalizePath } from "./utils"; const getCompiledFolderName = (moduleFormat: ModuleFormat): string => { switch (moduleFormat) { @@ -23,31 +23,48 @@ export const replaceFileExtension = (filePath: string, ext: string): string => { return path.format({ dir: path.dirname(filePath), name, ext }); }; +export const replaceFileExtensionWithNormalizedPath = ( + filePath: NormalizedPath, + ext: string, +): NormalizedPath => { + let name = path.basename(filePath, path.extname(filePath)); + const result = path.format({ dir: path.dirname(filePath), name, ext }); + // path.format() doesn't preserve normalization, so we need to normalize the result + const normalized = normalizePath(result); + if (normalized == null) { + // Should never happen, but handle gracefully + return result as NormalizedPath; + } + return normalized; +}; + // Check if filePartialPath exists at directory and return the joined path, // otherwise recursively check parent directories for it. export const findFilePathFromProjectRoot = ( - directory: p.DocumentUri | null, // This must be a directory and not a file! + directory: NormalizedPath | null, // This must be a directory and not a file! filePartialPath: string, -): null | p.DocumentUri => { +): NormalizedPath | null => { if (directory == null) { return null; } - let filePath: p.DocumentUri = path.join(directory, filePartialPath); + let filePath = path.join(directory, filePartialPath); if (fs.existsSync(filePath)) { - return filePath; + return normalizePath(filePath); } - let parentDir: p.DocumentUri = path.dirname(directory); - if (parentDir === directory) { + let parentDirStr = path.dirname(directory); + if (parentDirStr === directory) { // reached the top return null; } + const parentDir = normalizePath(parentDirStr); + return findFilePathFromProjectRoot(parentDir, filePartialPath); }; -export const readConfig = (projDir: p.DocumentUri): BuildSchema | null => { +export const readConfig = (projDir: NormalizedPath): BuildSchema | null => { try { let rescriptJson = path.join(projDir, c.rescriptJsonPartialPath); let bsconfigJson = path.join(projDir, c.bsconfigPartialPath); @@ -109,9 +126,9 @@ export const getSuffixAndPathFragmentFromBsconfig = (bsconfig: BuildSchema) => { }; export const getFilenameFromBsconfig = ( - projDir: string, + projDir: NormalizedPath, partialFilePath: string, -): string | null => { +): NormalizedPath | null => { let bsconfig = readConfig(projDir); if (!bsconfig) { @@ -122,22 +139,26 @@ export const getFilenameFromBsconfig = ( let compiledPartialPath = replaceFileExtension(partialFilePath, suffix); - return path.join(projDir, pathFragment, compiledPartialPath); + const result = path.join(projDir, pathFragment, compiledPartialPath); + return normalizePath(result); }; // Monorepo helpers export const getFilenameFromRootBsconfig = ( - projDir: string, + projDir: NormalizedPath, partialFilePath: string, -): string | null => { +): NormalizedPath | null => { + // Start searching from the parent directory of projDir to find the workspace root + const parentDir = normalizePath(path.dirname(projDir)); + let rootConfigPath = findFilePathFromProjectRoot( - path.join("..", projDir), + parentDir, c.rescriptJsonPartialPath, ); if (!rootConfigPath) { rootConfigPath = findFilePathFromProjectRoot( - path.join("..", projDir), + parentDir, c.bsconfigPartialPath, ); } @@ -146,7 +167,11 @@ export const getFilenameFromRootBsconfig = ( return null; } - let rootConfig = readConfig(path.dirname(rootConfigPath)); + const rootConfigDir = normalizePath(path.dirname(rootConfigPath)); + if (rootConfigDir == null) { + return null; + } + let rootConfig = readConfig(rootConfigDir); if (!rootConfig) { return null; @@ -156,5 +181,6 @@ export const getFilenameFromRootBsconfig = ( let compiledPartialPath = replaceFileExtension(partialFilePath, suffix); - return path.join(projDir, pathFragment, compiledPartialPath); + const result = path.join(projDir, pathFragment, compiledPartialPath); + return normalizePath(result); }; diff --git a/server/src/projectFiles.ts b/server/src/projectFiles.ts index 82582e123..a8684360a 100644 --- a/server/src/projectFiles.ts +++ b/server/src/projectFiles.ts @@ -1,16 +1,17 @@ import * as cp from "node:child_process"; import * as p from "vscode-languageserver-protocol"; +import { NormalizedPath, FileURI } from "./utils"; export type filesDiagnostics = { - [key: string]: p.Diagnostic[]; + [key: FileURI]: p.Diagnostic[]; }; export interface projectFiles { - openFiles: Set; - filesWithDiagnostics: Set; + openFiles: Set; + filesWithDiagnostics: Set; filesDiagnostics: filesDiagnostics; rescriptVersion: string | undefined; - bscBinaryLocation: string | null; + bscBinaryLocation: NormalizedPath | null; editorAnalysisLocation: string | null; namespaceName: string | null; @@ -24,5 +25,11 @@ export interface projectFiles { hasPromptedToStartBuild: boolean | "never"; } -export let projectsFiles: Map = // project root path - new Map(); +/** + * Map of project root paths to their project state. + * + * Keys are normalized paths (NormalizedPath) to ensure consistent lookups + * and prevent path format mismatches. All paths should be normalized using + * `normalizePath()` before being used as keys. + */ +export let projectsFiles: Map = new Map(); diff --git a/server/src/server.ts b/server/src/server.ts index eb29ab634..fccd88916 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -22,16 +22,16 @@ import * as utils from "./utils"; import * as codeActions from "./codeActions"; import * as c from "./constants"; import { assert } from "console"; -import { fileURLToPath } from "url"; import { WorkspaceEdit } from "vscode-languageserver"; import { onErrorReported } from "./errorReporter"; import * as ic from "./incrementalCompilation"; import config, { extensionConfiguration } from "./config"; import { projectsFiles } from "./projectFiles"; +import { NormalizedPath } from "./utils"; // Absolute paths to all the workspace folders // Configured during the initialize request -export const workspaceFolders = new Set(); +export const workspaceFolders = new Set(); // This holds client capabilities specific to our extension, and not necessarily // related to the LS protocol. It's for enabling/disabling features that might @@ -76,7 +76,7 @@ const projectCompilationStates: Map = type CompilationStatusPayload = { project: string; - projectRootPath: string; + projectRootPath: NormalizedPath; status: "compiling" | "success" | "error" | "warning"; errorCount: number; warningCount: number; @@ -92,15 +92,17 @@ const sendCompilationStatus = (payload: CompilationStatusPayload) => { }; let findRescriptBinary = async ( - projectRootPath: p.DocumentUri | null, -): Promise => { + projectRootPath: utils.NormalizedPath | null, +): Promise => { if ( config.extensionConfiguration.binaryPath != null && fs.existsSync( path.join(config.extensionConfiguration.binaryPath, "rescript"), ) ) { - return path.join(config.extensionConfiguration.binaryPath, "rescript"); + return utils.normalizePath( + path.join(config.extensionConfiguration.binaryPath, "rescript"), + ); } return utils.findRescriptBinary(projectRootPath); @@ -118,8 +120,8 @@ let openCompiledFileRequest = new v.RequestType< void >("textDocument/openCompiled"); -let getCurrentCompilerDiagnosticsForFile = ( - fileUri: string, +export let getCurrentCompilerDiagnosticsForFile = ( + fileUri: utils.FileURI, ): p.Diagnostic[] => { let diagnostics: p.Diagnostic[] | null = null; @@ -166,10 +168,12 @@ let sendUpdatedDiagnostics = async () => { codeActionsFromDiagnostics = codeActions; // diff - Object.keys(filesAndErrors).forEach((file) => { + ( + Object.entries(filesAndErrors) as Array<[utils.FileURI, p.Diagnostic[]]> + ).forEach(([fileUri, diagnostics]) => { let params: p.PublishDiagnosticsParams = { - uri: file, - diagnostics: filesAndErrors[file], + uri: fileUri, + diagnostics, }; let notification: p.NotificationMessage = { jsonrpc: c.jsonrpcVersion, @@ -178,7 +182,7 @@ let sendUpdatedDiagnostics = async () => { }; send(notification); - filesWithDiagnostics.add(file); + filesWithDiagnostics.add(fileUri); }); if (done) { // clear old files @@ -214,8 +218,10 @@ let sendUpdatedDiagnostics = async () => { let errorCount = 0; let warningCount = 0; - for (const [fileUri, diags] of Object.entries(filesAndErrors)) { - const filePath = fileURLToPath(fileUri); + for (const [fileUri, diags] of Object.entries(filesAndErrors) as Array< + [utils.FileURI, p.Diagnostic[]] + >) { + const filePath = utils.uriToNormalizedPath(fileUri); if (filePath.startsWith(projectRootPath)) { for (const d of diags as v.Diagnostic[]) { if (d.severity === v.DiagnosticSeverity.Error) errorCount++; @@ -282,7 +288,7 @@ let sendUpdatedDiagnostics = async () => { } }; -let deleteProjectDiagnostics = (projectRootPath: string) => { +let deleteProjectDiagnostics = (projectRootPath: utils.NormalizedPath) => { let root = projectsFiles.get(projectRootPath); if (root != null) { root.filesWithDiagnostics.forEach((file) => { @@ -317,7 +323,7 @@ let sendCompilationFinishedMessage = () => { let debug = false; -let syncProjectConfigCache = async (rootPath: string) => { +let syncProjectConfigCache = async (rootPath: utils.NormalizedPath) => { try { if (debug) console.log("syncing project config cache for " + rootPath); await utils.runAnalysisAfterSanityCheck(rootPath, [ @@ -330,7 +336,7 @@ let syncProjectConfigCache = async (rootPath: string) => { } }; -let deleteProjectConfigCache = async (rootPath: string) => { +let deleteProjectConfigCache = async (rootPath: utils.NormalizedPath) => { try { if (debug) console.log("deleting project config cache for " + rootPath); await utils.runAnalysisAfterSanityCheck(rootPath, [ @@ -352,7 +358,9 @@ async function onWorkspaceDidChangeWatchedFiles( if ( config.extensionConfiguration.cache?.projectConfig?.enable === true ) { - let projectRoot = utils.findProjectRootOfFile(change.uri); + let projectRoot = utils.findProjectRootOfFile( + utils.uriToNormalizedPath(change.uri as utils.FileURI), + ); if (projectRoot != null) { await syncProjectConfigCache(projectRoot); } @@ -371,7 +379,9 @@ async function onWorkspaceDidChangeWatchedFiles( console.log("Error while sending updated diagnostics"); } } else { - ic.incrementalCompilationFileChanged(fileURLToPath(change.uri)); + ic.incrementalCompilationFileChanged( + utils.uriToNormalizedPath(change.uri as utils.FileURI), + ); } }), ); @@ -379,15 +389,16 @@ async function onWorkspaceDidChangeWatchedFiles( type clientSentBuildAction = { title: string; - projectRootPath: string; + projectRootPath: utils.NormalizedPath; }; -let openedFile = async (fileUri: string, fileContent: string) => { - let filePath = fileURLToPath(fileUri); +let openedFile = async (fileUri: utils.FileURI, fileContent: string) => { + let filePath = utils.uriToNormalizedPath(fileUri); stupidFileContentCache.set(filePath, fileContent); let projectRootPath = utils.findProjectRootOfFile(filePath); if (projectRootPath != null) { + // projectRootPath is already normalized (NormalizedPath) from findProjectRootOfFile let projectRootState = projectsFiles.get(projectRootPath); if (projectRootState == null) { if (config.extensionConfiguration.incrementalTypechecking?.enable) { @@ -477,8 +488,8 @@ let openedFile = async (fileUri: string, fileContent: string) => { } }; -let closedFile = async (fileUri: string) => { - let filePath = fileURLToPath(fileUri); +let closedFile = async (fileUri: utils.FileURI) => { + let filePath = utils.uriToNormalizedPath(fileUri); if (config.extensionConfiguration.incrementalTypechecking?.enable) { ic.handleClosedFile(filePath); @@ -504,8 +515,8 @@ let closedFile = async (fileUri: string) => { } }; -let updateOpenedFile = (fileUri: string, fileContent: string) => { - let filePath = fileURLToPath(fileUri); +let updateOpenedFile = (fileUri: utils.FileURI, fileContent: string) => { + let filePath = utils.uriToNormalizedPath(fileUri); assert(stupidFileContentCache.has(filePath)); stupidFileContentCache.set(filePath, fileContent); if (config.extensionConfiguration.incrementalTypechecking?.enable) { @@ -519,8 +530,8 @@ let updateOpenedFile = (fileUri: string, fileContent: string) => { }); } }; -let getOpenedFileContent = (fileUri: string) => { - let filePath = fileURLToPath(fileUri); +let getOpenedFileContent = (fileUri: utils.FileURI) => { + let filePath = utils.uriToNormalizedPath(fileUri); let content = stupidFileContentCache.get(filePath)!; assert(content != null); return content; @@ -546,8 +557,10 @@ export default function listen(useStdio = false) { async function hover(msg: p.RequestMessage) { let params = msg.params as p.HoverParams; - let filePath = fileURLToPath(params.textDocument.uri); - let code = getOpenedFileContent(params.textDocument.uri); + let filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); + let code = getOpenedFileContent(params.textDocument.uri as utils.FileURI); let tmpname = utils.createFileInTempDir(); fs.writeFileSync(tmpname, code, { encoding: "utf-8" }); let response = await utils.runAnalysisCommand( @@ -568,7 +581,9 @@ async function hover(msg: p.RequestMessage) { async function inlayHint(msg: p.RequestMessage) { const params = msg.params as p.InlayHintParams; - const filePath = fileURLToPath(params.textDocument.uri); + const filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); const response = await utils.runAnalysisCommand( filePath, @@ -595,7 +610,9 @@ function sendInlayHintsRefresh() { async function codeLens(msg: p.RequestMessage) { const params = msg.params as p.CodeLensParams; - const filePath = fileURLToPath(params.textDocument.uri); + const filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); const response = await utils.runAnalysisCommand( filePath, @@ -616,8 +633,10 @@ function sendCodeLensRefresh() { async function signatureHelp(msg: p.RequestMessage) { let params = msg.params as p.SignatureHelpParams; - let filePath = fileURLToPath(params.textDocument.uri); - let code = getOpenedFileContent(params.textDocument.uri); + let filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); + let code = getOpenedFileContent(params.textDocument.uri as utils.FileURI); let tmpname = utils.createFileInTempDir(); fs.writeFileSync(tmpname, code, { encoding: "utf-8" }); let response = await utils.runAnalysisCommand( @@ -641,7 +660,9 @@ async function signatureHelp(msg: p.RequestMessage) { async function definition(msg: p.RequestMessage) { // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_definition let params = msg.params as p.DefinitionParams; - let filePath = fileURLToPath(params.textDocument.uri); + let filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); let response = await utils.runAnalysisCommand( filePath, ["definition", filePath, params.position.line, params.position.character], @@ -653,7 +674,9 @@ async function definition(msg: p.RequestMessage) { async function typeDefinition(msg: p.RequestMessage) { // https://microsoft.github.io/language-server-protocol/specification/specification-current/#textDocument_typeDefinition let params = msg.params as p.TypeDefinitionParams; - let filePath = fileURLToPath(params.textDocument.uri); + let filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); let response = await utils.runAnalysisCommand( filePath, [ @@ -670,7 +693,9 @@ async function typeDefinition(msg: p.RequestMessage) { async function references(msg: p.RequestMessage) { // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references let params = msg.params as p.ReferenceParams; - let filePath = fileURLToPath(params.textDocument.uri); + let filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); let result: typeof p.ReferencesRequest.type = await utils.getReferencesForPosition(filePath, params.position); let response: p.ResponseMessage = { @@ -687,7 +712,9 @@ async function prepareRename( ): Promise { // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_prepareRename let params = msg.params as p.PrepareRenameParams; - let filePath = fileURLToPath(params.textDocument.uri); + let filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); // `prepareRename` was introduced in 12.0.0-beta.10 let projectRootPath = utils.findProjectRootOfFile(filePath); @@ -727,8 +754,8 @@ async function prepareRename( if (locations !== null) { locations.forEach((loc) => { if ( - path.normalize(fileURLToPath(loc.uri)) === - path.normalize(fileURLToPath(params.textDocument.uri)) + utils.uriToNormalizedPath(loc.uri as utils.FileURI) === + utils.uriToNormalizedPath(params.textDocument.uri as utils.FileURI) ) { let { start, end } = loc.range; let pos = params.position; @@ -753,7 +780,9 @@ async function prepareRename( async function rename(msg: p.RequestMessage) { // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rename let params = msg.params as p.RenameParams; - let filePath = fileURLToPath(params.textDocument.uri); + let filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); let documentChanges: (p.RenameFile | p.TextDocumentEdit)[] | null = await utils.runAnalysisAfterSanityCheck(filePath, [ "rename", @@ -777,9 +806,11 @@ async function rename(msg: p.RequestMessage) { async function documentSymbol(msg: p.RequestMessage) { // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentSymbol let params = msg.params as p.DocumentSymbolParams; - let filePath = fileURLToPath(params.textDocument.uri); + let filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); let extension = path.extname(params.textDocument.uri); - let code = getOpenedFileContent(params.textDocument.uri); + let code = getOpenedFileContent(params.textDocument.uri as utils.FileURI); let tmpname = utils.createFileInTempDir(extension); fs.writeFileSync(tmpname, code, { encoding: "utf-8" }); let response = await utils.runAnalysisCommand( @@ -813,9 +844,11 @@ function askForAllCurrentConfiguration() { async function semanticTokens(msg: p.RequestMessage) { // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_semanticTokens let params = msg.params as p.SemanticTokensParams; - let filePath = fileURLToPath(params.textDocument.uri); + let filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); let extension = path.extname(params.textDocument.uri); - let code = getOpenedFileContent(params.textDocument.uri); + let code = getOpenedFileContent(params.textDocument.uri as utils.FileURI); let tmpname = utils.createFileInTempDir(extension); fs.writeFileSync(tmpname, code, { encoding: "utf-8" }); let response = await utils.runAnalysisCommand( @@ -831,8 +864,10 @@ async function semanticTokens(msg: p.RequestMessage) { async function completion(msg: p.RequestMessage) { // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion let params = msg.params as p.ReferenceParams; - let filePath = fileURLToPath(params.textDocument.uri); - let code = getOpenedFileContent(params.textDocument.uri); + let filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); + let code = getOpenedFileContent(params.textDocument.uri as utils.FileURI); let tmpname = utils.createFileInTempDir(); fs.writeFileSync(tmpname, code, { encoding: "utf-8" }); let response = await utils.runAnalysisCommand( @@ -860,8 +895,12 @@ async function completionResolve(msg: p.RequestMessage) { if (item.documentation == null && item.data != null) { const data = item.data as { filePath: string; modulePath: string }; + const normalizedFilePath = utils.normalizePath(data.filePath); + if (normalizedFilePath == null) { + return response; + } let result = await utils.runAnalysisAfterSanityCheck( - data.filePath, + normalizedFilePath, ["completionResolve", data.filePath, data.modulePath], true, ); @@ -873,15 +912,17 @@ async function completionResolve(msg: p.RequestMessage) { async function codeAction(msg: p.RequestMessage): Promise { let params = msg.params as p.CodeActionParams; - let filePath = fileURLToPath(params.textDocument.uri); - let code = getOpenedFileContent(params.textDocument.uri); + let filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); + let code = getOpenedFileContent(params.textDocument.uri as utils.FileURI); let extension = path.extname(params.textDocument.uri); let tmpname = utils.createFileInTempDir(extension); // Check local code actions coming from the diagnostics, or from incremental compilation. let localResults: v.CodeAction[] = []; const fromDiagnostics = - codeActionsFromDiagnostics[params.textDocument.uri] ?? []; + codeActionsFromDiagnostics[params.textDocument.uri as utils.FileURI] ?? []; const fromIncrementalCompilation = ic.getCodeActionsFromIncrementalCompilation(filePath) ?? []; [...fromDiagnostics, ...fromIncrementalCompilation].forEach( @@ -938,7 +979,9 @@ function format(msg: p.RequestMessage): Array { result: [], }; let params = msg.params as p.DocumentFormattingParams; - let filePath = fileURLToPath(params.textDocument.uri); + let filePath = utils.uriToNormalizedPath( + params.textDocument.uri as utils.FileURI, + ); let extension = path.extname(params.textDocument.uri); if (extension !== c.resExt && extension !== c.resiExt) { let params: p.ShowMessageParams = { @@ -953,7 +996,7 @@ function format(msg: p.RequestMessage): Array { return [fakeSuccessResponse, response]; } else { // code will always be defined here, even though technically it can be undefined - let code = getOpenedFileContent(params.textDocument.uri); + let code = getOpenedFileContent(params.textDocument.uri as utils.FileURI); let projectRootPath = utils.findProjectRootOfFile(filePath); let project = @@ -988,12 +1031,15 @@ function format(msg: p.RequestMessage): Array { } } -let updateDiagnosticSyntax = async (fileUri: string, fileContent: string) => { +let updateDiagnosticSyntax = async ( + fileUri: utils.FileURI, + fileContent: string, +) => { if (config.extensionConfiguration.incrementalTypechecking?.enable) { // The incremental typechecking already sends syntax diagnostics. return; } - let filePath = fileURLToPath(fileUri); + let filePath = utils.uriToNormalizedPath(fileUri); let extension = path.extname(filePath); let tmpname = utils.createFileInTempDir(extension); fs.writeFileSync(tmpname, fileContent, { encoding: "utf-8" }); @@ -1011,12 +1057,29 @@ let updateDiagnosticSyntax = async (fileUri: string, fileContent: string) => { tmpname, ]); + let allDiagnostics = [ + ...syntaxDiagnosticsForFile, + ...compilerDiagnosticsForFile, + ]; + + // Update filesWithDiagnostics to track this file + let projectRootPath = utils.findProjectRootOfFile(filePath); + let projectFile = utils.getProjectFile(projectRootPath); + + if (projectFile != null) { + if (allDiagnostics.length > 0) { + projectFile.filesWithDiagnostics.add(fileUri); + } else { + projectFile.filesWithDiagnostics.delete(fileUri); + } + } + let notification: p.NotificationMessage = { jsonrpc: c.jsonrpcVersion, method: "textDocument/publishDiagnostics", params: { uri: fileUri, - diagnostics: [...syntaxDiagnosticsForFile, ...compilerDiagnosticsForFile], + diagnostics: allDiagnostics, }, }; @@ -1028,7 +1091,7 @@ let updateDiagnosticSyntax = async (fileUri: string, fileContent: string) => { async function createInterface(msg: p.RequestMessage): Promise { let params = msg.params as p.TextDocumentIdentifier; let extension = path.extname(params.uri); - let filePath = fileURLToPath(params.uri); + let filePath = utils.uriToNormalizedPath(params.uri as utils.FileURI); let projDir = utils.findProjectRootOfFile(filePath); if (projDir === null) { @@ -1115,7 +1178,10 @@ async function createInterface(msg: p.RequestMessage): Promise { let result = typeof response.result === "string" ? response.result : ""; try { - let resiPath = lookup.replaceFileExtension(filePath, c.resiExt); + let resiPath = lookup.replaceFileExtensionWithNormalizedPath( + filePath, + c.resiExt, + ); fs.writeFileSync(resiPath, result, { encoding: "utf-8" }); let response: p.ResponseMessage = { jsonrpc: c.jsonrpcVersion, @@ -1140,7 +1206,7 @@ async function createInterface(msg: p.RequestMessage): Promise { function openCompiledFile(msg: p.RequestMessage): p.Message { let params = msg.params as p.TextDocumentIdentifier; - let filePath = fileURLToPath(params.uri); + let filePath = utils.uriToNormalizedPath(params.uri as utils.FileURI); let projDir = utils.findProjectRootOfFile(filePath); if (projDir === null) { @@ -1264,10 +1330,13 @@ async function onMessage(msg: p.Message) { await onWorkspaceDidChangeWatchedFiles(params); } else if (msg.method === DidOpenTextDocumentNotification.method) { let params = msg.params as p.DidOpenTextDocumentParams; - await openedFile(params.textDocument.uri, params.textDocument.text); + await openedFile( + params.textDocument.uri as utils.FileURI, + params.textDocument.text, + ); await sendUpdatedDiagnostics(); await updateDiagnosticSyntax( - params.textDocument.uri, + params.textDocument.uri as utils.FileURI, params.textDocument.text, ); } else if (msg.method === DidChangeTextDocumentNotification.method) { @@ -1280,18 +1349,18 @@ async function onMessage(msg: p.Message) { } else { // we currently only support full changes updateOpenedFile( - params.textDocument.uri, + params.textDocument.uri as utils.FileURI, changes[changes.length - 1].text, ); await updateDiagnosticSyntax( - params.textDocument.uri, + params.textDocument.uri as utils.FileURI, changes[changes.length - 1].text, ); } } } else if (msg.method === DidCloseTextDocumentNotification.method) { let params = msg.params as p.DidCloseTextDocumentParams; - await closedFile(params.textDocument.uri); + await closedFile(params.textDocument.uri as utils.FileURI); } else if (msg.method === DidChangeConfigurationNotification.type.method) { // Can't seem to get this notification to trigger, but if it does this will be here and ensure we're synced up at the server. askForAllCurrentConfiguration(); @@ -1312,8 +1381,9 @@ async function onMessage(msg: p.Message) { // Save initial configuration, if present let initParams = msg.params as InitializeParams; for (const workspaceFolder of initParams.workspaceFolders || []) { - const workspaceRootPath = fileURLToPath(workspaceFolder.uri); - workspaceFolders.add(workspaceRootPath); + workspaceFolders.add( + utils.uriToNormalizedPath(workspaceFolder.uri as utils.FileURI), + ); } let initialConfiguration = initParams.initializationOptions ?.extensionConfiguration as extensionConfiguration | undefined; @@ -1566,7 +1636,17 @@ async function onMessage(msg: p.Message) { msg.result.title === c.startBuildAction ) { let msg_ = msg.result as clientSentBuildAction; - let projectRootPath = msg_.projectRootPath; + // Normalize the path since JSON serialization loses the branded type + // The type says it's NormalizedPath, so we ensure it actually is + let projectRootPath = utils.normalizePath(msg_.projectRootPath); + if (projectRootPath == null) { + // Should never happen, but handle gracefully and log a warning + console.warn( + "[ReScript Language Server] Failed to normalize projectRootPath from clientSentBuildAction:", + msg_.projectRootPath, + ); + return; + } // TODO: sometime stale .bsb.lock dangling // TODO: close watcher when lang-server shuts down. However, by Node's // default, these subprocesses are automatically killed when this diff --git a/server/src/utils.ts b/server/src/utils.ts index 09134571f..ac47a2c58 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -10,30 +10,85 @@ import fs from "fs"; import fsAsync from "fs/promises"; import * as os from "os"; import semver from "semver"; +import { fileURLToPath, pathToFileURL } from "url"; import * as codeActions from "./codeActions"; import * as c from "./constants"; import * as lookup from "./lookup"; import { reportError } from "./errorReporter"; import config from "./config"; -import { filesDiagnostics, projectsFiles } from "./projectFiles"; +import { filesDiagnostics, projectsFiles, projectFiles } from "./projectFiles"; import { workspaceFolders } from "./server"; import { rewatchLockPartialPath, rescriptLockPartialPath } from "./constants"; import { findRescriptRuntimesInProject } from "./find-runtime"; +/** + * Branded type for normalized file paths. + * + * All paths stored as keys in `projectsFiles` are normalized to ensure + * consistent lookups and prevent path format mismatches (e.g., trailing + * slashes, symlinks, relative vs absolute paths). + * + * Use `normalizePath()` to convert a regular path to a `NormalizedPath`. + */ +export type NormalizedPath = string & { __brand: "NormalizedPath" }; + +/** + * Branded type for file URIs (e.g., `file:///path/to/file.res`). + * + * This represents a URI as used in the Language Server Protocol. + * Use `uriToNormalizedPath()` to convert a URI to a normalized file path. + */ +export type FileURI = string & { __brand: "FileURI" }; + +/** + * Normalizes a file path and returns it as a `NormalizedPath`. + * + * This function should be used whenever storing a path as a key in `projectsFiles` + * or when comparing paths that need to match exactly. + * + * @param filePath - The path to normalize (can be null) + * @returns The normalized path, or null if input was null + */ +export function normalizePath(filePath: string | null): NormalizedPath | null { + // `path.normalize` ensures we can assume string is now NormalizedPath + return filePath != null ? (path.normalize(filePath) as NormalizedPath) : null; +} + +/** + * Converts a file URI (e.g., `file:///path/to/file.res`) to a normalized file path. + * + * This is the preferred way to convert LSP DocumentUri values to file paths, + * as it ensures the resulting path is normalized for consistent use throughout + * the codebase. + * + * @param uri - The file URI to convert + * @returns The normalized file path + * @throws If the URI cannot be converted to a file path + */ +export function uriToNormalizedPath(uri: FileURI): NormalizedPath { + const filePath = fileURLToPath(uri); + // fileURLToPath always returns a string (throws on invalid URI), so we can directly normalize + return path.normalize(filePath) as NormalizedPath; +} + let tempFilePrefix = "rescript_format_file_" + process.pid + "_"; let tempFileId = 0; -export let createFileInTempDir = (extension = "") => { +export let createFileInTempDir = (extension = ""): NormalizedPath => { let tempFileName = tempFilePrefix + tempFileId + extension; tempFileId = tempFileId + 1; - return path.join(os.tmpdir(), tempFileName); + // `os.tmpdir` returns an absolute path, so `path.join` ensures we can assume string is now NormalizedPath + return path.join(os.tmpdir(), tempFileName) as NormalizedPath; }; let findProjectRootOfFileInDir = ( - source: p.DocumentUri, -): null | p.DocumentUri => { - let dir = path.dirname(source); + source: NormalizedPath, +): NormalizedPath | null => { + const dir = normalizePath(path.dirname(source)); + if (dir == null) { + return null; + } if ( fs.existsSync(path.join(dir, c.rescriptJsonPartialPath)) || fs.existsSync(path.join(dir, c.bsconfigPartialPath)) @@ -50,14 +105,14 @@ let findProjectRootOfFileInDir = ( }; // TODO: races here? -// TODO: this doesn't handle file:/// scheme export let findProjectRootOfFile = ( - source: p.DocumentUri, + source: NormalizedPath, allowDir?: boolean, -): null | p.DocumentUri => { - // First look in project files - let foundRootFromProjectFiles: string | null = null; +): NormalizedPath | null => { + // First look in project files (keys are already normalized) + let foundRootFromProjectFiles: NormalizedPath | null = null; for (const rootPath of projectsFiles.keys()) { + // Both are normalized, so direct comparison works if (source.startsWith(rootPath) && (!allowDir || source !== rootPath)) { // Prefer the longest path (most nested) if ( @@ -73,26 +128,54 @@ export let findProjectRootOfFile = ( return foundRootFromProjectFiles; } else { const isDir = path.extname(source) === ""; - return findProjectRootOfFileInDir( - isDir && !allowDir ? path.join(source, "dummy.res") : source, - ); + const searchPath = + isDir && !allowDir ? path.join(source, "dummy.res") : source; + const normalizedSearchPath = normalizePath(searchPath); + if (normalizedSearchPath == null) { + return null; + } + const foundPath = findProjectRootOfFileInDir(normalizedSearchPath); + return foundPath; } }; +/** + * Gets the project file for a given project root path. + * + * All keys in `projectsFiles` are normalized (see `openedFile` in server.ts). + * This function accepts a normalized project root path (or null) and performs a direct lookup. + * The path must already be normalized before calling this function. + * + * @param projectRootPath - The normalized project root path to look up (or null) + * @returns The project file if found, null otherwise + */ +export let getProjectFile = ( + projectRootPath: NormalizedPath | null, +): projectFiles | null => { + if (projectRootPath == null) { + return null; + } + return projectsFiles.get(projectRootPath) ?? null; +}; + // If ReScript < 12.0.0-alpha.13, then we want `{project_root}/node_modules/rescript/{c.platformDir}/{binary}`. // Otherwise, we want to dynamically import `{project_root}/node_modules/rescript` and from `binPaths` get the relevant binary. // We won't know which version is in the project root until we read and parse `{project_root}/node_modules/rescript/package.json` let findBinary = async ( - projectRootPath: p.DocumentUri | null, + projectRootPath: NormalizedPath | null, binary: | "bsc.exe" | "rescript-editor-analysis.exe" | "rescript" | "rewatch.exe" | "rescript.exe", -) => { +): Promise => { if (config.extensionConfiguration.platformPath != null) { - return path.join(config.extensionConfiguration.platformPath, binary); + const result = path.join( + config.extensionConfiguration.platformPath, + binary, + ); + return normalizePath(result); } if (projectRootPath !== null) { @@ -106,10 +189,10 @@ let findBinary = async ( if (compileInfo && compileInfo.bsc_path) { const bsc_path = compileInfo.bsc_path; if (binary === "bsc.exe") { - return bsc_path; + return normalizePath(bsc_path); } else { const binary_path = path.join(path.dirname(bsc_path), binary); - return binary_path; + return normalizePath(binary_path); } } } catch {} @@ -168,38 +251,39 @@ let findBinary = async ( } if (binaryPath != null && fs.existsSync(binaryPath)) { - return binaryPath; + return normalizePath(binaryPath); } else { return null; } }; -export let findRescriptBinary = (projectRootPath: p.DocumentUri | null) => +export let findRescriptBinary = (projectRootPath: NormalizedPath | null) => findBinary(projectRootPath, "rescript"); -export let findBscExeBinary = (projectRootPath: p.DocumentUri | null) => +export let findBscExeBinary = (projectRootPath: NormalizedPath | null) => findBinary(projectRootPath, "bsc.exe"); -export let findEditorAnalysisBinary = (projectRootPath: p.DocumentUri | null) => - findBinary(projectRootPath, "rescript-editor-analysis.exe"); +export let findEditorAnalysisBinary = ( + projectRootPath: NormalizedPath | null, +) => findBinary(projectRootPath, "rescript-editor-analysis.exe"); -export let findRewatchBinary = (projectRootPath: p.DocumentUri | null) => +export let findRewatchBinary = (projectRootPath: NormalizedPath | null) => findBinary(projectRootPath, "rewatch.exe"); -export let findRescriptExeBinary = (projectRootPath: p.DocumentUri | null) => +export let findRescriptExeBinary = (projectRootPath: NormalizedPath | null) => findBinary(projectRootPath, "rescript.exe"); -type execResult = +type execResult = | { kind: "success"; - result: string; + result: T; } | { kind: "error"; error: string; }; -type formatCodeResult = execResult; +type formatCodeResult = execResult; export let formatCode = ( bscPath: p.DocumentUri | null, @@ -240,7 +324,7 @@ export let formatCode = ( }; export async function findReScriptVersionForProjectRoot( - projectRootPath: string | null, + projectRootPath: NormalizedPath | null, ): Promise { if (projectRootPath == null) { return undefined; @@ -272,7 +356,7 @@ if (fs.existsSync(c.builtinAnalysisDevPath)) { } export let runAnalysisAfterSanityCheck = async ( - filePath: p.DocumentUri, + filePath: NormalizedPath, args: Array, projectRequired = false, ) => { @@ -281,8 +365,9 @@ export let runAnalysisAfterSanityCheck = async ( return null; } let rescriptVersion = - projectsFiles.get(projectRootPath ?? "")?.rescriptVersion ?? - (await findReScriptVersionForProjectRoot(projectRootPath)); + (projectRootPath + ? projectsFiles.get(projectRootPath)?.rescriptVersion + : null) ?? (await findReScriptVersionForProjectRoot(projectRootPath)); let binaryPath = builtinBinaryPath; @@ -349,7 +434,7 @@ export let runAnalysisAfterSanityCheck = async ( }; export let runAnalysisCommand = async ( - filePath: p.DocumentUri, + filePath: NormalizedPath, args: Array, msg: RequestMessage, projectRequired = true, @@ -368,7 +453,7 @@ export let runAnalysisCommand = async ( }; export let getReferencesForPosition = async ( - filePath: p.DocumentUri, + filePath: NormalizedPath, position: p.Position, ) => await runAnalysisAfterSanityCheck(filePath, [ @@ -391,8 +476,8 @@ export const toCamelCase = (text: string): string => { * in the workspace, so we return null (which will default to projectRootPath). */ export function computeWorkspaceRootPathFromLockfile( - projectRootPath: string | null, -): string | null { + projectRootPath: NormalizedPath | null, +): NormalizedPath | null { if (projectRootPath == null) { return null; } @@ -420,23 +505,24 @@ export function computeWorkspaceRootPathFromLockfile( } // Shared cache: key is either workspace root path or project root path -const runtimePathCache = new Map(); +const runtimePathCache = new Map(); /** * Gets the runtime path from a workspace root path. * This function is cached per workspace root path. */ export async function getRuntimePathFromWorkspaceRoot( - workspaceRootPath: string, -): Promise { + workspaceRootPath: NormalizedPath, +): Promise { // Check cache first if (runtimePathCache.has(workspaceRootPath)) { return runtimePathCache.get(workspaceRootPath)!; } // Compute and cache - let rescriptRuntime: string | null = - config.extensionConfiguration.runtimePath ?? null; + let rescriptRuntime: NormalizedPath | null = normalizePath( + config.extensionConfiguration.runtimePath ?? null, + ); if (rescriptRuntime !== null) { runtimePathCache.set(workspaceRootPath, rescriptRuntime); @@ -457,8 +543,8 @@ export async function getRuntimePathFromWorkspaceRoot( * This function is cached per project root path. */ export async function getRuntimePathFromProjectRoot( - projectRootPath: string | null, -): Promise { + projectRootPath: NormalizedPath | null, +): Promise { if (projectRootPath == null) { return null; } @@ -469,7 +555,7 @@ export async function getRuntimePathFromProjectRoot( } // Compute workspace root and resolve runtime - const workspaceRootPath = + const workspaceRootPath: NormalizedPath = computeWorkspaceRootPathFromLockfile(projectRootPath) ?? projectRootPath; // Check cache again with workspace root (might have been cached from a previous call) @@ -497,8 +583,8 @@ export function getRuntimePathCacheSnapshot(): Record { } export const getNamespaceNameFromConfigFile = ( - projDir: p.DocumentUri, -): execResult => { + projDir: NormalizedPath, +): execResult => { let config = lookup.readConfig(projDir); let result = ""; @@ -523,9 +609,9 @@ export const getNamespaceNameFromConfigFile = ( export let getCompiledFilePath = ( filePath: string, - projDir: string, -): execResult => { - let error: execResult = { + projDir: NormalizedPath, +): execResult => { + let error: execResult = { kind: "error", error: "Could not read ReScript config file", }; @@ -553,9 +639,12 @@ export let getCompiledFilePath = ( result = compiledPath; } + // Normalize the path before returning + const normalizedResult = normalizePath(result)!; + return { kind: "success", - result, + result: normalizedResult, }; }; @@ -627,8 +716,9 @@ export let runBuildWatcherUsingValidBuildPath = ( */ // parser helpers -export let pathToURI = (file: string) => { - return process.platform === "win32" ? `file:\\\\\\${file}` : `file://${file}`; +export let pathToURI = (file: NormalizedPath): FileURI => { + // `pathToFileURL` ensures we can assume string is now FileURI + return pathToFileURL(file).toString() as FileURI; }; let parseFileAndRange = (fileAndRange: string) => { // https://github.com/rescript-lang/rescript-compiler/blob/0a3f4bb32ca81e89cefd5a912b8795878836f883/jscomp/super_errors/super_location.ml#L15-L25 @@ -651,8 +741,9 @@ let parseFileAndRange = (fileAndRange: string) => { let match = trimmedFileAndRange.match(regex); if (match === null) { // no location! Though LSP insist that we provide at least a dummy location + const normalizedPath = normalizePath(trimmedFileAndRange)!; return { - file: pathToURI(trimmedFileAndRange), + file: pathToURI(normalizedPath), range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 }, @@ -696,8 +787,9 @@ let parseFileAndRange = (fileAndRange: string) => { end: { line: parseInt(endLine) - 1, character: parseInt(endChar) }, }; } + const normalizedFile = normalizePath(file)!; return { - file: pathToURI(file), + file: pathToURI(normalizedFile), range, }; };