Skip to content

Commit 8aa9328

Browse files
committed
Add paste as JSON.t
1 parent 567afe5 commit 8aa9328

File tree

5 files changed

+268
-8
lines changed

5 files changed

+268
-8
lines changed

client/src/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export { openCompiled } from "./commands/open_compiled";
1010
export { switchImplIntf } from "./commands/switch_impl_intf";
1111
export { dumpDebug, dumpDebugRetrigger } from "./commands/dump_debug";
1212
export { dumpServerState } from "./commands/dump_server_state";
13+
export { pasteAsRescriptJson } from "./commands/paste_as_rescript_json";
1314

1415
export const codeAnalysisWithReanalyze = (
1516
targetDir: string | null,
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { env, window, Position, Selection, TextDocument } from "vscode";
2+
3+
const INDENT_SIZE = 2;
4+
const INDENT_UNIT = " ".repeat(INDENT_SIZE);
5+
6+
const indent = (level: number) => INDENT_UNIT.repeat(level);
7+
8+
const isLikelyJson = (text: string): boolean => {
9+
const trimmed = text.trim();
10+
if (trimmed.length === 0) {
11+
return false;
12+
}
13+
const first = trimmed[0];
14+
if (first === "{" || first === "[" || first === '"' || first === "-") {
15+
return true;
16+
}
17+
if (first >= "0" && first <= "9") {
18+
return true;
19+
}
20+
if (
21+
trimmed.startsWith("true") ||
22+
trimmed.startsWith("false") ||
23+
trimmed.startsWith("null")
24+
) {
25+
return true;
26+
}
27+
return false;
28+
};
29+
30+
const ensureFloatString = (value: number): string => {
31+
const raw = Number.isFinite(value) ? String(value) : "0";
32+
if (raw.includes(".") || raw.includes("e") || raw.includes("E")) {
33+
return raw;
34+
}
35+
return `${raw}.`;
36+
};
37+
38+
const formatJsonValue = (value: unknown, level = 0): string => {
39+
if (value === null) {
40+
return "JSON.Null";
41+
}
42+
43+
switch (typeof value) {
44+
case "string":
45+
return `JSON.String(${JSON.stringify(value)})`;
46+
case "number":
47+
return `JSON.Number(${ensureFloatString(value)})`;
48+
case "boolean":
49+
return `JSON.Boolean(${value})`;
50+
case "object":
51+
if (Array.isArray(value)) {
52+
return formatArray(value, level);
53+
}
54+
return formatObject(value as Record<string, unknown>, level);
55+
default:
56+
return "JSON.Null";
57+
}
58+
};
59+
60+
const formatObject = (
61+
value: Record<string, unknown>,
62+
level: number,
63+
): string => {
64+
const entries = Object.entries(value);
65+
if (entries.length === 0) {
66+
return "JSON.Object(dict{})";
67+
}
68+
const nextLevel = level + 1;
69+
const lines = entries.map(
70+
([key, val]) =>
71+
`${indent(nextLevel)}${JSON.stringify(key)}: ${formatJsonValue(
72+
val,
73+
nextLevel,
74+
)}`,
75+
);
76+
return `JSON.Object(dict{\n${lines.join(",\n")}\n${indent(level)}})`;
77+
};
78+
79+
const formatArray = (values: unknown[], level: number): string => {
80+
if (values.length === 0) {
81+
return "JSON.Array([])";
82+
}
83+
const nextLevel = level + 1;
84+
const lines = values.map(
85+
(item) => `${indent(nextLevel)}${formatJsonValue(item, nextLevel)}`,
86+
);
87+
return `JSON.Array([\n${lines.join(",\n")}\n${indent(level)}])`;
88+
};
89+
90+
export type JsonConversionResult =
91+
| { kind: "success"; formatted: string }
92+
| { kind: "notJson" }
93+
| { kind: "error"; errorMessage: string };
94+
95+
export const convertPlainTextToJsonT = (text: string): JsonConversionResult => {
96+
if (!isLikelyJson(text)) {
97+
return { kind: "notJson" };
98+
}
99+
100+
try {
101+
const parsed = JSON.parse(text);
102+
return { kind: "success", formatted: formatJsonValue(parsed) };
103+
} catch {
104+
return {
105+
kind: "error",
106+
errorMessage: "Clipboard JSON could not be parsed.",
107+
};
108+
}
109+
};
110+
111+
export const getBaseIndent = (
112+
document: TextDocument,
113+
position: Position,
114+
): string => {
115+
const linePrefix = document
116+
.lineAt(position)
117+
.text.slice(0, position.character);
118+
return /^\s*$/.test(linePrefix) ? linePrefix : "";
119+
};
120+
121+
export const applyBaseIndent = (formatted: string, baseIndent: string) => {
122+
if (baseIndent.length === 0) {
123+
return formatted;
124+
}
125+
126+
return formatted
127+
.split("\n")
128+
.map((line, index) => (index === 0 ? line : `${baseIndent}${line}`))
129+
.join("\n");
130+
};
131+
132+
export const buildInsertionText = (
133+
document: TextDocument,
134+
position: Position,
135+
formatted: string,
136+
) => {
137+
const baseIndent = getBaseIndent(document, position);
138+
return applyBaseIndent(formatted, baseIndent);
139+
};
140+
141+
const computeEndPosition = (
142+
insertionStart: Position,
143+
indentedText: string,
144+
): Position => {
145+
const lines = indentedText.split("\n");
146+
if (lines.length === 1) {
147+
return insertionStart.translate(0, lines[0].length);
148+
}
149+
return new Position(
150+
insertionStart.line + lines.length - 1,
151+
lines[lines.length - 1].length,
152+
);
153+
};
154+
155+
export const pasteAsRescriptJson = async () => {
156+
const editor = window.activeTextEditor;
157+
if (!editor) {
158+
window.showInformationMessage(
159+
"No active editor to paste the ReScript JSON into.",
160+
);
161+
return;
162+
}
163+
164+
const clipboardText = await env.clipboard.readText();
165+
const conversion = convertPlainTextToJsonT(clipboardText);
166+
167+
if (conversion.kind === "notJson") {
168+
window.showInformationMessage("Clipboard does not appear to contain JSON.");
169+
return;
170+
}
171+
172+
if (conversion.kind === "error") {
173+
window.showErrorMessage("Clipboard JSON could not be parsed.");
174+
return;
175+
}
176+
177+
const formatted = conversion.formatted;
178+
const selection = editor.selection;
179+
const indentedText = buildInsertionText(
180+
editor.document,
181+
selection.start,
182+
formatted,
183+
);
184+
const insertionStart = selection.start;
185+
const didEdit = await editor.edit((editBuilder) => {
186+
editBuilder.replace(selection, indentedText);
187+
});
188+
189+
if (didEdit) {
190+
const endPosition = computeEndPosition(insertionStart, indentedText);
191+
editor.selection = new Selection(endPosition, endPosition);
192+
}
193+
};

client/src/extension.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
WorkspaceEdit,
1414
CodeActionKind,
1515
Diagnostic,
16+
DocumentDropOrPasteEditKind,
17+
DocumentPasteEdit,
1618
} from "vscode";
1719
import { ThemeColor } from "vscode";
1820

