@omnimod/plugin-react-class-to-hooks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 salnika
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # @omnimod/plugin-react-class-to-hooks
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40omnimod%2Fplugin-react-class-to-hooks?label=npm)](https://www.npmjs.com/package/@omnimod/plugin-react-class-to-hooks)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/Salnika/omnimod/ci.yml?branch=master&label=ci)](https://github.com/Salnika/omnimod/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/github/license/Salnika/omnimod)](../../LICENSE)
6
+
7
+ omnimod plugin that migrates simple React class components to function
8
+ components with hooks.
9
+
10
+ ## Use With The CLI
11
+
12
+ ```bash
13
+ omnimod run react-class-to-hooks "src/**/*.{tsx,jsx}"
14
+ omnimod run react-class-to-hooks "src/**/*.{tsx,jsx}" --write
15
+ ```
16
+
17
+ ## Use As A Library
18
+
19
+ ```ts
20
+ import { reactClassToHooks } from "@omnimod/plugin-react-class-to-hooks";
21
+ ```
22
+
23
+ ## Notes
24
+
25
+ The plugin targets straightforward class components with state, props, and common
26
+ lifecycle methods. Complex patterns such as HOCs, refs, and derived state are
27
+ reported as diagnostics.
@@ -0,0 +1,12 @@
1
+ //#region src/plugin.d.ts
2
+ type ReactClassToHooksOptions = Record<string, unknown>;
3
+ /**
4
+ * Migrate SIMPLE React class components to function components + hooks. This is
5
+ * intentionally conservative: only classes with a `render()`, optional
6
+ * `state = { ... }`, optional mount/unmount/update lifecycles, and plain methods
7
+ * are converted. Anything with a constructor body, refs, context, unsupported
8
+ * lifecycle, or a multi-key/functional `setState` is left untouched with a warn.
9
+ */
10
+ declare const reactClassToHooks: import("@omnimod/core").Plugin<ReactClassToHooksOptions, unknown>;
11
+ //#endregion
12
+ export { type ReactClassToHooksOptions, reactClassToHooks as default, reactClassToHooks };
package/dist/index.mjs ADDED
@@ -0,0 +1,590 @@
1
+ import { definePlugin, walk } from "@omnimod/core";
2
+ import { cast } from "@omnimod/plugin-utils";
3
+ //#region src/detect.ts
4
+ /** The React class-component base classes we recognise as a superclass. */
5
+ const REACT_BASES = /* @__PURE__ */ new Set(["Component", "PureComponent"]);
6
+ /**
7
+ * True when `superClass` is one of `React.Component`, `Component`,
8
+ * `React.PureComponent`, `PureComponent`.
9
+ */
10
+ function isReactComponentSuper(superClass) {
11
+ if (!superClass) return false;
12
+ if (superClass.type === "Identifier") return REACT_BASES.has(cast(superClass).name);
13
+ if (superClass.type === "MemberExpression") {
14
+ const member = cast(superClass);
15
+ if (member.computed || member.property.type !== "Identifier") return false;
16
+ if (member.object.type !== "Identifier") return false;
17
+ return cast(member.object).name === "React" && REACT_BASES.has(cast(member.property).name);
18
+ }
19
+ return false;
20
+ }
21
+ const UNSUPPORTED_LIFECYCLE = /* @__PURE__ */ new Set([
22
+ "getDerivedStateFromProps",
23
+ "getSnapshotBeforeUpdate",
24
+ "shouldComponentUpdate",
25
+ "componentDidCatch",
26
+ "getDerivedStateFromError",
27
+ "componentWillMount",
28
+ "UNSAFE_componentWillMount",
29
+ "componentWillReceiveProps",
30
+ "UNSAFE_componentWillReceiveProps",
31
+ "componentWillUpdate",
32
+ "UNSAFE_componentWillUpdate"
33
+ ]);
34
+ /** Property key as a plain string, or null if it is computed / non-identifier. */
35
+ function memberName(key, computed) {
36
+ if (computed) return null;
37
+ if (key.type === "Identifier") return cast(key).name;
38
+ if (key.type === "Literal") {
39
+ const value = key.value;
40
+ return typeof value === "string" ? value : null;
41
+ }
42
+ return null;
43
+ }
44
+ /**
45
+ * Inspect a React class component and decide whether it is "simple" enough to
46
+ * migrate. Returns a `bail` reason (non-null) when it must be left untouched.
47
+ */
48
+ function analyzeClass(node) {
49
+ const name = node.id ? cast(node.id).name : null;
50
+ const members = [];
51
+ let bail = null;
52
+ let hasRender = false;
53
+ for (const raw of node.body.body) {
54
+ if (bail) break;
55
+ if (raw.type === "MethodDefinition") {
56
+ const method = cast(raw);
57
+ const key = memberName(method.key, method.computed);
58
+ if (method.kind === "constructor") {
59
+ bail = "constructor contains logic beyond super()/this.state";
60
+ continue;
61
+ }
62
+ if (method.static) {
63
+ bail = `unsupported static method \`${key ?? "?"}\``;
64
+ continue;
65
+ }
66
+ if (key === null) {
67
+ bail = "computed method name";
68
+ continue;
69
+ }
70
+ if (key === "render") {
71
+ hasRender = true;
72
+ members.push({
73
+ node: raw,
74
+ kind: "render",
75
+ name: key
76
+ });
77
+ } else if (key === "componentDidMount") members.push({
78
+ node: raw,
79
+ kind: "lifecycle-mount",
80
+ name: key
81
+ });
82
+ else if (key === "componentWillUnmount") members.push({
83
+ node: raw,
84
+ kind: "lifecycle-unmount",
85
+ name: key
86
+ });
87
+ else if (key === "componentDidUpdate") members.push({
88
+ node: raw,
89
+ kind: "lifecycle-update",
90
+ name: key
91
+ });
92
+ else if (UNSUPPORTED_LIFECYCLE.has(key)) bail = `unsupported lifecycle method \`${key}\``;
93
+ else if (method.kind === "get" || method.kind === "set") bail = `unsupported accessor \`${key}\``;
94
+ else members.push({
95
+ node: raw,
96
+ kind: "method",
97
+ name: key
98
+ });
99
+ continue;
100
+ }
101
+ if (raw.type === "PropertyDefinition") {
102
+ const prop = cast(raw);
103
+ const key = memberName(prop.key, prop.computed);
104
+ if (key === null) {
105
+ bail = "computed class field";
106
+ continue;
107
+ }
108
+ if (prop.static) {
109
+ if (key === "defaultProps") members.push({
110
+ node: raw,
111
+ kind: "default-props",
112
+ name: key
113
+ });
114
+ else bail = `unsupported static field \`${key}\``;
115
+ continue;
116
+ }
117
+ if (key === "state") {
118
+ members.push({
119
+ node: raw,
120
+ kind: "state-field",
121
+ name: key
122
+ });
123
+ continue;
124
+ }
125
+ if (prop.value && prop.value.type === "ArrowFunctionExpression") {
126
+ members.push({
127
+ node: raw,
128
+ kind: "method",
129
+ name: key
130
+ });
131
+ continue;
132
+ }
133
+ bail = `unsupported instance field \`${key}\` (only \`state\` and bound methods are handled)`;
134
+ continue;
135
+ }
136
+ if (raw.type === "StaticBlock") {
137
+ bail = "static initialization block";
138
+ continue;
139
+ }
140
+ bail = `unsupported class member \`${raw.type}\``;
141
+ }
142
+ if (!bail && !hasRender) bail = "no render() method";
143
+ return {
144
+ node,
145
+ name,
146
+ members,
147
+ bail
148
+ };
149
+ }
150
+ function findClassComponents(program) {
151
+ const found = [];
152
+ for (const stmt of program.body) {
153
+ let classNode = null;
154
+ let isDeclaration = false;
155
+ if (stmt.type === "ClassDeclaration") {
156
+ classNode = cast(stmt);
157
+ isDeclaration = true;
158
+ } else if (stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportDefaultDeclaration") {
159
+ const decl = stmt.declaration;
160
+ if (decl && decl.type === "ClassDeclaration") {
161
+ classNode = cast(decl);
162
+ isDeclaration = true;
163
+ }
164
+ }
165
+ if (!classNode) continue;
166
+ if (!isReactComponentSuper(classNode.superClass)) continue;
167
+ found.push({
168
+ info: analyzeClass(classNode),
169
+ statement: stmt,
170
+ isDeclaration
171
+ });
172
+ }
173
+ return found;
174
+ }
175
+ //#endregion
176
+ //#region src/rewrite.ts
177
+ /** `count` → `setCount`. */
178
+ function setterName(key) {
179
+ return `set${key.charAt(0).toUpperCase()}${key.slice(1)}`;
180
+ }
181
+ /**
182
+ * Rewrite the `this.*` references inside a region of source into their function
183
+ * equivalents. Returns the transformed text and a flag noting whether a
184
+ * multi-key / functional `setState` was encountered (which forces a bail).
185
+ *
186
+ * Handled forms:
187
+ * - `this.state.k` → `k`
188
+ * - `this.props.x` / `this.props` → `props.x` / `props`
189
+ * - `this.setState({ k: v })` → `setK(v)` (single-key object updates only)
190
+ * - `this.foo` → `foo` (methods / bound fields)
191
+ */
192
+ function rewriteThis(source, region, state) {
193
+ const edits = [];
194
+ let unsupportedSetState = false;
195
+ walk(region, (node) => {
196
+ if (node.type !== "MemberExpression") return;
197
+ const member = cast(node);
198
+ if (member.object.type !== "ThisExpression") return;
199
+ if (member.computed || member.property.type !== "Identifier") return;
200
+ const prop = cast(member.property).name;
201
+ if (prop === "state") return;
202
+ if (prop === "props") {
203
+ edits.push({
204
+ start: member.object.start,
205
+ end: member.property.start,
206
+ text: ""
207
+ });
208
+ return;
209
+ }
210
+ edits.push({
211
+ start: member.object.start,
212
+ end: member.property.start,
213
+ text: ""
214
+ });
215
+ });
216
+ walk(region, (node) => {
217
+ if (node.type === "MemberExpression") {
218
+ const outer = cast(node);
219
+ if (outer.object.type === "MemberExpression" && !outer.computed && outer.property.type === "Identifier") {
220
+ const inner = cast(outer.object);
221
+ if (inner.object.type === "ThisExpression" && inner.property.type === "Identifier" && cast(inner.property).name === "state") {
222
+ const key = cast(outer.property).name;
223
+ if (state.keys.includes(key)) edits.push({
224
+ start: outer.start,
225
+ end: outer.property.end,
226
+ text: key
227
+ });
228
+ }
229
+ }
230
+ }
231
+ if (node.type === "CallExpression") {
232
+ const call = cast(node);
233
+ if (call.callee.type === "MemberExpression" && !cast(call.callee).computed) {
234
+ const callee = cast(call.callee);
235
+ if (callee.object.type === "ThisExpression" && callee.property.type === "Identifier" && cast(callee.property).name === "setState") {
236
+ const arg = call.arguments[0];
237
+ if (!arg || arg.type !== "ObjectExpression") {
238
+ unsupportedSetState = true;
239
+ return;
240
+ }
241
+ const obj = cast(arg);
242
+ if (obj.properties.length !== 1) {
243
+ unsupportedSetState = true;
244
+ return;
245
+ }
246
+ const only = obj.properties[0];
247
+ if (!only || only.type !== "Property") {
248
+ unsupportedSetState = true;
249
+ return;
250
+ }
251
+ const property = cast(only);
252
+ if (property.computed || property.key.type !== "Identifier") {
253
+ unsupportedSetState = true;
254
+ return;
255
+ }
256
+ const key = cast(property.key).name;
257
+ const setter = state.setters.get(key);
258
+ if (!setter) {
259
+ unsupportedSetState = true;
260
+ return;
261
+ }
262
+ const valueText = rewriteThis(source, property.value, state).text;
263
+ edits.push({
264
+ start: call.start,
265
+ end: call.end,
266
+ text: `${setter}(${valueText})`
267
+ });
268
+ }
269
+ }
270
+ }
271
+ });
272
+ return {
273
+ text: applyEdits(source, region, edits),
274
+ unsupportedSetState
275
+ };
276
+ }
277
+ /**
278
+ * Apply non-overlapping edits within [region.start, region.end) to the source
279
+ * and return the rewritten substring. Inner `this.state.k` / `setState` edits
280
+ * subsume the prefix-strip edits, so drop any prefix-strip that overlaps a
281
+ * span-replacing edit.
282
+ */
283
+ function applyEdits(source, region, edits) {
284
+ const sorted = [...edits].sort((a, b) => a.start - b.start || b.end - a.end);
285
+ const kept = [];
286
+ let lastEnd = -1;
287
+ for (const edit of sorted) {
288
+ if (edit.start < lastEnd) continue;
289
+ kept.push(edit);
290
+ lastEnd = edit.end;
291
+ }
292
+ let out = "";
293
+ let cursor = region.start;
294
+ for (const edit of kept) {
295
+ out += source.slice(cursor, edit.start);
296
+ out += edit.text;
297
+ cursor = edit.end;
298
+ }
299
+ out += source.slice(cursor, region.end);
300
+ return out;
301
+ }
302
+ //#endregion
303
+ //#region src/plugin.ts
304
+ /**
305
+ * Migrate SIMPLE React class components to function components + hooks. This is
306
+ * intentionally conservative: only classes with a `render()`, optional
307
+ * `state = { ... }`, optional mount/unmount/update lifecycles, and plain methods
308
+ * are converted. Anything with a constructor body, refs, context, unsupported
309
+ * lifecycle, or a multi-key/functional `setState` is left untouched with a warn.
310
+ */
311
+ const reactClassToHooks = definePlugin({
312
+ name: "react-class-to-hooks",
313
+ description: "Migrate simple React class components to function components + hooks.",
314
+ include: ["**/*.{tsx,jsx}"],
315
+ transform(file) {
316
+ const classes = findClassComponents(file.program);
317
+ if (classes.length === 0) return;
318
+ const hooksUsed = /* @__PURE__ */ new Set();
319
+ let anyConverted = false;
320
+ for (const found of classes) {
321
+ const info = found.info;
322
+ if (info.bail) {
323
+ file.report({
324
+ message: `Left \`${info.name ?? "class component"}\` as a class: ${info.bail}.`,
325
+ severity: "warn"
326
+ });
327
+ continue;
328
+ }
329
+ if (!info.name) {
330
+ file.report({
331
+ message: "Left an anonymous class component untouched (no name to convert to).",
332
+ severity: "warn"
333
+ });
334
+ continue;
335
+ }
336
+ const converted = convertClass(file, info, info.name);
337
+ if (!converted) continue;
338
+ for (const hook of converted.hooks) hooksUsed.add(hook);
339
+ anyConverted = true;
340
+ }
341
+ if (anyConverted && hooksUsed.size > 0) ensureReactHookImports(file, hooksUsed);
342
+ }
343
+ });
344
+ /** Build the function-component text and replace the class in place. */
345
+ function convertClass(file, info, name) {
346
+ const hooks = /* @__PURE__ */ new Set();
347
+ let stateField = null;
348
+ let render = null;
349
+ let mount = null;
350
+ let unmount = null;
351
+ let update = null;
352
+ const methods = [];
353
+ for (const member of info.members) switch (member.kind) {
354
+ case "state-field":
355
+ stateField = member;
356
+ break;
357
+ case "render":
358
+ render = member;
359
+ break;
360
+ case "lifecycle-mount":
361
+ mount = member;
362
+ break;
363
+ case "lifecycle-unmount":
364
+ unmount = member;
365
+ break;
366
+ case "lifecycle-update":
367
+ update = member;
368
+ break;
369
+ case "method":
370
+ methods.push(member);
371
+ break;
372
+ case "default-props": break;
373
+ case "constructor": break;
374
+ }
375
+ if (!render) {
376
+ file.report({
377
+ message: `Left \`${name}\`: no render() found.`,
378
+ severity: "warn"
379
+ });
380
+ return null;
381
+ }
382
+ const state = parseState(file, stateField);
383
+ if (state === "bail") {
384
+ file.report({
385
+ message: `Left \`${name}\`: \`state\` is not a simple object literal.`,
386
+ severity: "warn"
387
+ });
388
+ return null;
389
+ }
390
+ if (state.keys.length > 0) hooks.add("useState");
391
+ const lines = [];
392
+ for (const key of state.keys) {
393
+ const initText = state.inits.get(key) ?? "undefined";
394
+ lines.push(` const [${key}, ${state.setters.get(key)}] = useState(${initText});`);
395
+ }
396
+ for (const method of methods) {
397
+ const rendered = renderMethod(file, method, state);
398
+ if (rendered === null) {
399
+ file.report({
400
+ message: `Left \`${name}\`: method \`${method.name}\` uses an unsupported \`setState\` (multi-key or updater fn).`,
401
+ severity: "warn"
402
+ });
403
+ return null;
404
+ }
405
+ lines.push(rendered);
406
+ }
407
+ if (mount || unmount) {
408
+ hooks.add("useEffect");
409
+ const effect = renderMountEffect(file, mount, unmount, state);
410
+ if (effect === null) {
411
+ file.report({
412
+ message: `Left \`${name}\`: a lifecycle method uses an unsupported \`setState\`.`,
413
+ severity: "warn"
414
+ });
415
+ return null;
416
+ }
417
+ lines.push(effect);
418
+ }
419
+ if (update) {
420
+ hooks.add("useEffect");
421
+ const body = methodBodyText(file, update, state);
422
+ if (body === null) {
423
+ file.report({
424
+ message: `Left \`${name}\`: componentDidUpdate uses an unsupported \`setState\`.`,
425
+ severity: "warn"
426
+ });
427
+ return null;
428
+ }
429
+ lines.push(` // TODO(omnimod): review this effect's dependency array.`);
430
+ lines.push(` useEffect(() => {${body}});`);
431
+ file.report({
432
+ message: `Converted \`${name}\`.componentDidUpdate to a useEffect — review its dependency array.`,
433
+ severity: "info"
434
+ });
435
+ }
436
+ const renderReturn = renderRenderBody(file, render, state);
437
+ if (renderReturn === null) {
438
+ file.report({
439
+ message: `Left \`${name}\`: render() uses an unsupported \`setState\`.`,
440
+ severity: "warn"
441
+ });
442
+ return null;
443
+ }
444
+ lines.push(renderReturn);
445
+ const fnText = `function ${name}(props) {\n${lines.join("\n")}\n}`;
446
+ const classNode = info.node;
447
+ file.magic.update(classNode.start, classNode.end, fnText);
448
+ return { hooks };
449
+ }
450
+ /** Parse `state = { k: v }` into keys, setters and init-expression text. */
451
+ function parseState(file, field) {
452
+ const keys = [];
453
+ const setters = /* @__PURE__ */ new Map();
454
+ const inits = /* @__PURE__ */ new Map();
455
+ if (!field) return {
456
+ keys,
457
+ setters,
458
+ inits
459
+ };
460
+ const prop = cast(field.node);
461
+ if (!prop.value || prop.value.type !== "ObjectExpression") return "bail";
462
+ const obj = cast(prop.value);
463
+ for (const raw of obj.properties) {
464
+ if (raw.type !== "Property") return "bail";
465
+ const property = cast(raw);
466
+ if (property.computed || property.kind !== "init") return "bail";
467
+ if (property.key.type !== "Identifier") return "bail";
468
+ const key = cast(property.key).name;
469
+ keys.push(key);
470
+ setters.set(key, setterName(key));
471
+ inits.set(key, file.source.slice(property.value.start, property.value.end));
472
+ }
473
+ return {
474
+ keys,
475
+ setters,
476
+ inits
477
+ };
478
+ }
479
+ /** The `StateInfo` view expected by the `this.*` rewriter. */
480
+ function stateInfo(state) {
481
+ return {
482
+ keys: state.keys,
483
+ setters: state.setters
484
+ };
485
+ }
486
+ /** Render a plain method as `const foo = (params) => { body };`. */
487
+ function renderMethod(file, member, state) {
488
+ const node = member.node;
489
+ let fn;
490
+ if (node.type === "MethodDefinition") fn = cast(node).value;
491
+ else fn = cast(cast(node).value);
492
+ const params = file.source.slice(paramsStart(file, fn), paramsEnd(file, fn));
493
+ const rewritten = rewriteThis(file.source, fn.body, stateInfo(state));
494
+ if (rewritten.unsupportedSetState) return null;
495
+ return ` const ${member.name} = (${params}) => ${rewritten.text};`;
496
+ }
497
+ /** Get the source span text of a function/method body ({...} → inner text). */
498
+ function methodBodyText(file, member, state) {
499
+ const node = member.node;
500
+ const fn = cast(node).value;
501
+ const block = cast(fn.body);
502
+ const rewritten = rewriteThis(file.source, block, stateInfo(state));
503
+ if (rewritten.unsupportedSetState) return null;
504
+ return rewritten.text.slice(1, -1);
505
+ }
506
+ /** componentDidMount → useEffect(() => { B; [return cleanup;] }, []). */
507
+ function renderMountEffect(file, mount, unmount, state) {
508
+ let mountBody = "";
509
+ if (mount) {
510
+ const body = methodBodyText(file, mount, state);
511
+ if (body === null) return null;
512
+ mountBody = body;
513
+ }
514
+ let cleanup = "";
515
+ if (unmount) {
516
+ const body = methodBodyText(file, unmount, state);
517
+ if (body === null) return null;
518
+ cleanup = `\n return () => {${body}};`;
519
+ }
520
+ return ` useEffect(() => {${mountBody}${cleanup}\n }, []);`;
521
+ }
522
+ /** render() { return X; } → ` return X;` (with `this.*` rewritten). */
523
+ function renderRenderBody(file, render, state) {
524
+ const fn = cast(render.node).value;
525
+ const block = cast(fn.body);
526
+ const rewritten = rewriteThis(file.source, block, stateInfo(state));
527
+ if (rewritten.unsupportedSetState) return null;
528
+ const reindented = reindent(rewritten.text.slice(1, -1), " ");
529
+ return reindented.length > 0 ? reindented : " return null;";
530
+ }
531
+ /**
532
+ * Remove the shared leading indentation from a multi-line block and re-apply
533
+ * `prefix` to each non-blank line. Leading/trailing blank lines are trimmed.
534
+ */
535
+ function reindent(block, prefix) {
536
+ const lines = block.replace(/^\n+/, "").replace(/\s+$/, "").split("\n");
537
+ let common = Infinity;
538
+ for (const line of lines) {
539
+ if (line.trim().length === 0) continue;
540
+ const indent = line.length - line.trimStart().length;
541
+ if (indent < common) common = indent;
542
+ }
543
+ if (!Number.isFinite(common)) common = 0;
544
+ return lines.map((line) => line.trim().length === 0 ? "" : `${prefix}${line.slice(common)}`).join("\n");
545
+ }
546
+ /** Start offset of a function's parameter list (just after the `(`). */
547
+ function paramsStart(file, fn) {
548
+ const params = fn.params;
549
+ if (params.length > 0) return params[0].start;
550
+ return file.source.indexOf("(", fn.start) + 1;
551
+ }
552
+ /** End offset of a function's parameter list (just before the `)`). */
553
+ function paramsEnd(file, fn) {
554
+ const params = fn.params;
555
+ if (params.length > 0) return params[params.length - 1].end;
556
+ return file.source.indexOf("(", fn.start) + 1;
557
+ }
558
+ /**
559
+ * Merge the required hooks into an existing `import ... from "react"` (adding a
560
+ * named clause when needed) or prepend a fresh named import.
561
+ */
562
+ function ensureReactHookImports(file, hooks) {
563
+ const wanted = [...hooks].sort();
564
+ for (const stmt of file.program.body) {
565
+ if (stmt.type !== "ImportDeclaration") continue;
566
+ const imp = cast(stmt);
567
+ if (imp.source.value !== "react") continue;
568
+ const existing = /* @__PURE__ */ new Set();
569
+ let lastNamed = null;
570
+ let hasNamedBlock = false;
571
+ for (const spec of imp.specifiers) if (spec.type === "ImportSpecifier") {
572
+ const s = cast(spec);
573
+ if (s.imported.type === "Identifier") existing.add(cast(s.imported).name);
574
+ lastNamed = spec;
575
+ hasNamedBlock = true;
576
+ }
577
+ const missing = wanted.filter((hook) => !existing.has(hook));
578
+ if (missing.length === 0) return;
579
+ if (hasNamedBlock && lastNamed) file.magic.appendLeft(lastNamed.end, `, ${missing.join(", ")}`);
580
+ else {
581
+ const def = imp.specifiers[0];
582
+ if (def) file.magic.appendLeft(def.end, `, { ${missing.join(", ")} }`);
583
+ else file.magic.appendLeft(imp.source.start - 6, `{ ${missing.join(", ")} } `);
584
+ }
585
+ return;
586
+ }
587
+ file.magic.prepend(`import { ${wanted.join(", ")} } from "react";\n`);
588
+ }
589
+ //#endregion
590
+ export { reactClassToHooks as default, reactClassToHooks };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@omnimod/plugin-react-class-to-hooks",
3
+ "version": "0.1.0",
4
+ "description": "omnimod plugin: migrate simple React class components to function components + hooks.",
5
+ "keywords": [
6
+ "codemod",
7
+ "hooks",
8
+ "migration",
9
+ "omnimod-plugin",
10
+ "react"
11
+ ],
12
+ "homepage": "https://salnika.github.io/omnimod/",
13
+ "bugs": "https://github.com/salnika/omnimod/issues",
14
+ "license": "MIT",
15
+ "author": "salnika",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/salnika/omnimod.git",
19
+ "directory": "packages/plugin-react-class-to-hooks"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "type": "module",
25
+ "exports": {
26
+ ".": "./dist/index.mjs",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "dependencies": {
33
+ "@omnimod/core": "0.1.0",
34
+ "@omnimod/plugin-utils": "0.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^25.6.2",
38
+ "@typescript/native-preview": "7.0.0-dev.20260509.2",
39
+ "typescript": "^6.0.3",
40
+ "vite": "npm:@voidzero-dev/vite-plus-core@0.2.2",
41
+ "vite-plus": "0.2.2"
42
+ },
43
+ "scripts": {
44
+ "build": "vp pack",
45
+ "dev": "vp pack --watch",
46
+ "test": "vp test",
47
+ "check": "vp check"
48
+ }
49
+ }