Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
68 changes: 68 additions & 0 deletions packages/core/src/writers/file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { withGeneratedFileTransform, writeGeneratedFile } from './file';

describe('writeGeneratedFile', () => {
let dir: string;

beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'orval-write-'));
});

afterEach(async () => {
await fs.remove(dir);
});

it('creates the file and strips trailing whitespace', async () => {
const filePath = path.join(dir, 'nested', 'out.ts');
await writeGeneratedFile(filePath, 'const a = 1; \nconst b = 2;\n');

expect(await fs.readFile(filePath, 'utf8')).toBe(
'const a = 1;\nconst b = 2;\n',
);
});

it('leaves mtime untouched when the final content is unchanged', async () => {
const filePath = path.join(dir, 'out.ts');
await writeGeneratedFile(filePath, 'const a = 1;\n');
const past = new Date('2020-01-01T00:00:00.000Z');
await fs.utimes(filePath, past, past);

// Same final content, reached from a different source string: the trailing
// whitespace is stripped before the comparison.
await writeGeneratedFile(filePath, 'const a = 1; \n');

expect((await fs.stat(filePath)).mtimeMs).toBe(past.getTime());
expect(await fs.readFile(filePath, 'utf8')).toBe('const a = 1;\n');
});

it('compares transformed content before writing', async () => {
const filePath = path.join(dir, 'out.ts');
const format = async (_filePath: string, content: string) =>
content.replace(';\n', '; \n').replaceAll("'", '"');

await withGeneratedFileTransform(format, () =>
writeGeneratedFile(filePath, "const value = 'test';\n"),
);
const past = new Date('2020-01-01T00:00:00.000Z');
await fs.utimes(filePath, past, past);

await withGeneratedFileTransform(format, () =>
writeGeneratedFile(filePath, "const value = 'test';\n"),
);

expect(await fs.readFile(filePath, 'utf8')).toBe('const value = "test";\n');
expect((await fs.stat(filePath)).mtimeMs).toBe(past.getTime());
});

it('still writes when the content differs', async () => {
const filePath = path.join(dir, 'out.ts');
await writeGeneratedFile(filePath, 'const a = 1;\n');
await writeGeneratedFile(filePath, 'const a = 2;\n');

expect(await fs.readFile(filePath, 'utf8')).toBe('const a = 2;\n');
});
});
51 changes: 50 additions & 1 deletion packages/core/src/writers/file.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
import { AsyncLocalStorage } from 'node:async_hooks';

import fs from 'fs-extra';

const TRAILING_WHITESPACE_RE = /[^\S\r\n]+$/gm;

export type GeneratedFileTransform = (
filePath: string,
content: string,
) => Promise<string>;

const generatedFileTransform = new AsyncLocalStorage<GeneratedFileTransform>();

export function withGeneratedFileTransform<T>(
transform: GeneratedFileTransform,
callback: () => Promise<T>,
): Promise<T> {
return generatedFileTransform.run(transform, callback);
}

function isMissingFileError(error: unknown): error is NodeJS.ErrnoException {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ENOENT'
);
}