@@ -29,6 +31,10 @@ import {
2931
DiagnosticsResultCodeActionsMap,
3032
statusBarItem,
3133
} from "./commands/code_analysis";
34+
import {
35+
convertPlainTextToJsonT,
36+
buildInsertionText,
37+
} from "./commands/paste_as_rescript_json";
3238

3339
let client: LanguageClient;
3440

@@ -387,6 +393,65 @@ export function activate(context: ExtensionContext) {
387393
customCommands.dumpDebugRetrigger();
388394
});
389395

396+
const pasteEditKind = DocumentDropOrPasteEditKind.Text.append(
397+
"rescript",
398+
"json",
399+
);
400+
401+
context.subscriptions.push(
402+
languages.registerDocumentPasteEditProvider(
403+
{ language: "rescript" },
404+
{
405+
async provideDocumentPasteEdits(
406+
document,
407+
ranges,
408+
dataTransfer,
409+
_context,
410+
token,
411+
) {
412+
if (token.isCancellationRequested) {
413+
return;
414+
}
415+
416+
const candidateItem =
417+
dataTransfer.get("text/plain") ??
418+
dataTransfer.get("application/json");
419+
if (!candidateItem) {
420+
return;
421+
}
422+
423+
const text = await candidateItem.asString();
424+
const conversion = convertPlainTextToJsonT(text);
425+
if (conversion.kind !== "success") {
426+
return;
427+
}
428+
429+
const targetRange = ranges[0];
430+
if (!targetRange) {
431+
return;
432+
}
433+
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+
);
444+
445+
return [edit];
446+
},
447+
},
448+
{
449+
providedPasteEditKinds: [pasteEditKind],
450+
pasteMimeTypes: ["text/plain", "application/json"],
451+
},
452+
),
453+
);
454+
390455
commands.registerCommand(
391456
"rescript-vscode.go_to_location",
392457
async (fileUri: string, startLine: number, startCol: number) => {

package-lock.json

Lines changed: 7 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"language-server"
2323
],
2424
"engines": {
25-
"vscode": "^1.68.0"
25+
"vscode": "^1.97.0"
2626
},
2727
"activationEvents": [
2828
"onLanguage:rescript"
@@ -273,7 +273,7 @@
273273
"devDependencies": {
274274
"@types/node": "^20.19.13",
275275
"@types/semver": "^7.7.0",
276-
"@types/vscode": "1.68.0",
276+
"@types/vscode": "1.97.0",
277277
"esbuild": "^0.20.1",
278278
"prettier": "^3.6.2",
279279
"typescript": "^5.8.3"

0 commit comments

Comments
 (0)