diff --git a/.gitattributes b/.gitattributes index 18efe580..cfb8ddfd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,4 @@ integration-tests/*/src/generated/** linguist-generated=true e2e/src/generated/** linguist-generated=true integration-tests-definitions/** linguist-vendored=true yarn.lock linguist-generated=true +* text=auto eol=lf diff --git a/packages/openapi-code-generator/src/core/file-system/fs-adaptor.ts b/packages/openapi-code-generator/src/core/file-system/fs-adaptor.ts index ab7116e6..9115a663 100644 --- a/packages/openapi-code-generator/src/core/file-system/fs-adaptor.ts +++ b/packages/openapi-code-generator/src/core/file-system/fs-adaptor.ts @@ -10,4 +10,8 @@ export interface IFsAdaptor { mkDir(path: string, recursive: boolean): Promise resolve(request: string, fromDir: string): string + + pathJoin(...paths: string[]): string + + dirname(path: string): string } diff --git a/packages/openapi-code-generator/src/core/file-system/node-fs-adaptor.ts b/packages/openapi-code-generator/src/core/file-system/node-fs-adaptor.ts index fdd8f270..de15d3d2 100644 --- a/packages/openapi-code-generator/src/core/file-system/node-fs-adaptor.ts +++ b/packages/openapi-code-generator/src/core/file-system/node-fs-adaptor.ts @@ -1,6 +1,7 @@ import {existsSync} from "node:fs" import fs from "node:fs/promises" import {createRequire} from "node:module" +import pathModule from "node:path" import type {IFsAdaptor} from "./fs-adaptor.ts" export class NodeFsAdaptor implements IFsAdaptor { @@ -40,4 +41,12 @@ export class NodeFsAdaptor implements IFsAdaptor { const require = createRequire(import.meta.url) return require.resolve(request, {paths: [fromDir]}) } + + pathJoin(...paths: string[]): string { + return pathModule.join(...paths) + } + + dirname(path: string): string { + return pathModule.dirname(path) + } } diff --git a/packages/openapi-code-generator/src/core/file-system/test-fs-adaptor.ts b/packages/openapi-code-generator/src/core/file-system/test-fs-adaptor.ts new file mode 100644 index 00000000..4d986e57 --- /dev/null +++ b/packages/openapi-code-generator/src/core/file-system/test-fs-adaptor.ts @@ -0,0 +1,13 @@ +import path from "node:path" +import {WebFsAdaptor} from "./web-fs-adaptor.ts" + +export function testFsAdaptor( + files: Record, + sep: typeof path.sep = "/", +) { + return new WebFsAdaptor( + new Map(Object.entries(files)), + sep === "/" ? path.posix.join : path.win32.join, + sep === "/" ? path.posix.dirname : path.win32.dirname, + ) +} diff --git a/packages/openapi-code-generator/src/core/file-system/web-fs-adaptor.ts b/packages/openapi-code-generator/src/core/file-system/web-fs-adaptor.ts index 98eac410..9de8c0de 100644 --- a/packages/openapi-code-generator/src/core/file-system/web-fs-adaptor.ts +++ b/packages/openapi-code-generator/src/core/file-system/web-fs-adaptor.ts @@ -2,7 +2,11 @@ import pathModule from "node:path" import type {IFsAdaptor} from "./fs-adaptor.ts" export class WebFsAdaptor implements IFsAdaptor { - constructor(readonly files = new Map()) {} + constructor( + readonly files = new Map(), + readonly pathJoin = (...paths: string[]) => pathModule.join(...paths), + readonly dirname = (path: string) => pathModule.dirname(path), + ) {} clearFiles(filter: (it: string) => boolean) { for (const file of this.files.keys()) { diff --git a/packages/openapi-code-generator/src/core/loaders/package.json.loader.spec.ts b/packages/openapi-code-generator/src/core/loaders/package.json.loader.spec.ts index 597be8b4..f78ec1a8 100644 --- a/packages/openapi-code-generator/src/core/loaders/package.json.loader.spec.ts +++ b/packages/openapi-code-generator/src/core/loaders/package.json.loader.spec.ts @@ -4,32 +4,36 @@ import {NodeFsAdaptor} from "../file-system/node-fs-adaptor.ts" import {loadPackageJson} from "./package.json.loader.ts" describe("core/loaders/package.json.loader", () => { - const fsAdaptor = new NodeFsAdaptor() + describe("with the real filesystem", () => { + const fsAdaptor = new NodeFsAdaptor() - it("should load the nearest package.json (cwd)", async () => { - const actual = await loadPackageJson(__dirname, fsAdaptor) + it("should load the nearest package.json (cwd)", async () => { + const actual = await loadPackageJson(__dirname, fsAdaptor) - expect(actual).toEqual({ - name: "@nahkies/openapi-code-generator", - type: "module", + expect(actual).toEqual({ + name: "@nahkies/openapi-code-generator", + type: "module", + }) }) - }) - it("should load the nearest package.json (monorepo-root)", async () => { - const actual = await loadPackageJson( - path.join(__dirname, "../../../../"), - fsAdaptor, - ) - expect(actual).toEqual({ - name: "openapi-code-generator-root", - type: "commonjs", + it("should load the nearest package.json (monorepo-root)", async () => { + const actual = await loadPackageJson( + path.join(__dirname, "../../../../"), + fsAdaptor, + ) + expect(actual).toEqual({ + name: "openapi-code-generator-root", + type: "commonjs", + }) }) - }) - it("should default to commonjs if no package.json is found", async () => { - const actual = await loadPackageJson("/tmp/foo/bla", fsAdaptor) - expect(actual).toEqual({ - type: "commonjs", + it("should default to commonjs if no package.json is found", async () => { + const actual = await loadPackageJson("/tmp/foo/bla", fsAdaptor) + expect(actual).toEqual({ + type: "commonjs", + }) }) }) + + // todo: use testFsAdaptor to cover both linux and windows. }) diff --git a/packages/openapi-code-generator/src/core/loaders/tsconfig.loader.spec.ts b/packages/openapi-code-generator/src/core/loaders/tsconfig.loader.spec.ts index fbc1fdb1..aab235dc 100644 --- a/packages/openapi-code-generator/src/core/loaders/tsconfig.loader.spec.ts +++ b/packages/openapi-code-generator/src/core/loaders/tsconfig.loader.spec.ts @@ -1,14 +1,10 @@ import {describe, expect, it, vi} from "vitest" -import {WebFsAdaptor} from "../file-system/web-fs-adaptor.ts" +import {testFsAdaptor} from "../file-system/test-fs-adaptor.ts" import {loadTsConfigCompilerOptions} from "./tsconfig.loader.ts" -function fsAdaptor(files: Record) { - return new WebFsAdaptor(new Map(Object.entries(files))) -} - describe("core/loaders/tsconfig.loader", () => { it("returns defaults when no tsconfig.json is found", async () => { - const fs = fsAdaptor({}) + const fs = testFsAdaptor({}) const actual = await loadTsConfigCompilerOptions( "/virtual/workspace/src", @@ -22,7 +18,7 @@ describe("core/loaders/tsconfig.loader", () => { }) it("parses tsconfig.json without compilerOptions and applies defaults", async () => { - const fs = fsAdaptor({ + const fs = testFsAdaptor({ "/virtual/workspace/tsconfig.json": "{}", }) @@ -35,7 +31,7 @@ describe("core/loaders/tsconfig.loader", () => { }) it("reads and returns specified compilerOptions overriding defaults", async () => { - const fs = fsAdaptor({ + const fs = testFsAdaptor({ "/virtual/ws/tsconfig.json": JSON.stringify({ compilerOptions: { exactOptionalPropertyTypes: true, @@ -55,7 +51,7 @@ describe("core/loaders/tsconfig.loader", () => { }) it("supports both options when set", async () => { - const fs = fsAdaptor({ + const fs = testFsAdaptor({ "/v/tsconfig.json": JSON.stringify({ compilerOptions: { exactOptionalPropertyTypes: true, @@ -76,7 +72,7 @@ describe("core/loaders/tsconfig.loader", () => { const basePath = "/virt/base/tsconfig.base.json" const childPath = "/virt/proj/tsconfig.json" - const fs = fsAdaptor({ + const fs = testFsAdaptor({ [basePath]: JSON.stringify({ compilerOptions: { exactOptionalPropertyTypes: true, @@ -100,7 +96,7 @@ describe("core/loaders/tsconfig.loader", () => { }) it("falls back to defaults if an exception occurs", async () => { - const fs = fsAdaptor({ + const fs = testFsAdaptor({ "/virtual/ws/tsconfig.json": JSON.stringify({ compilerOptions: { exactOptionalPropertyTypes: true, diff --git a/packages/openapi-code-generator/src/core/loaders/typescript-formatter-config.loader.spec.ts b/packages/openapi-code-generator/src/core/loaders/typescript-formatter-config.loader.spec.ts new file mode 100644 index 00000000..40fad589 --- /dev/null +++ b/packages/openapi-code-generator/src/core/loaders/typescript-formatter-config.loader.spec.ts @@ -0,0 +1,119 @@ +import {describe, expect, it} from "vitest" +import {testFsAdaptor} from "../file-system/test-fs-adaptor.ts" +import { + findConfigFile, + loadTypescriptFormatterConfig, +} from "./typescript-formatter-config.loader.ts" + +describe("core/loaders/typescript-formatter-config.loader", () => { + describe("#loadTypescriptFormatterConfig", () => { + it("returns null when no config file is found and reaches root", async () => { + const fs = testFsAdaptor({}) + const result = await loadTypescriptFormatterConfig( + "/virtual/workspace/src", + fs, + ) + expect(result).toBeNull() + }) + + it("finds and loads biome.json", async () => { + const fs = testFsAdaptor({ + "/virtual/workspace/biome.json": JSON.stringify({ + formatter: {enabled: true}, + }), + }) + + const result = await loadTypescriptFormatterConfig( + "/virtual/workspace/src", + fs, + ) + + expect(result).toEqual({ + type: "biome", + config: {formatter: {enabled: true}}, + }) + }) + + it("finds and loads biome.jsonc", async () => { + const fs = testFsAdaptor({ + "/virtual/workspace/biome.jsonc": + '{"formatter": {"enabled": true}} // comment', + }) + + const result = await loadTypescriptFormatterConfig( + "/virtual/workspace/src", + fs, + ) + + expect(result).toEqual({ + type: "biome", + config: {formatter: {enabled: true}}, + }) + }) + }) + + describe.each([ + {root: "/virtual/workspace/src", sep: "/" as const}, + { + root: "C:\\Users\\Alice\\My Documents", + sep: "\\" as const, + }, + ])("#findConfigFile with root $root and path sep $sep", ({root, sep}) => { + it("returns null when no config file is found and reaches root", async () => { + const fs = testFsAdaptor({}) + + const result = findConfigFile(["biome.json", "biome.jsonc"], root, fs) + expect(result).toBeNull() + }) + + it("(in root) returns the first file found from the provided list", () => { + const biomeJsoncPath = root.split(sep).concat("biome.jsonc").join(sep) + const biomeJsonPath = root.split(sep).concat("biome.json").join(sep) + + const fs = testFsAdaptor( + { + [biomeJsoncPath]: JSON.stringify({ + formatter: {enabled: true}, + }), + [biomeJsonPath]: JSON.stringify({ + formatter: {enabled: false}, + }), + }, + sep, + ) + + const result = findConfigFile(["biome.jsonc", "biome.json"], root, fs) + + expect(result).toBe(biomeJsoncPath) + }) + + it("(above root) returns the first file found from the provided list", () => { + const biomeJsoncPath = root + .split(sep) + .slice(0, -1) + .concat("biome.jsonc") + .join(sep) + const biomeJsonPath = root + .split(sep) + .slice(0, -1) + .concat("biome.json") + .join(sep) + + const fs = testFsAdaptor( + { + [biomeJsoncPath]: JSON.stringify({ + formatter: {enabled: true}, + }), + [biomeJsonPath]: JSON.stringify({ + formatter: {enabled: false}, + }), + }, + sep, + ) + + const result = findConfigFile(["biome.jsonc", "biome.json"], root, fs) + + expect(result).toBe(biomeJsoncPath) + }) + }) +}) diff --git a/packages/openapi-code-generator/src/core/loaders/typescript-formatter-config.loader.ts b/packages/openapi-code-generator/src/core/loaders/typescript-formatter-config.loader.ts index 6c611922..db98f7e3 100644 --- a/packages/openapi-code-generator/src/core/loaders/typescript-formatter-config.loader.ts +++ b/packages/openapi-code-generator/src/core/loaders/typescript-formatter-config.loader.ts @@ -1,4 +1,3 @@ -import path from "node:path" import json5 from "json5" import type {IFsAdaptor} from "../file-system/fs-adaptor.ts" import {logger} from "../logger.ts" @@ -17,7 +16,7 @@ export async function loadTypescriptFormatterConfig( const biomeConfigFile = findConfigFile( ["biome.json", "biome.jsonc"], searchPath, - (it) => fsAdaptor.existsSync(it), + fsAdaptor, ) if (biomeConfigFile) { @@ -41,7 +40,7 @@ export async function loadTypescriptFormatterConfig( ".prettierrc.yml", ], searchPath, - (it) => fsAdaptor.existsSync(it), + fsAdaptor, ) if (prettierConfigFile) { @@ -60,22 +59,22 @@ export async function loadTypescriptFormatterConfig( return null } -function findConfigFile( +export function findConfigFile( names: string[], searchPath: string, - fileExists: (it: string) => boolean, + fsAdaptor: IFsAdaptor, ) { - if (searchPath === "/") { + if (searchPath === fsAdaptor.dirname(searchPath)) { return null } for (const name of names) { - const fullPath = path.join(searchPath, name) - if (fileExists(fullPath)) { + const fullPath = fsAdaptor.pathJoin(searchPath, name) + if (fsAdaptor.existsSync(fullPath)) { logger.info(`found ${fullPath} in ${searchPath}`) return fullPath } } - return findConfigFile(names, path.dirname(searchPath), fileExists) + return findConfigFile(names, fsAdaptor.dirname(searchPath), fsAdaptor) } diff --git a/packages/openapi-code-generator/src/typescript/common/import-builder.spec.ts b/packages/openapi-code-generator/src/typescript/common/import-builder.spec.ts index 41531297..9aa53757 100644 --- a/packages/openapi-code-generator/src/typescript/common/import-builder.spec.ts +++ b/packages/openapi-code-generator/src/typescript/common/import-builder.spec.ts @@ -40,7 +40,7 @@ describe("typescript/common/import-builder", () => { it("same directory", () => { const builder = new ImportBuilder({ unit: { - filename: "./foo/example", + filename: "./foo/example.ts", }, includeFileExtensions: false, }) @@ -53,7 +53,7 @@ describe("typescript/common/import-builder", () => { it("parent directory", () => { const builder = new ImportBuilder({ unit: { - filename: "./foo/example", + filename: "./foo/example.ts", }, includeFileExtensions: false, }) @@ -66,7 +66,7 @@ describe("typescript/common/import-builder", () => { it("child directory", () => { const builder = new ImportBuilder({ unit: { - filename: "./example", + filename: "./example.ts", }, includeFileExtensions: false, }) @@ -79,7 +79,7 @@ describe("typescript/common/import-builder", () => { it("sibling directory", () => { const builder = new ImportBuilder({ unit: { - filename: "./foo/example", + filename: "./foo/example.ts", }, includeFileExtensions: false, }) @@ -88,6 +88,22 @@ describe("typescript/common/import-builder", () => { expect(builder.toString()).toBe("import {Cat} from '../bar/models'") }) + + it("handles absolute paths", () => { + const builder = new ImportBuilder({ + unit: { + filename: "/home/user/project/src/index.ts", + }, + includeFileExtensions: false, + }) + + builder.addSingle("Cat", "/home/user/project/src/models.ts", false) + builder.addSingle("Dog", "/home/user/project/other/models.ts", false) + + expect(builder.toString()).toBe( + "import {Dog} from '../other/models'\nimport {Cat} from './models'", + ) + }) }) describe("type imports", () => { @@ -189,7 +205,7 @@ describe("typescript/common/import-builder", () => { it("orders sources by Biome distance (URL > protocol pkg > pkg > alias > paths)", () => { const builder = new ImportBuilder({ unit: { - filename: "./foo/example", + filename: "./foo/example.ts", }, includeFileExtensions: false, }) diff --git a/packages/openapi-code-generator/src/typescript/common/import-builder.ts b/packages/openapi-code-generator/src/typescript/common/import-builder.ts index 32a212d8..1566131c 100644 --- a/packages/openapi-code-generator/src/typescript/common/import-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/import-builder.ts @@ -49,6 +49,10 @@ enum ImportCategory { PATH = 4, } +function normalizeToUnix(it: string) { + return it.replace(/\\/g, "/").replace(/^([a-zA-Z]+:\/)/, "/") +} + export function categorizeImportSource(source: string): ImportCategory { const isUrl = source.startsWith("http://") || source.startsWith("https://") @@ -56,6 +60,10 @@ export function categorizeImportSource(source: string): ImportCategory { return ImportCategory.URL } + if (path.posix.isAbsolute(source) || path.win32.isAbsolute(source)) { + return ImportCategory.PATH + } + if (/^[a-z]+:/.test(source)) { return ImportCategory.PROTOCOL } @@ -67,10 +75,7 @@ export function categorizeImportSource(source: string): ImportCategory { source.startsWith("$") || source.startsWith("%") - const isPath = - source.startsWith("./") || - source.startsWith("../") || - source.startsWith("/") + const isPath = source.startsWith("./") || source.startsWith("../") if (!isAlias && !isPath) { return ImportCategory.PACKAGE @@ -94,7 +99,11 @@ export class ImportBuilder { > = {} private readonly importAll: Record = {} - constructor(private readonly config: ImportBuilderConfig) {} + constructor(private readonly config: ImportBuilderConfig) { + if (this.config.unit?.filename) { + this.config.unit.filename = normalizeToUnix(this.config.unit.filename) + } + } from(from: string) { const chain = { @@ -229,19 +238,38 @@ export class ImportBuilder { // biome-ignore lint/style/noParameterAssign: normalization from = from.substring(0, from.length - ".ts".length) } + if (categorizeImportSource(from) === ImportCategory.PATH) { + // biome-ignore lint/style/noParameterAssign: final normalization + from = normalizeToUnix(from) + } - if (this.config.unit && from.startsWith("./")) { - const unitDirname = path.dirname(this.config.unit.filename) - const fromDirname = path.dirname(from) + if (this.config.unit) { + const unitFilename = this.config.unit.filename + const isFromPath = + from.startsWith("./") || + from.startsWith("../") || + path.posix.isAbsolute(from) - const relative = path.relative(unitDirname, fromDirname) + if (isFromPath) { + const root = path.posix.isAbsolute(unitFilename) + ? path.posix.parse(unitFilename).root + : "/" - // biome-ignore lint/style/noParameterAssign: normalization - from = path.join(relative, path.basename(from)) + const unitDir = path.posix.dirname( + path.posix.resolve(root, unitFilename), + ) + const fromDir = path.posix.dirname(path.posix.resolve(root, from)) + + const relativeDir = path.posix.relative(unitDir, fromDir) + const basename = path.posix.basename(from) - if (!from.startsWith("../") && !from.startsWith("./")) { // biome-ignore lint/style/noParameterAssign: normalization - from = `./${from}` + from = path.posix.join(relativeDir, basename) + + if (!from.startsWith("../") && !from.startsWith("./")) { + // biome-ignore lint/style/noParameterAssign: normalization + from = `./${from}` + } } } diff --git a/packages/openapi-code-generator/src/typescript/common/import-builder.windows.spec.ts b/packages/openapi-code-generator/src/typescript/common/import-builder.windows.spec.ts new file mode 100644 index 00000000..96b8691b --- /dev/null +++ b/packages/openapi-code-generator/src/typescript/common/import-builder.windows.spec.ts @@ -0,0 +1,100 @@ +import {describe, expect, it, vi} from "vitest" +import {ImportBuilder} from "./import-builder.ts" + +vi.mock(import("node:path"), async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + default: original.win32, + ...original.win32, + } +}) + +describe("typescript/common/import-builder - windows behavior", () => { + it("should use forward slashes for imports even on Windows", () => { + const builder = new ImportBuilder({ + unit: { + filename: "src\\index.ts", + }, + includeFileExtensions: false, + }) + + builder.addSingle("Cat", "./models.ts", false) + + expect(builder.toString()).toBe("import {Cat} from '../models'") + }) + + describe("relative path handling", () => { + it("same directory", () => { + const builder = new ImportBuilder({ + unit: { + filename: "foo\\example.ts", + }, + includeFileExtensions: false, + }) + + builder.addSingle("Cat", "./foo/models.ts", false) + + expect(builder.toString()).toBe("import {Cat} from './models'") + }) + + it("parent directory", () => { + const builder = new ImportBuilder({ + unit: { + filename: "foo\\example.ts", + }, + includeFileExtensions: false, + }) + + builder.addSingle("Cat", "./models.ts", false) + + expect(builder.toString()).toBe("import {Cat} from '../models'") + }) + + it("child directory", () => { + const builder = new ImportBuilder({ + unit: { + filename: "example.ts", + }, + includeFileExtensions: false, + }) + + builder.addSingle("Cat", "./foo/models.ts", false) + + expect(builder.toString()).toBe("import {Cat} from './foo/models'") + }) + + it("sibling directory", () => { + const builder = new ImportBuilder({ + unit: { + filename: "foo\\example.ts", + }, + includeFileExtensions: false, + }) + + builder.addSingle("Cat", "./bar/models.ts", false) + + expect(builder.toString()).toBe("import {Cat} from '../bar/models'") + }) + + it("handles absolute paths", () => { + const builder = new ImportBuilder({ + unit: { + filename: "D:\\Users/user\\project\\src\\index.ts", + }, + includeFileExtensions: false, + }) + + builder.addSingle("Cat", "D:\\Users/user\\project\\src\\models.ts", false) + builder.addSingle( + "Dog", + "D:\\Users/user\\project\\other\\models.ts", + false, + ) + + expect(builder.toString()).toBe( + "import {Dog} from '../other/models'\nimport {Cat} from './models'", + ) + }) + }) +}) diff --git a/scripts/assert-clean-working-directory.sh b/scripts/assert-clean-working-directory.sh index 4faa11b3..de577e05 100755 --- a/scripts/assert-clean-working-directory.sh +++ b/scripts/assert-clean-working-directory.sh @@ -5,5 +5,6 @@ set -e if [ -n "$(git status --porcelain=v1)" ]; then echo "Uncommitted changes to repo found!" git status --porcelain=v1 + git diff exit 1 fi