Skip to content

Commit 2af5ab0

Browse files
committed
Paste as React JSX as ReScript JSX
1 parent 8aa9328 commit 2af5ab0

File tree

6 files changed

+748
-18
lines changed

6 files changed

+748
-18
lines changed

client/src/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export { switchImplIntf } from "./commands/switch_impl_intf";
1111
export { dumpDebug, dumpDebugRetrigger } from "./commands/dump_debug";
1212
export { dumpServerState } from "./commands/dump_server_state";
1313
export { pasteAsRescriptJson } from "./commands/paste_as_rescript_json";
14+
export { pasteAsRescriptJsx } from "./commands/paste_as_rescript_jsx";
1415

1516
export const codeAnalysisWithReanalyze = (
1617
targetDir: string | null,
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { env, window, Position, Selection } from "vscode";
2+
3+
import { buildInsertionText } from "./paste_as_rescript_json";
4+
import { transformJsx } from "./transform-jsx";
5+
6+
export type JsxConversionResult =
7+
| { kind: "success"; formatted: string }
8+
| { kind: "empty" }
9+
| { kind: "error"; errorMessage: string };
10+
11+
export const convertPlainTextToRescriptJsx = (
12+
text: string,
13+
): JsxConversionResult => {
14+
if (text.trim().length === 0) {
15+
return { kind: "empty" };
16+
}
17+
18+
try {
19+
const formatted = transformJsx(text);
20+
return { kind: "success", formatted };
21+
} catch (error) {
22+
const errorMessage =
23+
error instanceof Error ? error.message : "Unknown conversion error.";
24+
return {
25+
kind: "error",
26+
errorMessage,
27+
};
28+
}
29+
};
30+
31+
const computeEndPosition = (
32+
insertionStart: Position,
33+
indentedText: string,
34+
): Position => {
35+
const lines = indentedText.split("\n");
36+
if (lines.length === 1) {
37+
return insertionStart.translate(0, lines[0].length);
38+
}
39+
return new Position(
40+
insertionStart.line + lines.length - 1,
41+
lines[lines.length - 1].length,
42+
);
43+
};
44+
45+
export const pasteAsRescriptJsx = async () => {
46+
const editor = window.activeTextEditor;
47+
if (!editor) {
48+
window.showInformationMessage(
49+
"No active editor to paste the ReScript JSX into.",
50+
);
51+
return;
52+
}
53+
54+
const clipboardText = await env.clipboard.readText();
55+
const conversion = convertPlainTextToRescriptJsx(clipboardText);
56+
57+
if (conversion.kind === "empty") {
58+
window.showInformationMessage(
59+
"Clipboard does not appear to contain any JSX content.",
60+
);
61+
return;
62+
}
63+
64+
if (conversion.kind === "error") {
65+
window.showErrorMessage(
66+
`Clipboard JSX could not be transformed: ${conversion.errorMessage}`,
67+
);
68+
return;
69+
}
70+
71+
const formatted = conversion.formatted;
72+
const selection = editor.selection;
73+
const indentedText = buildInsertionText(
74+
editor.document,
75+
selection.start,
76+
formatted,
77+
);
78+
const insertionStart = selection.start;
79+
const didEdit = await editor.edit((editBuilder) => {
80+
editBuilder.replace(selection, indentedText);
81+
});
82+
83+
if (didEdit) {
84+
const endPosition = computeEndPosition(insertionStart, indentedText);
85+
editor.selection = new Selection(endPosition, endPosition);
86+
}
87+
};
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { parseSync, type Node } from "oxc-parser";
2+
import { walk } from "oxc-walker";
3+
import MagicString from "magic-string";
4+
5+
const integerRegex = /^-?\d+$/;
6+
const floatRegex = /^-?\d+(\.\d+)?$/;
7+
8+
const rescriptKeywords = new Set(["type", "open", "as", "in"]);
9+
10+
export function transformJsx(input: string): string {
11+
const magicString = new MagicString(input);
12+
const parseResult = parseSync("clipboard-input.tsx", input, {
13+
astType: "ts",
14+
lang: "tsx",
15+
});
16+
17+
walk(parseResult.program, {
18+
enter: (node: Node) => {
19+
if (node.type === "JSXAttribute") {
20+
if (node.value?.type === "Literal" && node.value.raw?.startsWith("'")) {
21+
magicString.update(
22+
node.value.start,
23+
node.value.end,
24+
`"${node.value.raw.slice(1, -1)}"`,
25+
);
26+
}
27+
28+
if (
29+
typeof node.name.name === "string" &&
30+
rescriptKeywords.has(node.name.name)
31+
) {
32+
magicString.appendRight(node.name.end, "_");
33+
}
34+
35+
if (
36+
typeof node.name.name === "string" &&
37+
node.name.name.startsWith("aria-")
38+
) {
39+
magicString.update(
40+
node.name.start + 4,
41+
node.name.start + 6,
42+
node.name.name[5]?.toUpperCase() || "",
43+
);
44+
}
45+
46+
if (node.name.name === "data-testid") {
47+
magicString.update(node.name.start, node.name.end, "dataTestId");
48+
}
49+
50+
if (node.value === null) {
51+
magicString.appendRight(node.end, "=true");
52+
}
53+
} else if (node.type === "JSXText") {
54+
if (node.raw && integerRegex.test(node.raw.trim())) {
55+
magicString.prependLeft(node.start, "{React.int(");
56+
magicString.appendRight(node.end, ")}");
57+
} else if (node.raw && floatRegex.test(node.raw.trim())) {
58+
magicString.prependLeft(node.start, "{React.float(");
59+
magicString.appendRight(node.end, ")}");
60+
} else if (node.value.trim()) {
61+
magicString.prependLeft(node.start, '{React.string("');
62+
magicString.appendRight(node.end, '")}');
63+
}
64+
}
65+
},
66+
});
67+
68+
return magicString.toString();
69+
}
70+
71+
export default transformJsx;

client/src/extension.ts

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
convertPlainTextToJsonT,
3636
buildInsertionText,
3737
} from "./commands/paste_as_rescript_json";
38+
import { convertPlainTextToRescriptJsx } from "./commands/paste_as_rescript_jsx";
3839

