diff --git a/docs/gentype-tsconfig-mapping.md b/docs/gentype-tsconfig-mapping.md new file mode 100644 index 0000000..9c920aa --- /dev/null +++ b/docs/gentype-tsconfig-mapping.md @@ -0,0 +1,198 @@ +# genType tsconfig Mapping Rules + +This note defines the rules for issue #44: when adding ReScript to an +existing TypeScript project, infer the ReScript and genType configuration from +the project's effective `tsconfig.json` instead of asking the user to choose a +module system manually. + +## Research Summary + +- ReScript v12 genType is configured through top-level `gentypeconfig` in + `rescript.json`. +- genType supports `gentypeconfig.module` values `esmodule` and `commonjs`. +- genType supports `gentypeconfig.moduleResolution` values `node`, `node16`, + and `bundler`. +- ReScript's TypeScript integration currently requires `"in-source": true` and + generated JS suffixes ending in `.js`, for example `.res.js`. +- ReScript's TypeScript integration requires TypeScript `allowJs: true`. +- ReScript's `bundler` genType mode requires TypeScript 5.0+ and + `allowImportingTsExtensions: true`. +- TypeScript `allowImportingTsExtensions` is only valid when `noEmit` or + `emitDeclarationOnly` is enabled. +- TypeScript `node16`, `node18`, `node20`, and `nodenext` module modes can emit + CommonJS or ESM per file. For `.ts` and `.tsx` files, package.json `"type"` + decides the format: `"module"` means ESM, otherwise CommonJS. +- TypeScript `moduleResolution: "bundler"` models bundlers and does not require + file extensions on relative imports. + +## Effective Input + +Read the effective TypeScript configuration, not only the raw root +`tsconfig.json`. + +1. Find `tsconfig.json` in the current project root. +2. Resolve `extends` using TypeScript semantics before mapping values. +3. Preserve the root project's `package.json` context. +4. Read at least these fields: + - `compilerOptions.module` + - `compilerOptions.moduleResolution` + - `compilerOptions.allowJs` + - `compilerOptions.allowImportingTsExtensions` + - `compilerOptions.noEmit` + - `compilerOptions.emitDeclarationOnly` + - `compilerOptions.jsx` + - package.json `type` + +Use a JSONC-aware parser or TypeScript's own config parser when implementing +this. `tsconfig.json` can contain comments, trailing commas, and inherited +settings. + +If there is no `tsconfig.json`, or the effective config cannot be read, keep the +current manual module prompt. + +## ReScript Output Baseline + +When genType is enabled for an existing TypeScript project, set: + +```json +{ + "package-specs": { + "module": "", + "in-source": true + }, + "suffix": ".res.js", + "gentypeconfig": { + "module": "", + "moduleResolution": "", + "generatedFileExtension": "" + } +} +``` + +Use `.res.js` for both ESM and CommonJS genType setups. The current CLI behavior +of using `.res.mjs` for ESM is fine for plain ReScript output, but it conflicts +with the documented genType limitation that TypeScript integration currently +supports suffixes ending in `.js`. + +The mapped `gentypeconfig.module` should match `package-specs.module`. + +## Module Format Mapping + +Normalize `compilerOptions.module` and `package.json.type` to lowercase before +mapping. + +| TypeScript input | package.json `type` | ReScript `package-specs.module` | genType `module` | Notes | +| --- | --- | --- | --- | --- | +| `commonjs` | any | `commonjs` | `commonjs` | Straight CommonJS mapping. | +| `es2015`, `es6`, `es2020`, `es2022`, `esnext` | any | `esmodule` | `esmodule` | Runtime-agnostic ESM output. | +| `preserve` | any | `esmodule` | `esmodule` | Best fit for bundlers. Warn if the project relies on statement-level CommonJS preservation. | +| `node16`, `node18`, `node20`, `nodenext` | `module` | `esmodule` | `esmodule` | `.ts` and `.tsx` files emit as ESM in this package scope. | +| `node16`, `node18`, `node20`, `nodenext` | absent or `commonjs` | `commonjs` | `commonjs` | `.ts` and `.tsx` files emit as CommonJS in this package scope. | +| `amd`, `umd`, `system`, `none` | any | none | none | Unsupported by ReScript's two genType module formats. Fall back to manual prompt or abort genType setup. | +| missing or unknown | any | none | none | Do not guess. Fall back to the current manual prompt. | + +For Node dual-format projects, warn when the project contains `.mts` or `.cts` +files, or package subdirectories with their own package.json `type`. ReScript +has one project-level module output setting, so it cannot exactly mirror a mixed +per-file TypeScript module graph. + +## Module Resolution Mapping + +Normalize `compilerOptions.moduleResolution` to lowercase. If it is missing, +infer the effective TypeScript default only when the `module` setting makes the +default unambiguous. + +| Effective TypeScript module resolution | genType `moduleResolution` | Notes | +| --- | --- | --- | +| `bundler` | `bundler` | Also requires `allowImportingTsExtensions: true`. | +| `node16` | `node16` | Exact documented genType mapping. | +| `nodenext` | `node16` | ReScript v12 documents NodeNext as a use case but only exposes `node16`; warn that this is an approximation. | +| `node`, `node10` | `node` | Legacy Node/CommonJS resolver. | +| `classic` | none | Unsupported for genType setup. Fall back to manual prompt or abort genType setup. | +| missing with `module: "preserve"` | `bundler` | TypeScript uses bundler-style resolution for preserve-mode bundled projects. | +| missing with `module: "node16"`, `node18`, `node20`, or `nodenext` | `node16` | Use the Node ESM-compatible genType mode. Warn for `nodenext` as above. | +| missing with `commonjs` | `node` | Legacy CommonJS default. | +| missing with ESM-family module values | none | Do not invent `node`; use TypeScript's effective value if the parser provides one, otherwise prompt. | +| unknown | none | Do not guess. Fall back to manual prompt. | + +## TypeScript Config Edits And Warnings + +### `allowJs` + +If `compilerOptions.allowJs` is not `true`, warn and offer to set it to `true`. +ReScript's TypeScript integration requires this so TypeScript can accept the JS +files emitted by ReScript and imported by generated genType files. + +### `allowImportingTsExtensions` + +If the mapped genType module resolution is `bundler`: + +1. If `allowImportingTsExtensions` is already `true`, no action is needed. +2. If `noEmit: true` or `emitDeclarationOnly: true`, offer to set + `allowImportingTsExtensions: true`. +3. Otherwise, warn that TypeScript does not allow + `allowImportingTsExtensions` unless `noEmit` or `emitDeclarationOnly` is + enabled. Continue only after confirmation, or fall back to manual setup. + +### Generated File Extension + +Use: + +| Signal | `gentypeconfig.generatedFileExtension` | +| --- | --- | +| `compilerOptions.jsx` is present | `.gen.tsx` | +| React, Next.js, or another JSX framework is detected in package.json dependencies | `.gen.tsx` | +| No JSX signal | `.gen.ts` | + +Set `.gen.ts` explicitly when no JSX signal is available. That differs from the +documented genType default of `.gen.tsx`, but it is less surprising for existing +non-React TypeScript projects. + +## Fallback Rules + +Fall back to the current manual module prompt when: + +- There is no readable `tsconfig.json`. +- The effective TypeScript config cannot be resolved. +- `module` is missing or unsupported. +- `moduleResolution` is `classic`, unknown, or conflicts with the module mode. +- The project is a mixed Node dual-format project that cannot be represented by + one ReScript project-level module setting, such as a CommonJS package with + `.mts` inputs, an ESM package with `.cts` inputs, or included source files + under nested package.json files with a different `type`. +- The project uses `module: "preserve"` and package contents suggest meaningful + CommonJS-style exports that ReScript cannot preserve statement-by-statement. + +When falling back, still surface the detected values in the prompt so the user +can make an informed choice. + +## Implementation Notes + +- Prefer loading the target project's `typescript` package and using its config + parser when available. That handles JSONC and `extends` correctly. +- If TypeScript is not available yet, use a JSONC parser and implement only the + documented `extends` behavior needed for `compilerOptions`, or ask the user to + install dependencies first. +- Do not copy the existing Next.js template's legacy `gentypeconfig` shape + (`language`, `shims`) into the add-to-existing flow. The v12 manual documents + the current compiler-integrated fields used above. +- Keep generated JS ignore behavior tied to the selected suffix. For genType, + the suffix is always `.res.js`, so the gitignore prompt should refer to + generated `.res.js` files. + +## Sources + +- ReScript build configuration, especially `package-specs`, `suffix`, and + `gentypeconfig`: https://rescript-lang.org/docs/manual/build-configuration/ +- ReScript TypeScript integration setup, `allowJs`, module resolution, and + genType limitations: https://rescript-lang.org/docs/manual/typescript-integration/ +- TypeScript `module` option and Node dual-format behavior: + https://www.typescriptlang.org/tsconfig/module.html +- TypeScript module resolution reference: + https://www.typescriptlang.org/tsconfig/moduleResolution.html +- TypeScript module reference for Node and bundler resolution: + https://www.typescriptlang.org/docs/handbook/modules/reference.html +- TypeScript `allowImportingTsExtensions` constraints: + https://www.typescriptlang.org/tsconfig/allowImportingTsExtensions.html +- TypeScript `extends` behavior: + https://www.typescriptlang.org/tsconfig/extends.html diff --git a/src/ExistingJsProject.res b/src/ExistingJsProject.res index 5c4bf0e..9fddbb8 100644 --- a/src/ExistingJsProject.res +++ b/src/ExistingJsProject.res @@ -2,6 +2,21 @@ open Node module P = ClackPrompts +type projectModuleConfig = { + moduleSystem: string, + suffix: string, + gentypeConfig: option, +} + +let getOrCreateJsonObject = (config: Dict.t, ~fieldName) => + switch config->Dict.get(fieldName) { + | Some(Object(object)) => object + | _ => + let object = Dict.make() + config->Dict.set(fieldName, Object(object)) + object + } + let updatePackageJson = async (~versions) => await JsonUtils.updateJsonFile("package.json", json => switch json { @@ -25,7 +40,14 @@ let updatePackageJson = async (~versions) => } ) -let updateRescriptJson = async (~projectName, ~sourceDir, ~moduleSystem, ~suffix, ~versions) => +let updateRescriptJson = async ( + ~projectName, + ~sourceDir, + ~moduleSystem, + ~suffix, + ~gentypeConfig: option, + ~versions, +) => await JsonUtils.updateJsonFile("rescript.json", json => switch json { | Object(config) => @@ -39,6 +61,21 @@ let updateRescriptJson = async (~projectName, ~sourceDir, ~moduleSystem, ~suffix | Some(Object(sources)) => sources->Dict.set("module", String(moduleSystem)) | _ => () } + switch gentypeConfig { + | Some(gentypeConfig) => + let gentypeConfigJson: Dict.t = Dict.make() + gentypeConfigJson->Dict.set("module", String(gentypeConfig.moduleSystem)) + gentypeConfigJson->Dict.set( + "moduleResolution", + String(gentypeConfig.gentypeModuleResolution), + ) + gentypeConfigJson->Dict.set( + "generatedFileExtension", + String(gentypeConfig.generatedFileExtension), + ) + config->Dict.set("gentypeconfig", Object(gentypeConfigJson)) + | None => () + } if Option.isNone(versions.RescriptVersions.rescriptCoreVersion) { RescriptJsonUtils.removeRescriptCore(config) @@ -60,8 +97,183 @@ let getModuleSystemOptions = () => [ }, ] +let getPackageJson = async () => await JsonUtils.readJsonFile("package.json") + +let getPackageType = (packageJson: JSON.t) => + switch packageJson { + | Object(config) => + switch config->Dict.get("type") { + | Some(String(packageType)) => Some(packageType->String.toLowerCase) + | _ => None + } + | _ => None + } + +let packageJsonHasDependency = (packageJson: JSON.t, dependencyNames) => { + let dependencyFields = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ] + + switch packageJson { + | Object(config) => + dependencyFields->Array.some(fieldName => + switch config->Dict.get(fieldName) { + | Some(Object(dependencies)) => + dependencyNames->Array.some(dependencyName => + dependencies->Dict.get(dependencyName)->Option.isSome + ) + | _ => false + } + ) + | _ => false + } +} + +let hasJsxDependency = (packageJson: JSON.t) => + packageJson->packageJsonHasDependency([ + "react", + "react-dom", + "next", + "preact", + "solid-js", + "@vitejs/plugin-react", + ]) + +let getManualModuleConfig = async () => { + let moduleSystem = await P.select({ + message: "What module system will you use?", + options: getModuleSystemOptions(), + })->P.resultOrRaise + + { + moduleSystem, + suffix: moduleSystem === "esmodule" ? ".res.mjs" : ".res.js", + gentypeConfig: None, + } +} + +let getTsConfigModuleConfig = async packageJson => { + let projectPath = Process.cwd() + let tsConfig = TsConfigMapping.read(projectPath) + + switch tsConfig.status { + | "found" => + switch TsConfigMapping.infer( + tsConfig, + ~packageType=getPackageType(packageJson), + ~hasJsxDependency=hasJsxDependency(packageJson), + ) { + | Ok(gentypeConfig) => + P.Log.info( + `Detected tsconfig.json. ReScript will use ${gentypeConfig.moduleSystem} output, ${gentypeConfig.gentypeModuleResolution} genType module resolution, and ${gentypeConfig.suffix} generated JS files.`, + ) + + gentypeConfig.warnings->Array.forEach(P.Log.warn) + + Some({ + moduleSystem: gentypeConfig.moduleSystem, + suffix: gentypeConfig.suffix, + gentypeConfig: Some(gentypeConfig), + }) + | Error(message) => + P.Log.warn(`${message} Falling back to manual ReScript module setup.`) + None + } + | "not_found" => None + | "typescript_missing" => + P.Log.warn( + "Found tsconfig.json, but could not resolve the project's TypeScript package. Falling back to manual ReScript module setup.", + ) + None + | _ => + let message = tsConfig.message->Option.getOr("Could not read the effective tsconfig.json.") + P.Log.warn(`${message} Falling back to manual ReScript module setup.`) + None + } +} + +let getProjectModuleConfig = async packageJson => + switch await getTsConfigModuleConfig(packageJson) { + | Some(config) => config + | None => await getManualModuleConfig() + } + +let updateTsConfig = async (~setAllowJs, ~setAllowImportingTsExtensions) => + if setAllowJs || setAllowImportingTsExtensions { + await JsonUtils.updateJsonFile("tsconfig.json", json => + switch json { + | Object(config) => + let compilerOptions = config->getOrCreateJsonObject(~fieldName="compilerOptions") + + if setAllowJs { + compilerOptions->Dict.set("allowJs", Boolean(true)) + } + + if setAllowImportingTsExtensions { + compilerOptions->Dict.set("allowImportingTsExtensions", Boolean(true)) + } + | _ => () + } + ) + } + +let promptTsConfigUpdates = async (gentypeConfig: option) => { + switch gentypeConfig { + | None => () + | Some(gentypeConfig) => + let setAllowJs = if gentypeConfig.needsAllowJs { + P.Log.warn( + "TypeScript allowJs is not enabled. genType imports ReScript's generated JS files, so TypeScript needs allowJs: true to type-check the setup.", + ) + + await P.confirm({ + message: "Set compilerOptions.allowJs to true in tsconfig.json?", + })->P.resultOrRaise + } else { + false + } + + let setAllowImportingTsExtensions = if gentypeConfig.needsAllowImportingTsExtensions { + P.Log.warn( + "genType bundler module resolution requires TypeScript allowImportingTsExtensions: true.", + ) + + await P.confirm({ + message: "Set compilerOptions.allowImportingTsExtensions to true in tsconfig.json?", + })->P.resultOrRaise + } else { + false + } + + if gentypeConfig.cannotSetAllowImportingTsExtensions { + P.Log.warn( + "genType bundler module resolution requires allowImportingTsExtensions: true, but TypeScript only allows that option when noEmit or emitDeclarationOnly is enabled.", + ) + + let shouldContinue = await P.confirm({ + message: "Continue with the inferred genType bundler configuration anyway?", + })->P.resultOrRaise + + if !shouldContinue { + JsError.throwWithMessage("genType bundler setup requires manual tsconfig changes.") + } + } + + try await updateTsConfig(~setAllowJs, ~setAllowImportingTsExtensions) catch { + | JsExn(error) => + P.Log.warn( + `Could not update tsconfig.json automatically: ${error->ErrorUtils.getErrorMessage}`, + ) + } + } +} + let addToExistingProject = async (~projectName) => { let versions = await RescriptVersions.promptVersions() + let packageJson = await getPackageJson() let sourceDir = await P.text({ message: "Where will you put your ReScript source files?", @@ -70,15 +282,11 @@ let addToExistingProject = async (~projectName) => { initialValue: "src", })->P.resultOrRaise - let moduleSystem = await P.select({ - message: "What module system will you use?", - options: getModuleSystemOptions(), - })->P.resultOrRaise - - let suffix = moduleSystem === "esmodule" ? ".res.mjs" : ".res.js" + let moduleConfig = await getProjectModuleConfig(packageJson) + await promptTsConfigUpdates(moduleConfig.gentypeConfig) let shouldCheckJsFilesIntoGit = await P.confirm({ - message: `Do you want to check generated ${suffix} files into git?`, + message: `Do you want to check generated ${moduleConfig.suffix} files into git?`, })->P.resultOrRaise let templatePath = CraPaths.getTemplatePath(~templateName=Templates.basicTemplateName) @@ -103,11 +311,18 @@ let addToExistingProject = async (~projectName) => { } if !shouldCheckJsFilesIntoGit { - await Fs.Promises.appendFile(gitignorePath, `**/*${suffix}${Os.eol}`) + await Fs.Promises.appendFile(gitignorePath, `**/*${moduleConfig.suffix}${Os.eol}`) } await updatePackageJson(~versions) - await updateRescriptJson(~projectName, ~sourceDir, ~moduleSystem, ~suffix, ~versions) + await updateRescriptJson( + ~projectName, + ~sourceDir, + ~moduleSystem=moduleConfig.moduleSystem, + ~suffix=moduleConfig.suffix, + ~gentypeConfig=moduleConfig.gentypeConfig, + ~versions, + ) if !Fs.existsSync(sourceDirPath) { await Fs.Promises.mkdir(sourceDirPath) diff --git a/src/TsConfigMapping.res b/src/TsConfigMapping.res new file mode 100644 index 0000000..c8b3a8c --- /dev/null +++ b/src/TsConfigMapping.res @@ -0,0 +1,152 @@ +type parsedConfig = { + status: string, + message?: string, + tsconfigPath?: string, + @as("module") + module_?: string, + moduleResolution?: string, + allowJs: bool, + allowImportingTsExtensions: bool, + noEmit: bool, + emitDeclarationOnly: bool, + jsx?: string, + hasMts: bool, + hasCts: bool, + hasNestedPackageType: bool, +} + +type inferredConfig = { + moduleSystem: string, + suffix: string, + gentypeModuleResolution: string, + generatedFileExtension: string, + warnings: array, + needsAllowJs: bool, + needsAllowImportingTsExtensions: bool, + cannotSetAllowImportingTsExtensions: bool, +} + +@module("./bindings/TsConfigParser.mjs") +external read: string => parsedConfig = "read" + +let esmodule = "esmodule" +let commonjs = "commonjs" +let suffix = ".res.js" + +let normalize = value => value->String.toLowerCase + +let isAnyOf = (value, values) => values->Array.some(candidate => candidate === value) + +let mapModule = (~module_, ~packageType) => + switch module_ { + | None => Error("No compilerOptions.module setting was found in tsconfig.json.") + | Some(moduleValue) => + switch moduleValue->normalize { + | "commonjs" => Ok(commonjs) + | "es2015" | "es6" | "es2020" | "es2022" | "esnext" => Ok(esmodule) + | "preserve" => Ok(esmodule) + | "node16" | "node18" | "node20" | "nodenext" => + switch packageType { + | Some("module") => Ok(esmodule) + | _ => Ok(commonjs) + } + | "amd" | "umd" | "system" | "none" => + Error( + `TypeScript module "${moduleValue}" cannot be represented by ReScript genType's esmodule/commonjs output.`, + ) + | _ => Error(`Unknown TypeScript module setting "${moduleValue}".`) + } + } + +let mapModuleResolution = (~module_, ~moduleResolution) => { + let nodeNextWarning = "TypeScript moduleResolution \"nodenext\" is approximated with genType \"node16\", because ReScript v12 only documents node/node16/bundler." + + switch moduleResolution { + | Some(moduleResolutionValue) => + switch moduleResolutionValue->normalize { + | "bundler" => Ok(("bundler", [])) + | "node16" => Ok(("node16", [])) + | "nodenext" => Ok(("node16", [nodeNextWarning])) + | "node" | "node10" | "nodejs" => Ok(("node", [])) + | "classic" => + Error("TypeScript moduleResolution \"classic\" is not supported for genType setup.") + | _ => Error(`Unknown TypeScript moduleResolution setting "${moduleResolutionValue}".`) + } + | None => + switch module_->Option.map(normalize) { + | Some("preserve") => Ok(("bundler", [])) + | Some("node16") | Some("node18") | Some("node20") => Ok(("node16", [])) + | Some("nodenext") => Ok(("node16", [nodeNextWarning])) + | Some("commonjs") => Ok(("node", [])) + | Some(moduleValue) if moduleValue->isAnyOf(["es2015", "es6", "es2020", "es2022", "esnext"]) => + Error( + `No compilerOptions.moduleResolution setting was found for TypeScript module "${moduleValue}".`, + ) + | Some(moduleValue) => + Error( + `No supported compilerOptions.moduleResolution mapping exists for TypeScript module "${moduleValue}".`, + ) + | None => Error("No compilerOptions.moduleResolution setting was found in tsconfig.json.") + } + } +} + +let hasMixedNodeModuleFormat = (~moduleSystem, ~packageType, config) => + switch packageType { + | Some("module") => moduleSystem === esmodule ? config.hasCts : true + | _ => moduleSystem === commonjs ? config.hasMts : true + } || + config.hasNestedPackageType + +let generatedFileExtension = (~hasJsxDependency, config) => + switch config.jsx { + | Some(_) => ".gen.tsx" + | None => hasJsxDependency ? ".gen.tsx" : ".gen.ts" + } + +let infer = (config, ~packageType, ~hasJsxDependency) => { + let normalizedModule = config.module_->Option.map(normalize) + let normalizedPackageType = packageType->Option.map(normalize) + + switch mapModule(~module_=normalizedModule, ~packageType=normalizedPackageType) { + | Error(message) => Error(message) + | Ok(moduleSystem) => + switch normalizedModule { + | Some(moduleValue) + if moduleValue->isAnyOf(["node16", "node18", "node20", "nodenext"]) && + hasMixedNodeModuleFormat(~moduleSystem, ~packageType=normalizedPackageType, config) => + Error( + "This TypeScript project appears to use mixed Node module formats, which cannot be represented by one ReScript project-level module setting.", + ) + | _ => + switch mapModuleResolution( + ~module_=normalizedModule, + ~moduleResolution=config.moduleResolution->Option.map(normalize), + ) { + | Error(message) => Error(message) + | Ok((gentypeModuleResolution, resolutionWarnings)) => + let preserveWarnings = switch normalizedModule { + | Some("preserve") => [ + "TypeScript module \"preserve\" is mapped to ReScript \"esmodule\"; verify the project does not rely on statement-level CommonJS preservation for generated ReScript modules.", + ] + | _ => [] + } + + Ok({ + moduleSystem, + suffix, + gentypeModuleResolution, + generatedFileExtension: generatedFileExtension(~hasJsxDependency, config), + warnings: Belt.Array.concat(resolutionWarnings, preserveWarnings), + needsAllowJs: !config.allowJs, + needsAllowImportingTsExtensions: gentypeModuleResolution === "bundler" && + !config.allowImportingTsExtensions && + (config.noEmit || config.emitDeclarationOnly), + cannotSetAllowImportingTsExtensions: gentypeModuleResolution === "bundler" && + !config.allowImportingTsExtensions && + !(config.noEmit || config.emitDeclarationOnly), + }) + } + } + } +} diff --git a/src/bindings/ClackPrompts.res b/src/bindings/ClackPrompts.res index 2bdb9c1..fe1d141 100644 --- a/src/bindings/ClackPrompts.res +++ b/src/bindings/ClackPrompts.res @@ -11,6 +11,7 @@ module Spinner = { module Log = { @module("@clack/prompts") @scope("log") external message: string => unit = "message" @module("@clack/prompts") @scope("log") external info: string => unit = "info" + @module("@clack/prompts") @scope("log") external warn: string => unit = "warn" @module("@clack/prompts") @scope("log") external error: string => unit = "error" } diff --git a/src/bindings/TsConfigParser.mjs b/src/bindings/TsConfigParser.mjs new file mode 100644 index 0000000..2913493 --- /dev/null +++ b/src/bindings/TsConfigParser.mjs @@ -0,0 +1,134 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const baseResult = { + status: "not_found", + allowJs: false, + allowImportingTsExtensions: false, + noEmit: false, + emitDeclarationOnly: false, + hasMts: false, + hasCts: false, + hasNestedPackageType: false, +}; + +function formatDiagnostic(ts, diagnostic) { + if (!diagnostic) { + return undefined; + } + + return ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); +} + +function getEnumName(enumObject, value) { + if (value === undefined || value === null) { + return undefined; + } + + if (typeof value === "string") { + return value.toLowerCase(); + } + + const name = enumObject?.[value]; + return typeof name === "string" ? name.toLowerCase() : undefined; +} + +function readPackageType(packageJsonPath) { + try { + const json = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + return typeof json.type === "string" ? json.type.toLowerCase() : undefined; + } catch (_error) { + return undefined; + } +} + +function hasNestedPackageType(projectPath, fileNames, rootPackageType) { + for (const fileName of fileNames) { + let currentDirectory = path.dirname(fileName); + + while (currentDirectory.startsWith(projectPath) && currentDirectory !== projectPath) { + const packageJsonPath = path.join(currentDirectory, "package.json"); + const packageType = readPackageType(packageJsonPath); + + if (packageType !== undefined && packageType !== rootPackageType) { + return true; + } + + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + break; + } + currentDirectory = parentDirectory; + } + } + + return false; +} + +export function read(projectPath) { + const tsconfigPath = path.join(projectPath, "tsconfig.json"); + + if (!fs.existsSync(tsconfigPath)) { + return baseResult; + } + + let ts; + try { + const requireFromProject = createRequire(path.join(projectPath, "package.json")); + ts = requireFromProject("typescript"); + } catch (_error) { + return { + ...baseResult, + status: "typescript_missing", + tsconfigPath, + message: "Could not resolve the project's TypeScript package.", + }; + } + + const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (configFile.error !== undefined) { + return { + ...baseResult, + status: "error", + tsconfigPath, + message: formatDiagnostic(ts, configFile.error), + }; + } + + const parsed = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + projectPath, + {}, + tsconfigPath, + ); + + if (parsed.errors.length > 0) { + return { + ...baseResult, + status: "error", + tsconfigPath, + message: parsed.errors.map(error => formatDiagnostic(ts, error)).join("\n"), + }; + } + + const options = parsed.options; + const fileNames = parsed.fileNames ?? []; + const rootPackageType = readPackageType(path.join(projectPath, "package.json")); + + return { + status: "found", + tsconfigPath, + module: getEnumName(ts.ModuleKind, options.module), + moduleResolution: getEnumName(ts.ModuleResolutionKind, options.moduleResolution), + allowJs: options.allowJs === true, + allowImportingTsExtensions: options.allowImportingTsExtensions === true, + noEmit: options.noEmit === true, + emitDeclarationOnly: options.emitDeclarationOnly === true, + jsx: getEnumName(ts.JsxEmit, options.jsx), + hasMts: fileNames.some(fileName => fileName.endsWith(".mts")), + hasCts: fileNames.some(fileName => fileName.endsWith(".cts")), + hasNestedPackageType: hasNestedPackageType(projectPath, fileNames, rootPackageType), + }; +} diff --git a/test/TsConfigMappingTest.res b/test/TsConfigMappingTest.res new file mode 100644 index 0000000..e25c871 --- /dev/null +++ b/test/TsConfigMappingTest.res @@ -0,0 +1,137 @@ +open Node + +let makeConfig = ( + ~module_=?, + ~moduleResolution=?, + ~allowJs=false, + ~allowImportingTsExtensions=false, + ~noEmit=false, + ~emitDeclarationOnly=false, + ~jsx=?, + ~hasMts=false, + ~hasCts=false, + ~hasNestedPackageType=false, + (), +): TsConfigMapping.parsedConfig => { + status: "found", + ?module_, + ?moduleResolution, + allowJs, + allowImportingTsExtensions, + noEmit, + emitDeclarationOnly, + ?jsx, + hasMts, + hasCts, + hasNestedPackageType, +} + +let assertInferred = ( + result: result, + ~moduleSystem, + ~moduleResolution, + ~generatedFileExtension, + ~needsAllowJs, +) => + switch result { + | Ok(config) => + Assert.strictEqual(config.moduleSystem, moduleSystem) + Assert.strictEqual(config.suffix, ".res.js") + Assert.strictEqual(config.gentypeModuleResolution, moduleResolution) + Assert.strictEqual(config.generatedFileExtension, generatedFileExtension) + Assert.strictEqual(config.needsAllowJs, needsAllowJs) + | Error(message) => Assert.fail(message) + } + +let assertError = (result: result, expectedMessage) => + switch result { + | Ok(_) => Assert.fail(`Expected mapping error: ${expectedMessage}`) + | Error(message) => Assert.strictEqual(message, expectedMessage) + } + +Test.describe("TsConfigMapping", () => { + Test.test("maps CommonJS projects to CommonJS genType output", () => { + makeConfig(~module_="commonjs", ~moduleResolution="nodejs", ()) + ->TsConfigMapping.infer(~packageType=None, ~hasJsxDependency=false) + ->assertInferred( + ~moduleSystem="commonjs", + ~moduleResolution="node", + ~generatedFileExtension=".gen.ts", + ~needsAllowJs=true, + ) + }) + + Test.test("maps bundler ESM projects to ESM genType output", () => { + makeConfig( + ~module_="esnext", + ~moduleResolution="bundler", + ~allowJs=true, + ~allowImportingTsExtensions=true, + ~jsx="react-jsx", + (), + ) + ->TsConfigMapping.infer(~packageType=None, ~hasJsxDependency=false) + ->assertInferred( + ~moduleSystem="esmodule", + ~moduleResolution="bundler", + ~generatedFileExtension=".gen.tsx", + ~needsAllowJs=false, + ) + }) + + Test.test("maps preserve mode to bundler genType resolution", () => { + switch makeConfig(~module_="preserve", ~noEmit=true, ())->TsConfigMapping.infer( + ~packageType=None, + ~hasJsxDependency=false, + ) { + | Ok(config) => + Assert.strictEqual(config.moduleSystem, "esmodule") + Assert.strictEqual(config.gentypeModuleResolution, "bundler") + Assert.strictEqual(config.needsAllowImportingTsExtensions, true) + Assert.strictEqual(config.cannotSetAllowImportingTsExtensions, false) + | Error(message) => Assert.fail(message) + } + }) + + Test.test("maps NodeNext package modules to ESM output and node16 genType resolution", () => { + switch makeConfig( + ~module_="nodenext", + ~moduleResolution="nodenext", + ~allowJs=true, + (), + )->TsConfigMapping.infer(~packageType=Some("module"), ~hasJsxDependency=false) { + | Ok(config) => + Assert.strictEqual(config.moduleSystem, "esmodule") + Assert.strictEqual(config.gentypeModuleResolution, "node16") + Assert.strictEqual(config.warnings->Array.length, 1) + | Error(message) => Assert.fail(message) + } + }) + + Test.test("rejects Node dual-format projects that conflict with package type", () => { + makeConfig(~module_="node16", ~moduleResolution="node16", ~hasMts=true, ()) + ->TsConfigMapping.infer(~packageType=None, ~hasJsxDependency=false) + ->assertError( + "This TypeScript project appears to use mixed Node module formats, which cannot be represented by one ReScript project-level module setting.", + ) + }) + + Test.test("rejects ESM module configs without an effective module resolution", () => { + makeConfig(~module_="esnext", ()) + ->TsConfigMapping.infer(~packageType=None, ~hasJsxDependency=false) + ->assertError( + "No compilerOptions.moduleResolution setting was found for TypeScript module \"esnext\".", + ) + }) + + Test.test("uses tsx generated files when package dependencies indicate JSX", () => { + makeConfig(~module_="commonjs", ~moduleResolution="node", ~allowJs=true, ()) + ->TsConfigMapping.infer(~packageType=None, ~hasJsxDependency=true) + ->assertInferred( + ~moduleSystem="commonjs", + ~moduleResolution="node", + ~generatedFileExtension=".gen.tsx", + ~needsAllowJs=false, + ) + }) +})