Skip to content

Commit 6a230a0

Browse files
authored
Merge pull request #1853 from counterfact/copilot/add-branded-string-utility-function
Add `ForwardSlashPath` branded type and forward-slash path utilities
2 parents 72c11d2 + a5e17e3 commit 6a230a0

17 files changed

Lines changed: 291 additions & 128 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"counterfact": patch
3+
---
4+
5+
Add `toForwardSlashPath` utility function and `ForwardSlashPath` branded type. All path normalization that previously used inline `.replaceAll("\\", "/")` now goes through this single, centralized function, making Windows path handling easier to find and reason about.

bin/counterfact.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,14 @@ const { loadConfigFile } = await import(
162162
)
163163
);
164164

165+
const { pathResolve } = await import(
166+
resolve(
167+
nativeTs
168+
? "../src/util/forward-slash-path.js"
169+
: "../dist/util/forward-slash-path.js",
170+
)
171+
);
172+
165173
const DEFAULT_PORT = 3100;
166174

167175
const debug = createDebug("counterfact:bin:counterfact");
@@ -314,9 +322,9 @@ async function main(source, destination) {
314322
source = options.spec;
315323
}
316324

317-
const destinationPath = nodePath.resolve(destination).replaceAll("\\", "/");
325+
const destinationPath = pathResolve(destination);
318326

319-
const basePath = nodePath.resolve(destinationPath).replaceAll("\\", "/");
327+
const basePath = pathResolve(destinationPath);
320328

321329
// If no action-related option is provided, default to all options
322330

src/app.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import fs, { rm } from "node:fs/promises";
2-
import nodePath from "node:path";
32

43
import { createHttpTerminator, type HttpTerminator } from "http-terminator";
54

@@ -19,6 +18,7 @@ import { Transpiler } from "./server/transpiler.js";
1918
import { CodeGenerator } from "./typescript-generator/code-generator.js";
2019
import { writeScenarioContextType } from "./typescript-generator/generate.js";
2120
import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable-ts.js";
21+
import { pathJoin } from "./util/forward-slash-path.js";
2222

2323
export { loadOpenApiDocument } from "./server/load-openapi-document.js";
2424

