Skip to content

Commit c5e1f50

Browse files
authored
Merge pull request #27 from levelcodeai/feat/json-beautify-on-paste
JSON beautify on paste
2 parents c233545 + d95a40f commit c5e1f50

5 files changed

Lines changed: 243 additions & 0 deletions

File tree

extensions/levelcode-npp-pack/extension.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const { registerLineOps } = require('./lineOps');
2323
const { registerColumnOps } = require('./columnOps');
2424
const { registerEncodingEol } = require('./encodingEol');
2525
const { registerBigFile } = require('./bigFile');
26+
const { registerJsonPaste } = require('./jsonPaste');
2627

2728
const CTX_RECORDING = 'levelcode.macroRecording';
2829
const KEY_LAST = 'levelcode.macros.last';
@@ -281,6 +282,9 @@ function activate(context) {
281282
// Big-file mode (status badge + notice)
282283
registerBigFile(context);
283284

285+
// Beautify JSON on paste (any buffer, incl. untitled scratch tabs)
286+
registerJsonPaste(context);
287+
284288
// Make sure recording context starts false (also clears a stale state after reload).
285289
setRecordingContext(false);
286290
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* LevelCode — Notepad++ Pack
3+
* Feature: JSON beautify-on-paste — the PURE core (no vscode import, unit-tested).
4+
*
5+
* Decides whether a pasted string is beautifiable JSON and produces the pretty-printed text. The
6+
* vscode paste-provider glue that calls this lives in jsonPaste.js; keeping the logic here means it
7+
* runs under plain `node` in test/jsonBeautify.test.js.
8+
*--------------------------------------------------------------------------------------------*/
9+
// @ts-check
10+
'use strict';
11+
12+
// Don't parse a paste larger than this — JSON.parse + stringify on a huge blob would block the
13+
// extension host mid-paste. 5 MB is far past any hand-pasted JSON; bigger pastes just paste normally.
14+
// Measured in real UTF-8 BYTES (Buffer.byteLength), NOT String#length — a code-unit count undercounts
15+
// multibyte content (a CJK char is 1 code unit but 3 bytes), which would let a much bigger payload through.
16+
const MAX_BYTES = 5 * 1024 * 1024;
17+
18+
/**
19+
* Decide whether a pasted string should be beautified, and to what.
20+
*
21+
* Fires ONLY when the WHOLE trimmed paste is a single valid JSON *container* (object or array):
22+
* - a bare scalar ("5", "\"hi\"", "true", "null") is valid JSON but must never be transformed;
23+
* - anything with trailing junk after the JSON (`{"a":1} x`) fails the parse and is left alone;
24+
* - text that is already exactly the canonical output is skipped (idempotent — so re-pasting, or
25+
* pasting output we'd produce, doesn't fight a normal paste).
26+
* Everything that isn't "yes, beautify" returns `beautify:false` so the caller falls through to a
27+
* plain paste.
28+
*
29+
* @param {unknown} text the pasted text
30+
* @param {{indent?: number|string, maxBytes?: number}} [opts] indent for JSON.stringify (space count
31+
* or a literal like '\t'; default 2); maxBytes overrides the size guard (for tests)
32+
* @returns {{beautify: boolean, output?: string, reason: string}}
33+
*/
34+
function analyzePaste(text, opts) {
35+
if (typeof text !== 'string') { return { beautify: false, reason: 'not-string' }; }
36+
const trimmed = text.trim();
37+
// Cheap pre-filter: a JSON container starts with { or [. This skips the parse for ~every paste that
38+
// isn't JSON, and rules out bare scalars without parsing them.
39+
if (trimmed.length < 2 || (trimmed[0] !== '{' && trimmed[0] !== '[')) { return { beautify: false, reason: 'not-container' }; }
40+
const maxBytes = (opts && typeof opts.maxBytes === 'number') ? opts.maxBytes : MAX_BYTES;
41+
if (Buffer.byteLength(trimmed, 'utf8') > maxBytes) { return { beautify: false, reason: 'too-large' }; }
42+
let parsed;
43+
try { parsed = JSON.parse(trimmed); }
44+
catch { return { beautify: false, reason: 'invalid-json' }; }
45+
// Only { and [ reach here, so parsed is already an object/array — but be explicit rather than assume.
46+
if (parsed === null || typeof parsed !== 'object') { return { beautify: false, reason: 'not-container' }; }
47+
const indent = (opts && opts.indent != null) ? opts.indent : 2;
48+
const output = JSON.stringify(parsed, null, indent);
49+
if (output === trimmed) { return { beautify: false, reason: 'already-formatted' }; }
50+
return { beautify: true, output, reason: 'ok' };
51+
}
52+
53+
module.exports = { analyzePaste, MAX_BYTES };
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* LevelCode — Notepad++ Pack
3+
* Feature: JSON beautify-on-paste.
4+
*
5+
* Paste JSON — minified, escaped, one giant line — into ANY editor buffer (a .json file, a .txt, an
6+
* untitled scratch tab) and it lands pretty-printed. Implemented as a DocumentPasteEditProvider for
7+
* text/plain: when the whole pasted blob is valid JSON (see jsonBeautify.analyzePaste), the paste is
8+
* replaced with the formatted text; every other paste falls through untouched.
9+
*
10+
* Why a paste provider and not `editor.formatOnPaste`: formatOnPaste only fires in a buffer the editor
11+
* already knows is JSON. This works regardless of the buffer's language — the common "paste a blob
12+
* into a scratch tab" case — which was the whole point.
13+
*
14+
* The kind is `text.json.beautify` (nested under the generic `text` paste), so on a normal paste the
15+
* editor prefers this more-specific edit and applies it automatically, offering a small paste-options
16+
* affordance to switch back to a plain paste. Toggle the whole feature with `levelcode.jsonPaste.enabled`.
17+
*--------------------------------------------------------------------------------------------*/
18+
// @ts-check
19+
'use strict';
20+
21+
const vscode = require('vscode');
22+
const { analyzePaste } = require('./jsonBeautify');
23+
24+
function registerJsonPaste(context) {
25+
const kind = vscode.DocumentDropOrPasteEditKind.Empty.append('text', 'json', 'beautify');
26+
27+
/** @type {vscode.DocumentPasteEditProvider} */
28+
const provider = {
29+
async provideDocumentPasteEdits(document, _ranges, dataTransfer, _ctx, token) {
30+
const cfg = vscode.workspace.getConfiguration('levelcode.jsonPaste', document);
31+
if (!cfg.get('enabled', true)) { return undefined; }
32+
33+
const item = dataTransfer.get('text/plain');
34+
if (!item) { return undefined; }
35+
const text = await item.asString();
36+
if (token.isCancellationRequested) { return undefined; }
37+
38+
const res = analyzePaste(text, { indent: resolveIndent(cfg, document) });
39+
if (!res.beautify || res.output == null) { return undefined; }
40+
41+
return [new vscode.DocumentPasteEdit(res.output, 'Beautify JSON', kind)];
42+
}
43+
};
44+
45+
context.subscriptions.push(vscode.languages.registerDocumentPasteEditProvider(
46+
'*', // any language / scheme — files AND untitled scratch buffers
47+
provider,
48+
{ providedPasteEditKinds: [kind], pasteMimeTypes: ['text/plain'] }
49+
));
50+
}
51+
52+
/**
53+
* Indent for the pretty-print. `levelcode.jsonPaste.indent`: a number (spaces), "tab", or "editor"
54+
* (default) → follow the buffer's own tabSize / insertSpaces so beautified JSON matches its surroundings.
55+
*/
56+
function resolveIndent(cfg, document) {
57+
const setting = cfg.get('indent', 'editor');
58+
if (setting === 'tab') { return '\t'; }
59+
if (typeof setting === 'number' && setting > 0) { return Math.min(setting, 8); }
60+
const ed = vscode.workspace.getConfiguration('editor', document);
61+
return ed.get('insertSpaces', true) ? (ed.get('tabSize', 2) || 2) : '\t';
62+
}
63+
64+
module.exports = { registerJsonPaste };

extensions/levelcode-npp-pack/package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,25 @@
397397
"type": "boolean",
398398
"default": true,
399399
"description": "Show a one-time notification when a file opens in Big-file mode."
400+
},
401+
"levelcode.jsonPaste.enabled": {
402+
"type": "boolean",
403+
"default": true,
404+
"description": "Beautify JSON on paste. When the whole pasted text is valid JSON, it is pretty-printed in place — in any buffer, including untitled scratch tabs. Non-JSON pastes are unaffected."
405+
},
406+
"levelcode.jsonPaste.indent": {
407+
"type": [
408+
"string",
409+
"number"
410+
],
411+
"default": "editor",
412+
"markdownDescription": "Indentation for JSON beautified on paste. `\"editor\"` follows the buffer's own `editor.tabSize` / `editor.insertSpaces`; a number sets that many spaces; `\"tab\"` uses a tab.",
413+
"examples": [
414+
"editor",
415+
2,
416+
4,
417+
"tab"
418+
]
400419
}
401420
}
402421
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Unit tests for extensions/levelcode-npp-pack/jsonBeautify.js — run: node test/jsonBeautify.test.js
3+
*--------------------------------------------------------------------------------------------*/
4+
// @ts-check
5+
'use strict';
6+
7+
const assert = require('assert');
8+
const { analyzePaste, MAX_BYTES } = require('../jsonBeautify');
9+
10+
let n = 0;
11+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
12+
13+
test('beautifies a minified object, and the result parses back to the same value', () => {
14+
const r = analyzePaste('{"a":1,"b":[2,3],"c":{"d":true}}');
15+
assert.strictEqual(r.beautify, true);
16+
assert.ok(r.output.includes('\n'), 'output should be multi-line');
17+
assert.deepStrictEqual(JSON.parse(r.output), { a: 1, b: [2, 3], c: { d: true } });
18+
assert.strictEqual(r.output, '{\n "a": 1,\n "b": [\n 2,\n 3\n ],\n "c": {\n "d": true\n }\n}');
19+
});
20+
21+
test('beautifies a minified array', () => {
22+
assert.strictEqual(analyzePaste('[1,{"x":2}]').beautify, true);
23+
});
24+
25+
test('leaves already-formatted (canonical 2-space) JSON alone — idempotent', () => {
26+
const once = analyzePaste('{"a":1,"b":2}').output;
27+
const twice = analyzePaste(once);
28+
assert.strictEqual(twice.beautify, false);
29+
assert.strictEqual(twice.reason, 'already-formatted');
30+
});
31+
32+
test('ignores bare JSON scalars — a pasted number/string/bool must NOT be transformed', () => {
33+
for (const s of ['5', '3.14', '"hello"', 'true', 'false', 'null']) {
34+
assert.strictEqual(analyzePaste(s).beautify, false, s);
35+
}
36+
});
37+
38+
test('ignores invalid JSON (unquoted key, trailing comma, JSONC)', () => {
39+
for (const s of ['{a:1}', '{"a":1,}', '{"a":1 /* c */}']) {
40+
const r = analyzePaste(s);
41+
assert.strictEqual(r.beautify, false, s);
42+
assert.strictEqual(r.reason, 'invalid-json', s);
43+
}
44+
});
45+
46+
test('ignores a JSON value with trailing junk (whole blob must be JSON)', () => {
47+
const r = analyzePaste('{"a":1} and then some prose');
48+
assert.strictEqual(r.beautify, false);
49+
assert.strictEqual(r.reason, 'invalid-json');
50+
});
51+
52+
test('ignores ordinary prose / code that is not JSON', () => {
53+
for (const s of ['hello world', 'const x = 1;', 'function f(){}', '']) {
54+
assert.strictEqual(analyzePaste(s).beautify, false, JSON.stringify(s));
55+
}
56+
});
57+
58+
test('trims surrounding whitespace and beautifies the inner JSON', () => {
59+
const r = analyzePaste('\n\t {"a":1} \n');
60+
assert.strictEqual(r.beautify, true);
61+
assert.strictEqual(r.output, '{\n "a": 1\n}');
62+
});
63+
64+
test('respects a numeric indent and a tab indent', () => {
65+
assert.strictEqual(analyzePaste('{"a":1}', { indent: 4 }).output, '{\n "a": 1\n}');
66+
assert.strictEqual(analyzePaste('{"a":1}', { indent: '\t' }).output, '{\n\t"a": 1\n}');
67+
});
68+
69+
test('empty object / empty array are a no-op (already canonical)', () => {
70+
assert.strictEqual(analyzePaste('{}').beautify, false);
71+
assert.strictEqual(analyzePaste('[]').beautify, false);
72+
});
73+
74+
test('rejects non-string input without throwing', () => {
75+
// @ts-expect-error — deliberately wrong types
76+
assert.strictEqual(analyzePaste(null).beautify, false);
77+
// @ts-expect-error
78+
assert.strictEqual(analyzePaste(42).beautify, false);
79+
// @ts-expect-error
80+
assert.strictEqual(analyzePaste(undefined).beautify, false);
81+
});
82+
83+
test('honors the size guard (parses nothing past maxBytes)', () => {
84+
const r = analyzePaste('[1,2,3,4,5]', { maxBytes: 4 });
85+
assert.strictEqual(r.beautify, false);
86+
assert.strictEqual(r.reason, 'too-large');
87+
assert.ok(MAX_BYTES >= 1024 * 1024, 'default cap should be sizeable');
88+
});
89+
90+
test('size guard counts UTF-8 BYTES, not code units — multibyte payloads cannot slip past', () => {
91+
// A CJK char is 1 UTF-16 code unit but 3 UTF-8 bytes. Build JSON whose .length is UNDER the cap but
92+
// whose byte size is OVER it — a String#length guard would wrongly allow it through and parse it.
93+
const json = '{"k":"' + '実'.repeat(50) + '"}';
94+
assert.ok(json.length < 100, 'precondition: under cap by code units');
95+
assert.ok(Buffer.byteLength(json, 'utf8') > 100, 'precondition: over cap by bytes');
96+
const r = analyzePaste(json, { maxBytes: 100 });
97+
assert.strictEqual(r.beautify, false);
98+
assert.strictEqual(r.reason, 'too-large');
99+
// Sanity: the SAME payload beautifies when the cap is raised above its byte size.
100+
assert.strictEqual(analyzePaste(json, { maxBytes: 1000 }).beautify, true);
101+
});
102+
103+
console.log('\njsonBeautify.js: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)