/**
* Write generated code to a file, stripping trailing whitespace from each line.
*
Expand All @@ -13,5 +38,29 @@ export async function writeGeneratedFile(
filePath: string,
content: string,
): Promise<void> {
await fs.outputFile(filePath, content.replaceAll(TRAILING_WHITESPACE_RE, ''));
let nextContent = content.replaceAll(TRAILING_WHITESPACE_RE, '');
const transform = generatedFileTransform.getStore();
if (transform) {
nextContent = (await transform(filePath, nextContent)).replaceAll(
TRAILING_WHITESPACE_RE,
'',
);
}

// Skip the write when the file already holds this exact output, so a no-op
// regeneration does not churn mtime and wake every downstream watcher. Same
// reasoning as the barrel writers (#3756), applied to generated artifacts
// as well. (#3787)
try {
const existingContent = await fs.readFile(filePath, 'utf8');
if (existingContent === nextContent) {
return;
}
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
}
}

await fs.outputFile(filePath, nextContent);
}
19 changes: 14 additions & 5 deletions packages/orval/src/formatters/prettier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ const mocks = vi.hoisted(() => ({
readdir: vi.fn(),
resolveConfig: vi.fn(),
stat: vi.fn(),
writeFile: vi.fn(),
writeGeneratedFile: vi.fn(),
}));

vi.mock('@orval/core', () => ({
logWarning: mocks.logWarning,
writeGeneratedFile: mocks.writeGeneratedFile,
}));

vi.mock('node:fs/promises', () => ({
default: {
readFile: mocks.readFile,
writeFile: mocks.writeFile,
stat: mocks.stat,
readdir: mocks.readdir,
},
Expand Down Expand Up @@ -50,7 +50,7 @@ describe('formatWithPrettier', () => {
mocks.resolveConfig.mockResolvedValue({ semi: true });
mocks.readFile.mockResolvedValue('const value=1');
mocks.format.mockResolvedValue('const value = 1;\n');
mocks.writeFile.mockImplementation(async () => {
mocks.writeGeneratedFile.mockImplementation(async () => {
await Promise.resolve();
});
});
Expand All @@ -60,7 +60,7 @@ describe('formatWithPrettier', () => {
code: 'ENOENT',
});

mocks.writeFile.mockRejectedValueOnce(missingFileError);
mocks.writeGeneratedFile.mockRejectedValueOnce(missingFileError);

await expect(
formatWithPrettier([FILE_PATH], 'petstore'),
Expand Down Expand Up @@ -93,9 +93,18 @@ describe('formatWithPrettier', () => {
expect(mocks.format).toHaveBeenCalledWith('const value=1', {
filepath: FILE_PATH,
});
expect(mocks.writeFile).toHaveBeenCalledWith(
expect(mocks.writeGeneratedFile).toHaveBeenCalledWith(
FILE_PATH,
'const value = 1;\n',
);
});

it('resolves prettier config for each file', async () => {
const schemaPath = path.resolve('/tmp/pets.schema.ts');

await formatWithPrettier([FILE_PATH, schemaPath], 'petstore');

expect(mocks.resolveConfig).toHaveBeenCalledWith(FILE_PATH);
expect(mocks.resolveConfig).toHaveBeenCalledWith(schemaPath);
});
});
50 changes: 40 additions & 10 deletions packages/orval/src/formatters/prettier.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,45 @@
import fs from 'node:fs/promises';
import path from 'node:path';

import { logWarning } from '@orval/core';
import {
type GeneratedFileTransform,
logWarning,
writeGeneratedFile,
} from '@orval/core';
import { execa } from 'execa';

export async function createPrettierFileTransform(
projectTitle?: string,
): Promise<GeneratedFileTransform | undefined> {
const prettier = await tryImportPrettier();
if (!prettier) {
return;
}

return async (filePath, content) => {
try {
const config = await prettier.resolveConfig(filePath);

return await prettier.format(content, {
...config,
// filepath lets Prettier infer the parser from the file extension.
filepath: filePath,
});
} catch (error) {
if (error instanceof Error && error.name === 'UndefinedParserError') {
return content;
}

const detail =
error instanceof Error ? error.toString() : 'unknown error';
logWarning(
`⚠️ ${projectTitle ? `${projectTitle} - ` : ''}Failed to format file ${filePath}: ${detail}`,
);
return content;
}
};
}

/**
* Format files with prettier.
* Tries the programmatic API first (project dependency),
Expand All @@ -13,25 +49,19 @@ export async function formatWithPrettier(
paths: string[],
projectTitle?: string,
): Promise<void> {
const prettier = await tryImportPrettier();
const format = await createPrettierFileTransform(projectTitle);

if (prettier) {
if (format) {
const filePaths = [...new Set(await collectFilePaths(paths))];
if (filePaths.length === 0) {
return;
}

const config = (await prettier.resolveConfig(filePaths[0])) ?? {};
await Promise.all(
filePaths.map(async (filePath) => {
try {
const content = await fs.readFile(filePath, 'utf8');
const formatted = await prettier.format(content, {
...config,
// options.filepath can be specified for Prettier to infer the parser from the file extension
filepath: filePath,
});
await fs.writeFile(filePath, formatted);
await writeGeneratedFile(filePath, await format(filePath, content));
} catch (error) {
if (isMissingFileError(error)) {
return;
Expand Down
44 changes: 44 additions & 0 deletions packages/orval/src/generate-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,50 @@ const createTempWorkspace = async () => {
return mkdtemp(path.join(os.tmpdir(), 'orval-gen-spec-'));
};

describe('generateSpec - unchanged formatted output', () => {
it('keeps mtimes when prettier produces the same final files', async () => {
const workspace = await createTempWorkspace();
const targetFile = path.join(workspace, 'endpoints.ts');
const schemasDir = path.join(workspace, 'model');

try {
const options = await normalizeOptions(
{
input: { target: PETSTORE_SPEC },
output: {
target: './endpoints.ts',
schemas: './model',
client: 'zod',
formatter: 'prettier',
},
},
workspace,
);

await generateSpec(workspace, options);
const generatedFiles = [
targetFile,
...(await fs.readdir(schemasDir)).map((file) =>
path.join(schemasDir, file),
),
];
const past = new Date('2020-01-01T00:00:00.000Z');
await Promise.all(
generatedFiles.map((file) => fs.utimes(file, past, past)),
);

await generateSpec(workspace, options);

const mtimes = await Promise.all(
generatedFiles.map(async (file) => (await fs.stat(file)).mtimeMs),
);
expect(mtimes).toEqual(generatedFiles.map(() => past.getTime()));
} finally {
await rm(workspace, { recursive: true, force: true });
}
});
});

describe('generateSpec - HTTP QUERY method', () => {
it('generates clients for QUERY operations with request bodies', async () => {
const workspace = await createTempWorkspace();
Expand Down
29 changes: 20 additions & 9 deletions packages/orval/src/utils/barrel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import path from 'node:path';

import { writeGeneratedFile } from '@orval/core';
import fs from 'fs-extra';

const RE_EXPORT_LINE = /^\s*export\s+\*\s+from\s*['"]([^'"]+)['"]\s*;?\s*$/;
Expand Down Expand Up @@ -44,15 +46,19 @@ export async function reconcileWorkspaceBarrel(
fileExtension: string,
importExtension: string,
): Promise<void> {
if (!(await fs.pathExists(filePath))) {
await fs.outputFile(
let existingContent: string;
try {
existingContent = await fs.readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
await writeGeneratedFile(
filePath,
specifiers.map((s) => `export * from '${s}';`).join('\n') + '\n',
);
return;
}

const existingContent = await fs.readFile(filePath, 'utf8');
const declared = [...readReExportSpecifiers(existingContent)];
const resolvable = await Promise.all(
declared.map((s) =>
Expand Down Expand Up @@ -84,7 +90,7 @@ export async function reconcileWorkspaceBarrel(
[retainedContent, appended].filter(Boolean).join(eol) + eol;

if (nextContent !== existingContent) {
await fs.outputFile(filePath, nextContent);
await writeGeneratedFile(filePath, nextContent);
}
}

Expand All @@ -96,9 +102,14 @@ export async function reconcileZodBarrel(
header: string,
mergeExisting: boolean,
): Promise<void> {
const existingContent = (await fs.pathExists(filePath))
? await fs.readFile(filePath, 'utf8')
: '';
let existingContent = '';
try {
existingContent = await fs.readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
const existing = mergeExisting
? readReExportSpecifiers(existingContent)
: new Set<string>();
Expand All @@ -108,6 +119,6 @@ export async function reconcileZodBarrel(
.join('\n');
const nextContent = `${header}\n${body}\n`;
if (nextContent !== existingContent) {
await fs.outputFile(filePath, nextContent);
await writeGeneratedFile(filePath, nextContent);
}
}
Loading
Loading