Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions client/src/commands/code_analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
);

Expand Down
4 changes: 3 additions & 1 deletion client/src/commands/dump_debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createFileInTempDir,
findProjectRootOfFileInDir,
getBinaryPath,
NormalizedPath,
} from "../utils";
import * as path from "path";

Expand Down Expand Up @@ -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,
Expand Down
37 changes: 32 additions & 5 deletions client/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ 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 {
return filePath != null ? (path.normalize(filePath) as NormalizedPath) : null;
}

type binaryName = "rescript-editor-analysis.exe" | "rescript-tools.exe";

const platformDir =
Expand All @@ -29,7 +49,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 ?? "",
Expand Down Expand Up @@ -60,16 +80,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 {
Expand Down
9 changes: 5 additions & 4 deletions server/src/bsc-args/rewatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ export type RewatchCompilerArgs = {

async function getRuntimePath(
entry: IncrementallyCompiledFileInfo,
): Promise<string | null> {
): Promise<utils.NormalizedPath | null> {
return utils.getRuntimePathFromWorkspaceRoot(entry.project.workspaceRootPath);
}

export async function getRewatchBscArgs(
send: (msg: p.Message) => void,
bscBinaryLocation: string | null,
projectsFiles: Map<string, projectFiles>,
bscBinaryLocation: utils.NormalizedPath | null,
projectsFiles: Map<utils.NormalizedPath, projectFiles>,
entry: IncrementallyCompiledFileInfo,
): Promise<RewatchCompilerArgs | null> {
const rewatchCacheEntry = entry.buildRewatch;
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 5 additions & 6 deletions server/src/codeActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -327,7 +326,7 @@ let handleUndefinedRecordFieldsAction = ({
}: {
recordFieldNames: string[];
codeActions: filesCodeActions;
file: string;
file: utils.FileURI;
range: p.Range;
diagnostic: p.Diagnostic;
todoValue: string;
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 10 additions & 5 deletions server/src/find-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readdir, stat as statAsync, readFile } from "fs/promises";
import { join, resolve } from "path";
import { compilerInfoPartialPath } from "./constants";
import { NormalizedPath } from "./utils";

// Efficient parallel folder traversal to find node_modules directories
async function findNodeModulesDirs(
Expand Down Expand Up @@ -92,14 +93,16 @@ async function findRescriptRuntimeInAlternativeLayout(
return results;
}

async function findRuntimePath(project: string): Promise<string[]> {
async function findRuntimePath(
project: NormalizedPath,
): Promise<NormalizedPath[]> {
// 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];
return [compileInfo.runtime_path as NormalizedPath];
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct type assertion to NormalizedPath assumes runtime_path from JSON is already normalized. Use normalizePath(compileInfo.runtime_path) to ensure it's actually normalized, and handle the null case appropriately.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nojaf worth adding a comment here on why the as is fine?

}
} catch {
// Ignore errors, fallback to node_modules search
Expand Down Expand Up @@ -146,7 +149,9 @@ async function findRuntimePath(project: string): Promise<string[]> {
}),
).then((results) => results.flatMap((x) => x));

return rescriptRuntimeDirs.map((runtime) => resolve(runtime));
return rescriptRuntimeDirs.map(
(runtime) => resolve(runtime) as NormalizedPath,
);
Comment on lines 154 to 157
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct type assertion to NormalizedPath assumes path.resolve() output is normalized. Use normalizePath(resolve(runtime)) to ensure consistent normalization across the codebase.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, worth commenting on as?

}

/**
Expand All @@ -156,7 +161,7 @@ async function findRuntimePath(project: string): Promise<string[]> {
* (see getRuntimePathFromWorkspaceRoot in utils.ts).
*/
export async function findRescriptRuntimesInProject(
project: string,
): Promise<string[]> {
project: NormalizedPath,
): Promise<NormalizedPath[]> {
return await findRuntimePath(project);
}
Loading