Skip to content

Commit eee577b

Browse files
Infer genType setup from tsconfig
1 parent 295067f commit eee577b

6 files changed

Lines changed: 655 additions & 15 deletions

File tree

docs/gentype-tsconfig-mapping.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,9 @@ Use:
144144
| React, Next.js, or another JSX framework is detected in package.json dependencies | `.gen.tsx` |
145145
| No JSX signal | `.gen.ts` |
146146

147-
If we want to minimize behavior differences from ReScript's documented default,
148-
omit `generatedFileExtension` when no JSX signal is available and accept the
149-
compiler default of `.gen.tsx`. If we want the least surprising setup for
150-
non-React TypeScript projects, set `.gen.ts` explicitly.
147+
Set `.gen.ts` explicitly when no JSX signal is available. That differs from the
148+
documented genType default of `.gen.tsx`, but it is less surprising for existing
149+
non-React TypeScript projects.
151150

152151
## Fallback Rules
153152

@@ -158,7 +157,9 @@ Fall back to the current manual module prompt when:
158157
- `module` is missing or unsupported.
159158
- `moduleResolution` is `classic`, unknown, or conflicts with the module mode.
160159
- The project is a mixed Node dual-format project that cannot be represented by
161-
one ReScript project-level module setting.
160+
one ReScript project-level module setting, such as a CommonJS package with
161+
`.mts` inputs, an ESM package with `.cts` inputs, or included source files
162+
under nested package.json files with a different `type`.
162163
- The project uses `module: "preserve"` and package contents suggest meaningful
163164
CommonJS-style exports that ReScript cannot preserve statement-by-statement.
164165

src/ExistingJsProject.res

Lines changed: 225 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@ open Node
22

33
module P = ClackPrompts
44

5+
type projectModuleConfig = {
6+
moduleSystem: string,
7+
suffix: string,
8+
gentypeConfig: option<TsConfigMapping.inferredConfig>,
9+
}
10+
11+
let getOrCreateJsonObject = (config: Dict.t<JSON.t>, ~fieldName) =>
12+
switch config->Dict.get(fieldName) {
13+
| Some(Object(object)) => object
14+
| _ =>
15+
let object = Dict.make()
16+
config->Dict.set(fieldName, Object(object))
17+
object
18+
}
19+
520
let updatePackageJson = async (~versions) =>
621
await JsonUtils.updateJsonFile("package.json", json =>
722
switch json {
@@ -25,7 +40,14 @@ let updatePackageJson = async (~versions) =>
2540
}
2641
)
2742

