Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export { openCompiled } from "./commands/open_compiled";
export { switchImplIntf } from "./commands/switch_impl_intf";
export { dumpDebug, dumpDebugRetrigger } from "./commands/dump_debug";
export { dumpServerState } from "./commands/dump_server_state";
export { pasteAsRescriptJson } from "./commands/paste_as_rescript_json";
export { pasteAsRescriptJsx } from "./commands/paste_as_rescript_jsx";

export const codeAnalysisWithReanalyze = (
targetDir: string | null,
Expand Down
193 changes: 193 additions & 0 deletions client/src/commands/paste_as_rescript_json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { env, window, Position, Selection, TextDocument } from "vscode";

const INDENT_SIZE = 2;
const INDENT_UNIT = " ".repeat(INDENT_SIZE);

const indent = (level: number) => INDENT_UNIT.repeat(level);

const isLikelyJson = (text: string): boolean => {
const trimmed = text.trim();
if (trimmed.length === 0) {
return false;
}
const first = trimmed[0];
if (first === "{" || first === "[" || first === '"' || first === "-") {
return true;
}
if (first >= "0" && first <= "9") {
return true;
}
if (
trimmed.startsWith("true") ||
trimmed.startsWith("false") ||
trimmed.startsWith("null")
) {
return true;
}
return false;
};

const ensureFloatString = (value: number): string => {
const raw = Number.isFinite(value) ? String(value) : "0";
if (raw.includes(".") || raw.includes("e") || raw.includes("E")) {
return raw;
}
return `${raw}.`;
};

const formatJsonValue = (value: unknown, level = 0): string => {
if (value === null) {
return "JSON.Null";
}

switch (typeof value) {
case "string":
return `JSON.String(${JSON.stringify(value)})`;
case "number":
return `JSON.Number(${ensureFloatString(value)})`;
case "boolean":
return `JSON.Boolean(${value})`;
case "object":
if (Array.isArray(value)) {
return formatArray(value, level);
}
return formatObject(value as Record<string, unknown>, level);
default:
return "JSON.Null";
}
};

const formatObject = (
value: Record<string, unknown>,
level: number,
): string => {
const entries = Object.entries(value);
if (entries.length === 0) {
return "JSON.Object(dict{})";
}
const nextLevel = level + 1;
const lines = entries.map(
([key, val]) =>
`${indent(nextLevel)}${JSON.stringify(key)}: ${formatJsonValue(
val,
nextLevel,
)}`,
);
return `JSON.Object(dict{\n${lines.join(",\n")}\n${indent(level)}})`;
};

const formatArray = (values: unknown[], level: number): string => {
if (values.length === 0) {
return "JSON.Array([])";
}
const nextLevel = level + 1;
const lines = values.map(
(item) => `${indent(nextLevel)}${formatJsonValue(item, nextLevel)}`,
);
return `JSON.Array([\n${lines.join(",\n")}\n${indent(level)}])`;
};

export type JsonConversionResult =
| { kind: "success"; formatted: string }
| { kind: "notJson" }
| { kind: "error"; errorMessage: string };

export const convertPlainTextToJsonT = (text: string): JsonConversionResult => {
if (!isLikelyJson(text)) {
return { kind: "notJson" };
}

try {
const parsed = JSON.parse(text);
return { kind: "success", formatted: formatJsonValue(parsed) };
} catch {
return {
kind: "error",
errorMessage: "Clipboard JSON could not be parsed.",
};
}
};

export const getBaseIndent = (
document: TextDocument,
position: Position,
): string => {
const linePrefix = document
.lineAt(position)
.text.slice(0, position.character);
return /^\s*$/.test(linePrefix) ? linePrefix : "";
};

export const applyBaseIndent = (formatted: string, baseIndent: string) => {
if (baseIndent.length === 0) {
return formatted;
}

return formatted
.split("\n")
.map((line, index) => (index === 0 ? line : `${baseIndent}${line}`))
.join("\n");
};

export const buildInsertionText = (
document: TextDocument,
position: Position,
formatted: string,
) => {
const baseIndent = getBaseIndent(document, position);
return applyBaseIndent(formatted, baseIndent);
};

const computeEndPosition = (
insertionStart: Position,
indentedText: string,
): Position => {
const lines = indentedText.split("\n");
if (lines.length === 1) {
return insertionStart.translate(0, lines[0].length);
}
return new Position(
insertionStart.line + lines.length - 1,
lines[lines.length - 1].length,
);
};

export const pasteAsRescriptJson = async () => {
const editor = window.activeTextEditor;
if (!editor) {
window.showInformationMessage(
"No active editor to paste the ReScript JSON into.",
);
return;
}

const clipboardText = await env.clipboard.readText();
const conversion = convertPlainTextToJsonT(clipboardText);

if (conversion.kind === "notJson") {
window.showInformationMessage("Clipboard does not appear to contain JSON.");
return;
}

if (conversion.kind === "error") {
window.showErrorMessage("Clipboard JSON could not be parsed.");
return;
}

const formatted = conversion.formatted;
const selection = editor.selection;
const indentedText = buildInsertionText(
editor.document,
selection.start,
formatted,
);
const insertionStart = selection.start;
const didEdit = await editor.edit((editBuilder) => {
editBuilder.replace(selection, indentedText);
});

if (didEdit) {
const endPosition = computeEndPosition(insertionStart, indentedText);
editor.selection = new Selection(endPosition, endPosition);
}
};
87 changes: 87 additions & 0 deletions client/src/commands/paste_as_rescript_jsx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { env, window, Position, Selection } from "vscode";

import { buildInsertionText } from "./paste_as_rescript_json";
import { transformJsx } from "./transform-jsx";

export type JsxConversionResult =
| { kind: "success"; formatted: string }
| { kind: "empty" }
| { kind: "error"; errorMessage: string };

export const convertPlainTextToRescriptJsx = (
text: string,
): JsxConversionResult => {
if (text.trim().length === 0) {
return { kind: "empty" };
}

try {
const formatted = transformJsx(text);
return { kind: "success", formatted };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown conversion error.";
return {
kind: "error",
errorMessage,
};
}
};

const computeEndPosition = (
insertionStart: Position,
indentedText: string,
): Position => {
const lines = indentedText.split("\n");
if (lines.length === 1) {
return insertionStart.translate(0, lines[0].length);
}
return new Position(
insertionStart.line + lines.length - 1,
lines[lines.length - 1].length,
);
};

export const pasteAsRescriptJsx = async () => {
const editor = window.activeTextEditor;
if (!editor) {
window.showInformationMessage(
"No active editor to paste the ReScript JSX into.",
);
return;
}

const clipboardText = await env.clipboard.readText();
const conversion = convertPlainTextToRescriptJsx(clipboardText);

if (conversion.kind === "empty") {
window.showInformationMessage(
"Clipboard does not appear to contain any JSX content.",
);
return;
}

if (conversion.kind === "error") {
window.showErrorMessage(
`Clipboard JSX could not be transformed: ${conversion.errorMessage}`,
);
return;
}

const formatted = conversion.formatted;
const selection = editor.selection;
const indentedText = buildInsertionText(
editor.document,
selection.start,
formatted,
);
const insertionStart = selection.start;
const didEdit = await editor.edit((editBuilder) => {
editBuilder.replace(selection, indentedText);
});

if (didEdit) {
const endPosition = computeEndPosition(insertionStart, indentedText);
editor.selection = new Selection(endPosition, endPosition);
}
};
71 changes: 71 additions & 0 deletions client/src/commands/transform-jsx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { parseSync, type Node } from "oxc-parser";
import { walk } from "oxc-walker";
import MagicString from "magic-string";

const integerRegex = /^-?\d+$/;
const floatRegex = /^-?\d+(\.\d+)?$/;

const rescriptKeywords = new Set(["type", "open", "as", "in"]);

export function transformJsx(input: string): string {
const magicString = new MagicString(input);
const parseResult = parseSync("clipboard-input.tsx", input, {
astType: "ts",
lang: "tsx",
});

walk(parseResult.program, {
enter: (node: Node) => {
if (node.type === "JSXAttribute") {
if (node.value?.type === "Literal" && node.value.raw?.startsWith("'")) {
magicString.update(
node.value.start,
node.value.end,
`"${node.value.raw.slice(1, -1)}"`,
);
}

if (
typeof node.name.name === "string" &&
rescriptKeywords.has(node.name.name)
) {
magicString.appendRight(node.name.end, "_");
}

if (
typeof node.name.name === "string" &&
node.name.name.startsWith("aria-")
) {
magicString.update(
node.name.start + 4,
node.name.start + 6,
node.name.name[5]?.toUpperCase() || "",
);
}

if (node.name.name === "data-testid") {
magicString.update(node.name.start, node.name.end, "dataTestId");
}

if (node.value === null) {
magicString.appendRight(node.end, "=true");
}
} else if (node.type === "JSXText") {
if (node.raw && integerRegex.test(node.raw.trim())) {
magicString.prependLeft(node.start, "{React.int(");
magicString.appendRight(node.end, ")}");
} else if (node.raw && floatRegex.test(node.raw.trim())) {
magicString.prependLeft(node.start, "{React.float(");
magicString.appendRight(node.end, ")}");
} else if (node.value.trim()) {
magicString.prependLeft(node.start, '{React.string("');
magicString.appendRight(node.end, '")}');
}
}
},
});

return magicString.toString();
}

export default transformJsx;
Loading