Skip to content

Commit 5c7417d

Browse files
committed
chore: Documented how to support Windows
1 parent f8eaf38 commit 5c7417d

File tree

6 files changed

+338
-6
lines changed

6 files changed

+338
-6
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ npx @codemod-utils/cli <your-codemod-name>
3131

3232
- [Main tutorial](./tutorials/main-tutorial/00-introduction.md)
3333
- [Create blueprints](./tutorials/create-blueprints/00-introduction.md)
34+
- [Support Windows](./tutorials/support-windows/00-introduction.md)
3435
- [Update CSS files](./tutorials/update-css-files/00-introduction.md)
3536
- [Update `<template>` tags](./tutorials/update-template-tags/00-introduction.md)
3637

tutorials/create-blueprints/03-define-options.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ See if you can complete the starter code shown above. The requirements are:
282282
<summary>Solution: <code>src/steps/create-options.ts</code></summary>
283283

284284
```diff
285-
+ import { join } from 'node:path';
285+
+ import { join, sep } from 'node:path';
286286
+
287287
import type { CodemodOptions, Options } from '../types/index.js';
288288

@@ -296,12 +296,12 @@ export function createOptions(codemodOptions: CodemodOptions): Options {
296296

297297
return {
298298
+ addon: {
299-
+ location: join('packages', addonLocation),
299+
+ location: join('packages', addonLocation).replaceAll(sep, '/'),
300300
+ name: addonName,
301301
+ },
302302
projectRoot,
303303
+ testApp: {
304-
+ location: join('tests', addonLocation),
304+
+ location: join('tests', addonLocation).replaceAll(sep, '/'),
305305
+ name: `test-app-for-${dasherize(addonName)}`,
306306
+ },
307307
};

tutorials/main-tutorial/05-step-1-update-acceptance-tests-part-2.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -594,11 +594,14 @@ Currently, `data.moduleName` is hard-coded. We can derive the test module name f
594594
595595
<summary>Solution: <code>src/steps/rename-acceptance-tests.ts</code></summary>
596596
597+
It's important to note that Windows uses `\` as the file separator, while the entity name uses `/` as its separator (independently of the operating system). Below, we use `relative` and `sep` to normalize values.
598+
597599
The implementation for `renameModule()` remains unchanged and has been hidden for simplicity.
598600
599601
```diff
600602
import { readFileSync, writeFileSync } from 'node:fs';
601-
import { join } from 'node:path';
603+
- import { join } from 'node:path';
604+
+ import { join, relative, sep } from 'node:path';
602605