@@ -113,9 +113,7 @@ export async function createMswHandlers(
113113
await fs.readFile(config.openApiPath);
114114
const openApiDocument = await loadOpenApiDocument(config.openApiPath);
115115
const modulesPath = config.basePath;
116-
const compiledPathsDirectory = nodePath
117-
.join(modulesPath, ".cache")
118-
.replaceAll("\\", "/");
116+
const compiledPathsDirectory = pathJoin(modulesPath, ".cache");
119117

120118
const registry = new Registry();
121119
const contextRegistry = new ContextRegistry();
@@ -175,9 +173,10 @@ export async function counterfact(config: Config) {
175173

176174
const nativeTs = await runtimeCanExecuteErasableTs();
177175

178-
const compiledPathsDirectory = nodePath
179-
.join(modulesPath, nativeTs ? "routes" : ".cache")
180-
.replaceAll("\\", "/");
176+
const compiledPathsDirectory = pathJoin(
177+
modulesPath,
178+
nativeTs ? "routes" : ".cache",
179+
);
181180

182181
if (!nativeTs) {
183182
await rm(compiledPathsDirectory, { force: true, recursive: true });
@@ -208,7 +207,7 @@ export async function counterfact(config: Config) {
208207
);
209208

210209
const transpiler = new Transpiler(
211-
nodePath.join(modulesPath, "routes").replaceAll("\\", "/"),
210+
pathJoin(modulesPath, "routes"),
212211
compiledPathsDirectory,
213212
"commonjs",
214213
);
@@ -217,7 +216,7 @@ export async function counterfact(config: Config) {
217216
compiledPathsDirectory,
218217
registry,
219218
contextRegistry,
220-
nodePath.join(modulesPath, "scenarios").replaceAll("\\", "/"),
219+
pathJoin(modulesPath, "scenarios"),
221220
scenarioRegistry,
222221
);
223222

src/migrate/update-route-types.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from "node:path";
33

44
import createDebug from "debug";
55

6+
import { toForwardSlashPath } from "../util/forward-slash-path.js";
67
import {
78
OperationTypeCoder,
89
type SecurityScheme,
@@ -271,9 +272,7 @@ async function processRouteDirectory(
271272
);
272273
} else if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") {
273274
// Process TypeScript route files (skip context files)
274-
const routePath = relativePath
275-
.replace(/\.ts$/, "")
276-
.replaceAll("\\", "/");
275+
const routePath = toForwardSlashPath(relativePath.replace(/\.ts$/, ""));
277276
const methodMap = mapping.get(routePath);
278277

279278
if (methodMap) {

src/server/file-discovery.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { existsSync } from "node:fs";
22
import fs from "node:fs/promises";
3-
import nodePath from "node:path";
43

4+
import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
55
import { escapePathForWindows } from "../util/windows-escape.js";
66

77
const JS_EXTENSIONS = new Set(["cjs", "cts", "js", "mjs", "mts", "ts"]);
@@ -17,7 +17,7 @@ export class FileDiscovery {
1717
private readonly basePath: string;
1818

1919
public constructor(basePath: string) {
20-
this.basePath = basePath.replaceAll("\\", "/");
20+
this.basePath = toForwardSlashPath(basePath);
2121
}
2222

2323
/**
@@ -29,9 +29,7 @@ export class FileDiscovery {
2929
* @throws When `basePath/directory` does not exist.
3030
*/
3131
public async findFiles(directory = ""): Promise<string[]> {
32-
const fullDir = nodePath
33-
.join(this.basePath, directory)
34-
.replaceAll("\\", "/");
32+
const fullDir = pathJoin(this.basePath, directory);
3533

3634
if (!existsSync(fullDir)) {
3735
throw new Error(`Directory does not exist ${fullDir}`);
@@ -42,9 +40,7 @@ export class FileDiscovery {
4240
const results = await Promise.all(
4341
entries.map(async (entry) => {
4442
if (entry.isDirectory()) {
45-
return this.findFiles(
46-
nodePath.join(directory, entry.name).replaceAll("\\", "/"),
47-
);
43+
return this.findFiles(pathJoin(directory, entry.name));
4844
}
4945

5046
const extension = entry.name.split(".").at(-1);
@@ -53,9 +49,7 @@ export class FileDiscovery {
5349
return [];
5450
}
5551

56-
const fullPath = nodePath
57-
.join(this.basePath, directory, entry.name)
58-
.replaceAll("\\", "/");
52+
const fullPath = pathJoin(this.basePath, directory, entry.name);
5953

6054
return [escapePathForWindows(fullPath)];
6155
}),

src/server/module-loader.ts

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { once } from "node:events";
22
import fs from "node:fs/promises";
3-
import nodePath, { basename, dirname } from "node:path";
3+
import nodePath, { basename } from "node:path";
44

55
import { type FSWatcher, watch } from "chokidar";
66
import createDebug from "debug";
@@ -18,6 +18,11 @@ import { ModuleDependencyGraph } from "./module-dependency-graph.js";
1818
import type { Module, Registry } from "./registry.js";
1919
import { ScenarioRegistry } from "./scenario-registry.js";
2020
import { uncachedImport } from "./uncached-import.js";
21+
import {
22+
toForwardSlashPath,
23+
pathDirname,
24+
pathRelative,
25+
} from "../util/forward-slash-path.js";
2126
import { unescapePathForWindows } from "../util/windows-escape.js";
2227

2328
const { uncachedRequire } = await import("./uncached-require.cjs");
@@ -69,10 +74,13 @@ export class ModuleLoader extends EventTarget {
6974
scenarioRegistry?: ScenarioRegistry,
7075
) {
7176
super();
72-
this.basePath = basePath.replaceAll("\\", "/");
77+
this.basePath = toForwardSlashPath(basePath);
7378
this.registry = registry;
7479
this.contextRegistry = contextRegistry;
75-
this.scenariosPath = scenariosPath?.replaceAll("\\", "/");
80+
this.scenariosPath =
81+
scenariosPath === undefined
82+
? undefined
83+
: toForwardSlashPath(scenariosPath);
7684
this.scenarioRegistry = scenarioRegistry;
7785
this.fileDiscovery = new FileDiscovery(this.basePath);
7886
}
@@ -98,7 +106,7 @@ export class ModuleLoader extends EventTarget {
98106
)
99107
return;
100108

101-
const pathName = pathNameOriginal.replaceAll("\\", "/");
109+
const pathName = toForwardSlashPath(pathNameOriginal);
102110

103111
if (pathName.includes("$.context") && eventName === "add") {
104112
process.stdout.write(
@@ -114,17 +122,18 @@ export class ModuleLoader extends EventTarget {
114122

115123
const parts = nodePath.parse(pathName.replace(this.basePath, ""));
116124
const url = unescapePathForWindows(
117-
`/${parts.dir}/${parts.name}`
118-
.replaceAll("\\", "/")
119-
.replaceAll(/\/+/gu, "/"),
125+
toForwardSlashPath(`/${parts.dir}/${parts.name}`).replaceAll(
126+
/\/+/gu,
127+
"/",
128+
),
120129
);
121130

122131
if (eventName === "unlink") {
123132
this.registry.remove(url);
124133
this.dispatchEvent(new Event("remove"));
125134
if (this.isContextFile(pathName)) {
126135
this.contextRegistry.remove(
127-
unescapePathForWindows(parts.dir).replaceAll("\\", "/") || "/",
136+
unescapePathForWindows(toForwardSlashPath(parts.dir)) || "/",
128137
);
129138
}
130139
return;
@@ -155,7 +164,7 @@ export class ModuleLoader extends EventTarget {
155164

156165
if (!["add", "change", "unlink"].includes(eventName)) return;
157166

158-
const pathName = pathNameOriginal.replaceAll("\\", "/");
167+
const pathName = toForwardSlashPath(pathNameOriginal);
159168

160169
if (eventName === "unlink") {
161170
const fileKey = this.scenarioFileKey(pathName);
@@ -215,18 +224,18 @@ export class ModuleLoader extends EventTarget {
215224
}
216225

217226
private scenarioFileKey(pathName: string): string {
218-
const normalizedScenariosPath = (this.scenariosPath ?? "").replaceAll(
219-
"\\",
220-
"/",
227+
const normalizedScenariosPath = toForwardSlashPath(
228+
this.scenariosPath ?? "",
221229
);
222-
const directory = dirname(
230+
const directory = pathDirname(
223231
pathName.slice(normalizedScenariosPath.length),
224-
).replaceAll("\\", "/");
232+
);
225233
const name = nodePath.parse(basename(pathName)).name;
226234
const url = unescapePathForWindows(
227-
`/${nodePath.join(directory, name)}`
228-
.replaceAll("\\", "/")
229-
.replaceAll(/\/+/gu, "/"),
235+
toForwardSlashPath(`/${nodePath.join(directory, name)}`).replaceAll(
236+
/\/+/gu,
237+
"/",
238+
),
230239
);
231240

232241
return url.slice(1); // strip leading "/"
@@ -258,15 +267,12 @@ export class ModuleLoader extends EventTarget {
258267
private async loadEndpoint(pathName: string) {
259268
debug("importing module: %s", pathName);
260269

261-
const directory = dirname(pathName.slice(this.basePath.length)).replaceAll(
262-
"\\",
263-
"/",
264-
);
270+
const directory = pathDirname(pathName.slice(this.basePath.length));
265271

266272
const url = unescapePathForWindows(
267-
`/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}`
268-
.replaceAll("\\", "/")
269-
.replaceAll(/\/+/gu, "/"),
273+
toForwardSlashPath(
274+
`/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}`,
275+
).replaceAll(/\/+/gu, "/"),
270276
);
271277

272278
debug(`loading pathName from dependencyGraph: ${pathName}`);
@@ -290,9 +296,10 @@ export class ModuleLoader extends EventTarget {
290296
importError instanceof SyntaxError ||
291297
String(importError).startsWith("SyntaxError:");
292298

293-
const displayPath = nodePath
294-
.relative(process.cwd(), unescapePathForWindows(pathName))
295-
.replaceAll("\\", "/");
299+
const displayPath = pathRelative(
300+
process.cwd(),
301+
unescapePathForWindows(pathName),
302+
);
296303

297304
const message = isSyntaxError
298305
? `There is a syntax error in the route file: ${displayPath}`

src/server/transpiler.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
import { once } from "node:events";
44
import fs from "node:fs/promises";
5-
import nodePath from "node:path";
65

76
import { type FSWatcher, watch as chokidarWatch } from "chokidar";
87
import createDebug from "debug";
98
import ts from "typescript";
109

1110
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
11+
import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
1212
import { CHOKIDAR_OPTIONS } from "./constants.js";
1313
import { convertFileExtensionsToCjs } from "./convert-js-extensions-to-cjs.js";
1414

@@ -79,12 +79,13 @@ export class Transpiler extends EventTarget {
7979
)
8080
return;
8181

82-
const sourcePath = sourcePathOriginal.replaceAll("\\", "/");
82+
const sourcePath = toForwardSlashPath(sourcePathOriginal);
8383

84-
const destinationPath = sourcePath
85-
.replace(this.sourcePath, this.destinationPath)
86-
.replaceAll("\\", "/")
87-
.replace(".ts", this.extension);
84+
const destinationPath = toForwardSlashPath(
85+
sourcePath
86+
.replace(this.sourcePath, this.destinationPath)
87+
.replace(".ts", this.extension),
88+
);
8889

8990
if (["add", "change"].includes(eventName)) {
9091
transpiles.push(
@@ -152,13 +153,11 @@ export class Transpiler extends EventTarget {
152153

153154
const result: string = transpileOutput.outputText;
154155

155-
const fullDestination = nodePath
156-
.join(
157-
sourcePath
158-
.replace(this.sourcePath, this.destinationPath)
159-
.replace(".ts", this.extension),
160-
)
161-
.replaceAll("\\", "/");
156+
const fullDestination = pathJoin(
157+
sourcePath
158+
.replace(this.sourcePath, this.destinationPath)
159+
.replace(".ts", this.extension),
160+
);
162161

163162
const resultWithTransformedFileExtensions =
164163
convertFileExtensionsToCjs(result);

src/typescript-generator/generate.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import nodePath from "node:path";
55
import createDebug from "debug";
66

77
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
8+
import { pathRelative } from "../util/forward-slash-path.js";
89
import { OperationCoder } from "./operation-coder.js";
910
import { type SecurityScheme } from "./operation-type-coder.js";
1011
import { pruneRoutes } from "./prune.js";
@@ -210,9 +211,7 @@ async function walkForContextFiles(
210211
results,
211212
);
212213
} else if (entry.name === "_.context.ts") {
213-
const relDir = nodePath
214-
.relative(routesDir, currentDir)
215-
.replaceAll("\\", "/");
214+
const relDir = pathRelative(routesDir, currentDir);
216215
const routePath = relDir === "" ? "/" : `/${relDir}`;
217216
const depth = relDir === "" ? 0 : relDir.split("/").length;
218217
const importPath =

src/typescript-generator/operation-coder.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import nodePath from "node:path";
2-
1+
import { pathJoin } from "../util/forward-slash-path.js";
32
import { Coder } from "./coder.js";
43
import {
54
OperationTypeCoder,
@@ -88,8 +87,6 @@ export class OperationCoder extends Coder {
8887
.at(-2)!
8988
.replaceAll("~1", "/");
9089

91-
return `${nodePath
92-
.join("routes", pathString)
93-
.replaceAll("\\", "/")}.types.ts`;
90+
return `${pathJoin("routes", pathString)}.types.ts`;
9491
}
9592
}

0 commit comments

Comments
 (0)