28-
let updateRescriptJson = async (~projectName, ~sourceDir, ~moduleSystem, ~suffix, ~versions) =>
43+
let updateRescriptJson = async (
44+
~projectName,
45+
~sourceDir,
46+
~moduleSystem,
47+
~suffix,
48+
~gentypeConfig: option<TsConfigMapping.inferredConfig>,
49+
~versions,
50+
) =>
2951
await JsonUtils.updateJsonFile("rescript.json", json =>
3052
switch json {
3153
| Object(config) =>
@@ -39,6 +61,21 @@ let updateRescriptJson = async (~projectName, ~sourceDir, ~moduleSystem, ~suffix
3961
| Some(Object(sources)) => sources->Dict.set("module", String(moduleSystem))
4062
| _ => ()
4163
}
64+
switch gentypeConfig {
65+
| Some(gentypeConfig) =>
66+
let gentypeConfigJson: Dict.t<JSON.t> = Dict.make()
67+
gentypeConfigJson->Dict.set("module", String(gentypeConfig.moduleSystem))
68+
gentypeConfigJson->Dict.set(
69+
"moduleResolution",
70+
String(gentypeConfig.gentypeModuleResolution),
71+
)
72+
gentypeConfigJson->Dict.set(
73+
"generatedFileExtension",
74+
String(gentypeConfig.generatedFileExtension),
75+
)
76+
config->Dict.set("gentypeconfig", Object(gentypeConfigJson))
77+
| None => ()
78+
}
4279

4380
if Option.isNone(versions.RescriptVersions.rescriptCoreVersion) {
4481
RescriptJsonUtils.removeRescriptCore(config)
@@ -60,8 +97,183 @@ let getModuleSystemOptions = () => [
6097
},
6198
]
6299

100+
let getPackageJson = async () => await JsonUtils.readJsonFile("package.json")
101+
102+
let getPackageType = (packageJson: JSON.t) =>
103+
switch packageJson {
104+
| Object(config) =>
105+
switch config->Dict.get("type") {
106+
| Some(String(packageType)) => Some(packageType->String.toLowerCase)
107+
| _ => None
108+
}
109+
| _ => None
110+
}
111+
112+
let packageJsonHasDependency = (packageJson: JSON.t, dependencyNames) => {
113+
let dependencyFields = [
114+
"dependencies",
115+
"devDependencies",
116+
"peerDependencies",
117+
"optionalDependencies",
118+
]
119+
120+
switch packageJson {
121+
| Object(config) =>
122+
dependencyFields->Array.some(fieldName =>
123+
switch config->Dict.get(fieldName) {
124+
| Some(Object(dependencies)) =>
125+
dependencyNames->Array.some(dependencyName =>
126+
dependencies->Dict.get(dependencyName)->Option.isSome
127+
)
128+
| _ => false
129+
}
130+
)
131+
| _ => false
132+
}
133+
}
134+
135+
let hasJsxDependency = (packageJson: JSON.t) =>
136+
packageJson->packageJsonHasDependency([
137+
"react",
138+
"react-dom",
139+
"next",
140+
"preact",
141+
"solid-js",
142+
"@vitejs/plugin-react",
143+
])
144+
145+
let getManualModuleConfig = async () => {
146+
let moduleSystem = await P.select({
147+
message: "What module system will you use?",
148+
options: getModuleSystemOptions(),
149+
})->P.resultOrRaise
150+
151+
{
152+
moduleSystem,
153+
suffix: moduleSystem === "esmodule" ? ".res.mjs" : ".res.js",
154+
gentypeConfig: None,
155+
}
156+
}
157+
158+
let getTsConfigModuleConfig = async packageJson => {
159+
let projectPath = Process.cwd()
160+
let tsConfig = TsConfigMapping.read(projectPath)
161+
162+
switch tsConfig.status {
163+
| "found" =>
164+
switch TsConfigMapping.infer(
165+
tsConfig,
166+
~packageType=getPackageType(packageJson),
167+
~hasJsxDependency=hasJsxDependency(packageJson),
168+
) {
169+
| Ok(gentypeConfig) =>
170+
P.Log.info(
171+
`Detected tsconfig.json. ReScript will use ${gentypeConfig.moduleSystem} output, ${gentypeConfig.gentypeModuleResolution} genType module resolution, and ${gentypeConfig.suffix} generated JS files.`,
172+
)
173+
174+
gentypeConfig.warnings->Array.forEach(P.Log.warn)
175+
176+
Some({
177+
moduleSystem: gentypeConfig.moduleSystem,
178+
suffix: gentypeConfig.suffix,
179+
gentypeConfig: Some(gentypeConfig),
180+
})
181+
| Error(message) =>
182+
P.Log.warn(`${message} Falling back to manual ReScript module setup.`)
183+
None
184+
}
185+
| "not_found" => None
186+
| "typescript_missing" =>
187+
P.Log.warn(
188+
"Found tsconfig.json, but could not resolve the project's TypeScript package. Falling back to manual ReScript module setup.",
189+
)
190+
None
191+
| _ =>
192+
let message = tsConfig.message->Option.getOr("Could not read the effective tsconfig.json.")
193+
P.Log.warn(`${message} Falling back to manual ReScript module setup.`)
194+
None
195+
}
196+
}
197+
198+
let getProjectModuleConfig = async packageJson =>
199+
switch await getTsConfigModuleConfig(packageJson) {
200+
| Some(config) => config
201+
| None => await getManualModuleConfig()
202+
}
203+
204+
let updateTsConfig = async (~setAllowJs, ~setAllowImportingTsExtensions) =>
205+
if setAllowJs || setAllowImportingTsExtensions {
206+
await JsonUtils.updateJsonFile("tsconfig.json", json =>
207+
switch json {
208+
| Object(config) =>
209+
let compilerOptions = config->getOrCreateJsonObject(~fieldName="compilerOptions")
210+
211+
if setAllowJs {
212+
compilerOptions->Dict.set("allowJs", Boolean(true))
213+
}
214+
215+
if setAllowImportingTsExtensions {
216+
compilerOptions->Dict.set("allowImportingTsExtensions", Boolean(true))
217+
}
218+
| _ => ()
219+
}
220+
)
221+
}
222+
223+
let promptTsConfigUpdates = async (gentypeConfig: option<TsConfigMapping.inferredConfig>) => {
224+
switch gentypeConfig {
225+
| None => ()
226+
| Some(gentypeConfig) =>
227+
let setAllowJs = if gentypeConfig.needsAllowJs {
228+
P.Log.warn(
229+
"TypeScript allowJs is not enabled. genType imports ReScript's generated JS files, so TypeScript needs allowJs: true to type-check the setup.",
230+
)
231+
232+
await P.confirm({
233+
message: "Set compilerOptions.allowJs to true in tsconfig.json?",
234+
})->P.resultOrRaise
235+
} else {
236+
false
237+
}
238+
239+
let setAllowImportingTsExtensions = if gentypeConfig.needsAllowImportingTsExtensions {
240+
P.Log.warn(
241+
"genType bundler module resolution requires TypeScript allowImportingTsExtensions: true.",
242+
)
243+
244+
await P.confirm({
245+
message: "Set compilerOptions.allowImportingTsExtensions to true in tsconfig.json?",
246+
})->P.resultOrRaise
247+
} else {
248+
false
249+
}
250+
251+
if gentypeConfig.cannotSetAllowImportingTsExtensions {
252+
P.Log.warn(
253+
"genType bundler module resolution requires allowImportingTsExtensions: true, but TypeScript only allows that option when noEmit or emitDeclarationOnly is enabled.",
254+
)
255+
256+
let shouldContinue = await P.confirm({
257+
message: "Continue with the inferred genType bundler configuration anyway?",
258+
})->P.resultOrRaise
259+
260+
if !shouldContinue {
261+
JsError.throwWithMessage("genType bundler setup requires manual tsconfig changes.")
262+
}
263+
}
264+
265+
try await updateTsConfig(~setAllowJs, ~setAllowImportingTsExtensions) catch {
266+
| JsExn(error) =>
267+
P.Log.warn(
268+
`Could not update tsconfig.json automatically: ${error->ErrorUtils.getErrorMessage}`,
269+
)
270+
}
271+
}
272+
}
273+
63274
let addToExistingProject = async (~projectName) => {
64275
let versions = await RescriptVersions.promptVersions()
276+
let packageJson = await getPackageJson()
65277

66278
let sourceDir = await P.text({
67279
message: "Where will you put your ReScript source files?",
@@ -70,15 +282,11 @@ let addToExistingProject = async (~projectName) => {
70282
initialValue: "src",
71283
})->P.resultOrRaise
72284

73-
let moduleSystem = await P.select({
74-
message: "What module system will you use?",
75-
options: getModuleSystemOptions(),
76-
})->P.resultOrRaise
77-
78-
let suffix = moduleSystem === "esmodule" ? ".res.mjs" : ".res.js"
285+
let moduleConfig = await getProjectModuleConfig(packageJson)
286+
await promptTsConfigUpdates(moduleConfig.gentypeConfig)
79287

80288
let shouldCheckJsFilesIntoGit = await P.confirm({
81-
message: `Do you want to check generated ${suffix} files into git?`,
289+
message: `Do you want to check generated ${moduleConfig.suffix} files into git?`,
82290
})->P.resultOrRaise
83291

84292
let templatePath = CraPaths.getTemplatePath(~templateName=Templates.basicTemplateName)
@@ -103,11 +311,18 @@ let addToExistingProject = async (~projectName) => {
103311
}
104312

105313
if !shouldCheckJsFilesIntoGit {
106-
await Fs.Promises.appendFile(gitignorePath, `**/*${suffix}${Os.eol}`)
314+
await Fs.Promises.appendFile(gitignorePath, `**/*${moduleConfig.suffix}${Os.eol}`)
107315
}
108316

109317
await updatePackageJson(~versions)
110-
await updateRescriptJson(~projectName, ~sourceDir, ~moduleSystem, ~suffix, ~versions)
318+
await updateRescriptJson(
319+
~projectName,
320+
~sourceDir,
321+
~moduleSystem=moduleConfig.moduleSystem,
322+
~suffix=moduleConfig.suffix,
323+
~gentypeConfig=moduleConfig.gentypeConfig,
324+
~versions,
325+
)
111326

112327
if !Fs.existsSync(sourceDirPath) {
113328
await Fs.Promises.mkdir(sourceDirPath)

0 commit comments

Comments
 (0)