603606
import { AST } from '@codemod-utils/ast-javascript';
604607
- import { findFiles } from '@codemod-utils/files';
@@ -614,10 +617,10 @@ type Data = {
614617
+ function getModuleName(filePath: string): string {
615618
+ let { dir, name } = parseFilePath(filePath);
616619
+
617-
+ dir = dir.replace(/^tests\/acceptance(\/)?/, '');
620+
+ dir = relative('tests/acceptance', dir);
618621
+ name = name.replace(/-test$/, '');
619622
+
620-
+ const entityName = join(dir, name);
623+
+ const entityName = join(dir, name).replaceAll(sep, '/');
621624
+
622625
+ // a.k.a. friendlyTestDescription
623626
+ return ['Acceptance', entityName].join(' | ');
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Introduction
2+
3+
Extra attention is needed when you read or write files, or derive values (e.g. entity names in Ember) from file paths. This is because Windows handles file paths differently than POSIX: It uses `\` instead of `/` for a path separator, and `\r\n` instead of `\n` for a line break.
4+
5+
By supporting Windows, you can remove wrong abstractions and write cleaner code. This tutorial will show common pitfalls and what to do differently.
6+
7+
8+
## Table of contents
9+
10+
1. [Beware of file paths](./01-beware-of-file-paths.md)
11+
1. [Beware of line breaks](./02-beware-of-line-breaks.md)
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# Beware of file paths
2+
3+
Windows uses `\` (not `/`) for a path separator. While `node:path` provides `sep` and several methods to hide this pesky detail, you still need to be wary of these cases.
4+
5+
6+
## When to always use `/`
7+
8+
### `codemodOptions`
9+
10+
To improve user experience and simplify documentation, user-defined file and folder paths in `codemodOptions` (e.g. `projectRoot`) should always use `/`.
11+
12+
This means, you should not call `normalize()` on such paths in your source code and tests.
13+
14+
```diff
15+
/* tests/helpers/shared-test-setups/sample-project.ts */
16+
- import { normalize } from 'node:path';
17+
-
18+
import type { CodemodOptions, Options } from '../../../src/types/index.js';
19+
20+
const codemodOptions: CodemodOptions = {
21+
- projectRoot: normalize('tmp/sample-project'),
22+
+ projectRoot: 'tmp/sample-project',
23+
};
24+
25+
const options: Options = {
26+
- projectRoot: normalize('tmp/sample-project'),
27+
+ projectRoot: 'tmp/sample-project',
28+
};
29+
30+
export { codemodOptions, options };
31+
```
32+
33+
34+
### `findFiles()`
35+
36+
Glob pattern(s) use `/` as a path separator.
37+
38+
```ts
39+
import { findFiles } from '@codemod-utils/files';
40+
41+
function updateTests(options: Options): void {
42+
const { projectRoot } = options;
43+
44+
const filePaths = findFiles('tests/**/*-test.{gjs,gts,js,ts}', {
45+
ignoreList: ['**/*.d.ts'],
46+
projectRoot,
47+
});
48+
49+
// ...
50+
}
51+
```
52+
53+
Since `join()` results in `\`'s on Windows, you should watch out for the code pattern `findFiles(join(...))`.
54+
55+
```diff
56+
- import { join } from 'node:path';
57+
+ import { join, sep } from 'node:path';
58+
59+
import { findFiles } from '@codemod-utils/files';
60+
61+
+ function normalizedJoin(...folders: string[]): string {
62+
+ return join(...folders).replaceAll(sep, '/');
63+
+ }
64+
+
65+
function updateHbs(options: Options): void {
66+
const { folder, projectRoot } = options;
67+
68+
- const components = findFiles(join('app/components', folder, '**/*.hbs'), {
69+
+ const components = findFiles(normalizedJoin('app/components', folder, '**/*.hbs'), {
70+
projectRoot,
71+
});
72+
73+
- const routes = findFiles(join('app/templates', folder, '**/*.hbs'), {
74+
+ const routes = findFiles(normalizedJoin('app/templates', folder, '**/*.hbs'), {
75+
projectRoot,
76+
});
77+
78+
// ...
79+
}
80+
```
81+
82+
83+
### Entity names in Ember
84+
85+
Entity names are dasherized and use `/` for folders. If you need to derive the name from the file path, make sure to use `/`.
86+
87+
```diff
88+
- import { join } from 'node:path';
89+
+ import { join, relative, sep } from 'node:path';
90+
91+
import { parseFilePath } from '@codemod-utils/files';
92+
93+
function getModuleName(filePath: string): string {
94+
let { dir, name } = parseFilePath(filePath);
95+
96+
- dir = dir.replace(/^tests\/acceptance(\/)?/, '');
97+
+ dir = relative('tests/acceptance', dir);
98+
name = name.replace(/-test$/, '');
99+
100+
- const entityName = join(dir, name);
101+
+ const entityName = join(dir, name).replaceAll(sep, '/');
102+
103+
// a.k.a. friendlyTestDescription
104+
return ['Acceptance', entityName].join(' | ');
105+
}
106+
```
107+
108+
109+
### Import paths
110+
111+
Import statements in `*.{gjs,gts,js,ts}` files use `/` for the import path. If you need to derive the import path from some file paths, make sure to call `String.replaceAll(sep, '/')`.
112+
113+
114+
115+
## When not to use `/`
116+
117+
### Calculations involving file paths
118+
119+
When the input or output of a calculation is a file path, use `sep` or `normalize()` to get the correct path separator.
120+
121+
```diff
122+
+ import { sep } from 'node:path';
123+
+
124+
function parseEntity(
125+
dir: string,
126+
folderToEntityType: Map<string, string>,
127+
): {
128+
entityType: string | undefined;
129+
remainingPath: string;
130+
} {
131+
- const [folder, ...remainingPaths] = dir.split('/');
132+
+ const [folder, ...remainingPaths] = dir.split(sep);
133+
const entityType = folderToEntityType.get(folder!);
134+
135+
if (entityType === undefined) {
136+
return {
137+
entityType,
138+
remainingPath: dir,
139+
};
140+
}
141+
142+
return {
143+
entityType,
144+
- remainingPath: remainingPaths.join('/'),
145+
+ remainingPath: remainingPaths.join(sep),
146+
};
147+
}
148+
```
149+
150+
```diff
151+
+ import { normalize } from 'node:path';
152+
+
153+
import { parseFilePath } from '@codemod-utils/files';
154+
155+
function getClass(templateFilePath: string) {
156+
const { dir, ext, name } = parseFilePath(templateFilePath);
157+
158+
const data = {
159+
- isRouteTemplate: dir.startsWith('app/templates'),
160+
+ isRouteTemplate: dir.startsWith(normalize('app/templates')),
161+
isTemplateTag: ext === '.gjs' || ext === '.gts',
162+
};
163+
164+
// ...
165+
}
166+
```
167+
168+
The same rule applies to tests.
169+
170+
```diff
171+
+ import { normalize } from 'node:path';
172+
+
173+
import { assert, test } from '@codemod-utils/tests';
174+
175+
import { parseEntity } from '../../../../src/utils/rename-tests/index.js';
176+
177+
test('utils | rename-tests | parse-entity > base case', function () {
178+
const folderToEntityType = new Map([
179+
['components', 'Component'],
180+
['helpers', 'Helper'],
181+
['modifiers', 'Modifier'],
182+
]);
183+
184+
const output = parseEntity(
185+
- 'components/ui/form',
186+
+ normalize('components/ui/form'),
187+
folderToEntityType,
188+
);
189+
190+
assert.deepStrictEqual(output, {
191+
entityType: 'Component',
192+
- remainingPath: 'ui/form',
193+
+ remainingPath: normalize('ui/form'),
194+
});
195+
});
196+
```
197+
198+
199+
<div align="center">
200+
<div>
201+
Next: <a href="./02-beware-of-line-breaks.md">Beware of line breaks</a>
202+
</div>
203+
<div>
204+
Previous: <a href="./00-introduction.md">Introduction</a>
205+
</div>
206+
</div>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Beware of line breaks
2+
3+
Windows uses `\r\n` (also called a CRLF) for a line break and `node:os` provides `EOL`. The problem is, Windows can also handle files with `\n` or a mix. Moreover, output fixtures can end up with `\r\n`'s when tests are run on Windows.
4+
5+
The uncertainty in line breaks makes reading and writing files non-trivial. For simplicity, we'll always prefer `\r\n` on Windows when writing files. End-users who want `\n` can use `git` and `prettier` to remove CRLFs.
6+
7+
8+
## When to always use `EOL`
9+
10+
### `JSON.stringify()`
11+
12+
`JSON.stringify()` uses `\n` for line breaks. Before saving the result to a file, make sure to replace `\n` with `EOL`.
13+
14+
```diff
15+
import { EOL } from 'node:os';
16+
import { join } from 'node:path';
17+
18+
import { readPackageJson } from '@codemod-utils/package-json';
19+
20+
function updatePackageJson(options: Options): void {
21+
const { projectRoot } = options;
22+
23+
const packageJson = readPackageJson({ projectRoot });
24+
25+
// ...
26+
27+
- const file = JSON.stringify(packageJson, null, 2) + '\n';
28+
+ const file = JSON.stringify(packageJson, null, 2).replaceAll('\n', EOL) + EOL;
29+
30+
writeFileSync(join(projectRoot, 'package.json'), file, 'utf8');
31+
}
32+
```
33+
34+
35+
### Test fixtures
36+
37+
Fixtures files should use `EOL` so that the same test can pass on POSIX and Windows. `@codemod-utils/tests` provides `normalizeFile()` to hide the implementation detail.
38+
39+
```diff
40+
- import { assert, test } from '@codemod-utils/tests';
41+
+ import { assert, normalizeFile, test } from '@codemod-utils/tests';
42+
43+
import { renameModule } from '../../../../src/utils/rename-tests/index.js';
44+
45+
test('utils | rename-tests | rename-module', function () {
46+
- const oldFile = [
47+
+ const oldFile = normalizeFile([
48+
`module('Old name', function (hooks) {`,
49+
` module('Old name', function (nestedHooks) {});`,
50+
`});`,
51+
``,
52+
- ].join('\n');
53+
+ ]);
54+
55+
const newFile = renameModule(oldFile, {
56+
isTypeScript: true,
57+
moduleName: 'New name',
58+
});
59+
60+
assert.strictEqual(
61+
newFile,
62+
- [
63+
+ normalizeFile([
64+
`module('New name', function (hooks) {`,
65+
` module('Old name', function (nestedHooks) {});`,
66+
`});`,
67+
``,
68+
- ].join('\n'),
69+
+ ]),
70+
);
71+
});
72+
```
73+
74+
In some cases, making a test pass on Windows involves high cost and little reward. For example, `content-tag` (the underlying dependency of `@codemod-utils/ast-template-tag`) returns different character and line indices on Windows. It'd be a pain to assert their values with a conditional branch, and to update these values manually if you need to change the input file.
75+
76+
If you want to run a test only on POSIX, you can write a test helper.
77+
78+
```ts
79+
/* tests/helpers/test-on-posix.ts */
80+
import { EOL } from 'node:os';
81+
82+
import { test } from '@codemod-utils/tests';
83+
84+
const onPosix = EOL === '\n';
85+
86+
export function testOnPosix(...parameters: Parameters<typeof test>): void {
87+
if (onPosix) {
88+
test(...parameters);
89+
}
90+
}
91+
```
92+
93+
```diff
94+
- import { assert, test } from '@codemod-utils/tests';
95+
+ import { assert, normalizeFile } from '@codemod-utils/tests';
96+
97+
import { getClassToStyles } from '../../../../src/utils/css/index.js';
98+
+ import { testOnPosix } from '../../../helpers/index.js';
99+
100+
- test('utils | css | get-class-to-styles', function () {
101+
+ testOnPosix('utils | css | get-class-to-styles', function () {
102+
/* ... */
103+
});
104+
```
105+
106+
107+
<div align="center">
108+
<div>
109+
Previous: <a href="./01-beware-of-file-paths.md">Beware of file paths</a>
110+
</div>
111+
</div>

0 commit comments

Comments
 (0)