3940
let client: LanguageClient;
4041

@@ -393,10 +394,14 @@ export function activate(context: ExtensionContext) {
393394
customCommands.dumpDebugRetrigger();
394395
});
395396

396-
const pasteEditKind = DocumentDropOrPasteEditKind.Text.append(
397+
const pasteJsonEditKind = DocumentDropOrPasteEditKind.Text.append(
397398
"rescript",
398399
"json",
399400
);
401+
const pasteJsxEditKind = DocumentDropOrPasteEditKind.Text.append(
402+
"rescript",
403+
"jsx",
404+
);
400405

401406
context.subscriptions.push(
402407
languages.registerDocumentPasteEditProvider(
@@ -421,32 +426,54 @@ export function activate(context: ExtensionContext) {
421426
}
422427

423428
const text = await candidateItem.asString();
424-
const conversion = convertPlainTextToJsonT(text);
425-
if (conversion.kind !== "success") {
426-
return;
427-
}
428-
429429
const targetRange = ranges[0];
430430
if (!targetRange) {
431431
return;
432432
}
433433

434-
const insertText = buildInsertionText(
435-
document,
436-
targetRange.start,
437-
conversion.formatted,
438-
);
439-
const edit = new DocumentPasteEdit(
440-
insertText,
441-
"Paste as ReScript JSON.t",
442-
pasteEditKind,
443-
);
434+
const edits: DocumentPasteEdit[] = [];
435+
436+
const jsonConversion = convertPlainTextToJsonT(text);
437+
if (jsonConversion.kind === "success") {
438+
const insertText = buildInsertionText(
439+
document,
440+
targetRange.start,
441+
jsonConversion.formatted,
442+
);
443+
edits.push(
444+
new DocumentPasteEdit(
445+
insertText,
446+
"Paste as ReScript JSON.t",
447+
pasteJsonEditKind,
448+
),
449+
);
450+
}
451+
452+
const jsxConversion = convertPlainTextToRescriptJsx(text);
453+
if (jsxConversion.kind === "success") {
454+
const insertText = buildInsertionText(
455+
document,
456+
targetRange.start,
457+
jsxConversion.formatted,
458+
);
459+
edits.push(
460+
new DocumentPasteEdit(
461+
insertText,
462+
"Paste as ReScript JSX",
463+
pasteJsxEditKind,
464+
),
465+
);
466+
}
467+
468+
if (edits.length === 0) {
469+
return;
470+
}
444471

445-
return [edit];
472+
return edits;
446473
},
447474
},
448475
{
449-
providedPasteEditKinds: [pasteEditKind],
476+
providedPasteEditKinds: [pasteJsonEditKind, pasteJsxEditKind],
450477
pasteMimeTypes: ["text/plain", "application/json"],
451478
},
452479
),

0 commit comments

Comments
 (0)