Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions extensions/levelcode-npp-pack/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const { registerLineOps } = require('./lineOps');
const { registerColumnOps } = require('./columnOps');
const { registerEncodingEol } = require('./encodingEol');
const { registerBigFile } = require('./bigFile');
const { registerJsonPaste } = require('./jsonPaste');

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

// Beautify JSON on paste (any buffer, incl. untitled scratch tabs)
registerJsonPaste(context);

// Make sure recording context starts false (also clears a stale state after reload).
setRecordingContext(false);
}
Expand Down
53 changes: 53 additions & 0 deletions extensions/levelcode-npp-pack/jsonBeautify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*---------------------------------------------------------------------------------------------
* LevelCode — Notepad++ Pack
* Feature: JSON beautify-on-paste — the PURE core (no vscode import, unit-tested).
*
* Decides whether a pasted string is beautifiable JSON and produces the pretty-printed text. The
* vscode paste-provider glue that calls this lives in jsonPaste.js; keeping the logic here means it
* runs under plain `node` in test/jsonBeautify.test.js.
*--------------------------------------------------------------------------------------------*/
// @ts-check
'use strict';

// Don't parse a paste larger than this — JSON.parse + stringify on a huge blob would block the
// extension host mid-paste. 5 MB is far past any hand-pasted JSON; bigger pastes just paste normally.
// Measured in real UTF-8 BYTES (Buffer.byteLength), NOT String#length — a code-unit count undercounts
// multibyte content (a CJK char is 1 code unit but 3 bytes), which would let a much bigger payload through.
const MAX_BYTES = 5 * 1024 * 1024;

/**
* Decide whether a pasted string should be beautified, and to what.
*
* Fires ONLY when the WHOLE trimmed paste is a single valid JSON *container* (object or array):
* - a bare scalar ("5", "\"hi\"", "true", "null") is valid JSON but must never be transformed;
* - anything with trailing junk after the JSON (`{"a":1} x`) fails the parse and is left alone;
* - text that is already exactly the canonical output is skipped (idempotent — so re-pasting, or
* pasting output we'd produce, doesn't fight a normal paste).
* Everything that isn't "yes, beautify" returns `beautify:false` so the caller falls through to a
* plain paste.
*
* @param {unknown} text the pasted text
* @param {{indent?: number|string, maxBytes?: number}} [opts] indent for JSON.stringify (space count
* or a literal like '\t'; default 2); maxBytes overrides the size guard (for tests)
* @returns {{beautify: boolean, output?: string, reason: string}}
*/
function analyzePaste(text, opts) {
if (typeof text !== 'string') { return { beautify: false, reason: 'not-string' }; }
const trimmed = text.trim();
// Cheap pre-filter: a JSON container starts with { or [. This skips the parse for ~every paste that
// isn't JSON, and rules out bare scalars without parsing them.
if (trimmed.length < 2 || (trimmed[0] !== '{' && trimmed[0] !== '[')) { return { beautify: false, reason: 'not-container' }; }
const maxBytes = (opts && typeof opts.maxBytes === 'number') ? opts.maxBytes : MAX_BYTES;
if (Buffer.byteLength(trimmed, 'utf8') > maxBytes) { return { beautify: false, reason: 'too-large' }; }
let parsed;
try { parsed = JSON.parse(trimmed); }
catch { return { beautify: false, reason: 'invalid-json' }; }
// Only { and [ reach here, so parsed is already an object/array — but be explicit rather than assume.
if (parsed === null || typeof parsed !== 'object') { return { beautify: false, reason: 'not-container' }; }
const indent = (opts && opts.indent != null) ? opts.indent : 2;
const output = JSON.stringify(parsed, null, indent);
if (output === trimmed) { return { beautify: false, reason: 'already-formatted' }; }
return { beautify: true, output, reason: 'ok' };
}

module.exports = { analyzePaste, MAX_BYTES };
64 changes: 64 additions & 0 deletions extensions/levelcode-npp-pack/jsonPaste.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*---------------------------------------------------------------------------------------------
* LevelCode — Notepad++ Pack
* Feature: JSON beautify-on-paste.
*
* Paste JSON — minified, escaped, one giant line — into ANY editor buffer (a .json file, a .txt, an
* untitled scratch tab) and it lands pretty-printed. Implemented as a DocumentPasteEditProvider for
* text/plain: when the whole pasted blob is valid JSON (see jsonBeautify.analyzePaste), the paste is
* replaced with the formatted text; every other paste falls through untouched.
*
* Why a paste provider and not `editor.formatOnPaste`: formatOnPaste only fires in a buffer the editor
* already knows is JSON. This works regardless of the buffer's language — the common "paste a blob
* into a scratch tab" case — which was the whole point.
*
* The kind is `text.json.beautify` (nested under the generic `text` paste), so on a normal paste the
* editor prefers this more-specific edit and applies it automatically, offering a small paste-options
* affordance to switch back to a plain paste. Toggle the whole feature with `levelcode.jsonPaste.enabled`.
*--------------------------------------------------------------------------------------------*/
// @ts-check
'use strict';

const vscode = require('vscode');
const { analyzePaste } = require('./jsonBeautify');

function registerJsonPaste(context) {
const kind = vscode.DocumentDropOrPasteEditKind.Empty.append('text', 'json', 'beautify');

/** @type {vscode.DocumentPasteEditProvider} */
const provider = {
async provideDocumentPasteEdits(document, _ranges, dataTransfer, _ctx, token) {
const cfg = vscode.workspace.getConfiguration('levelcode.jsonPaste', document);
if (!cfg.get('enabled', true)) { return undefined; }

const item = dataTransfer.get('text/plain');
if (!item) { return undefined; }
const text = await item.asString();
if (token.isCancellationRequested) { return undefined; }

const res = analyzePaste(text, { indent: resolveIndent(cfg, document) });
if (!res.beautify || res.output == null) { return undefined; }

return [new vscode.DocumentPasteEdit(res.output, 'Beautify JSON', kind)];
}
};

context.subscriptions.push(vscode.languages.registerDocumentPasteEditProvider(
'*', // any language / scheme — files AND untitled scratch buffers
provider,
{ providedPasteEditKinds: [kind], pasteMimeTypes: ['text/plain'] }
));
}

/**
* Indent for the pretty-print. `levelcode.jsonPaste.indent`: a number (spaces), "tab", or "editor"
* (default) → follow the buffer's own tabSize / insertSpaces so beautified JSON matches its surroundings.
*/
function resolveIndent(cfg, document) {
const setting = cfg.get('indent', 'editor');
if (setting === 'tab') { return '\t'; }
if (typeof setting === 'number' && setting > 0) { return Math.min(setting, 8); }
const ed = vscode.workspace.getConfiguration('editor', document);
return ed.get('insertSpaces', true) ? (ed.get('tabSize', 2) || 2) : '\t';
}

module.exports = { registerJsonPaste };
19 changes: 19 additions & 0 deletions extensions/levelcode-npp-pack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,25 @@
"type": "boolean",
"default": true,
"description": "Show a one-time notification when a file opens in Big-file mode."
},
"levelcode.jsonPaste.enabled": {
"type": "boolean",
"default": true,
"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."
},
"levelcode.jsonPaste.indent": {
"type": [
"string",
"number"
],
"default": "editor",
"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.",
"examples": [
"editor",
2,
4,
"tab"
]
}
}
}
Expand Down
103 changes: 103 additions & 0 deletions extensions/levelcode-npp-pack/test/jsonBeautify.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*---------------------------------------------------------------------------------------------
* Unit tests for extensions/levelcode-npp-pack/jsonBeautify.js — run: node test/jsonBeautify.test.js
*--------------------------------------------------------------------------------------------*/
// @ts-check
'use strict';

const assert = require('assert');
const { analyzePaste, MAX_BYTES } = require('../jsonBeautify');

let n = 0;
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }

test('beautifies a minified object, and the result parses back to the same value', () => {
const r = analyzePaste('{"a":1,"b":[2,3],"c":{"d":true}}');
assert.strictEqual(r.beautify, true);
assert.ok(r.output.includes('\n'), 'output should be multi-line');
assert.deepStrictEqual(JSON.parse(r.output), { a: 1, b: [2, 3], c: { d: true } });
assert.strictEqual(r.output, '{\n "a": 1,\n "b": [\n 2,\n 3\n ],\n "c": {\n "d": true\n }\n}');
});

test('beautifies a minified array', () => {
assert.strictEqual(analyzePaste('[1,{"x":2}]').beautify, true);
});

test('leaves already-formatted (canonical 2-space) JSON alone — idempotent', () => {
const once = analyzePaste('{"a":1,"b":2}').output;
const twice = analyzePaste(once);
assert.strictEqual(twice.beautify, false);
assert.strictEqual(twice.reason, 'already-formatted');
});

test('ignores bare JSON scalars — a pasted number/string/bool must NOT be transformed', () => {
for (const s of ['5', '3.14', '"hello"', 'true', 'false', 'null']) {
assert.strictEqual(analyzePaste(s).beautify, false, s);
}
});

test('ignores invalid JSON (unquoted key, trailing comma, JSONC)', () => {
for (const s of ['{a:1}', '{"a":1,}', '{"a":1 /* c */}']) {
const r = analyzePaste(s);
assert.strictEqual(r.beautify, false, s);
assert.strictEqual(r.reason, 'invalid-json', s);
}
});

test('ignores a JSON value with trailing junk (whole blob must be JSON)', () => {
const r = analyzePaste('{"a":1} and then some prose');
assert.strictEqual(r.beautify, false);
assert.strictEqual(r.reason, 'invalid-json');
});

test('ignores ordinary prose / code that is not JSON', () => {
for (const s of ['hello world', 'const x = 1;', 'function f(){}', '']) {
assert.strictEqual(analyzePaste(s).beautify, false, JSON.stringify(s));
}
});

test('trims surrounding whitespace and beautifies the inner JSON', () => {
const r = analyzePaste('\n\t {"a":1} \n');
assert.strictEqual(r.beautify, true);
assert.strictEqual(r.output, '{\n "a": 1\n}');
});

test('respects a numeric indent and a tab indent', () => {
assert.strictEqual(analyzePaste('{"a":1}', { indent: 4 }).output, '{\n "a": 1\n}');
assert.strictEqual(analyzePaste('{"a":1}', { indent: '\t' }).output, '{\n\t"a": 1\n}');
});

test('empty object / empty array are a no-op (already canonical)', () => {
assert.strictEqual(analyzePaste('{}').beautify, false);
assert.strictEqual(analyzePaste('[]').beautify, false);
});

test('rejects non-string input without throwing', () => {
// @ts-expect-error — deliberately wrong types
assert.strictEqual(analyzePaste(null).beautify, false);
// @ts-expect-error
assert.strictEqual(analyzePaste(42).beautify, false);
// @ts-expect-error
assert.strictEqual(analyzePaste(undefined).beautify, false);
});

test('honors the size guard (parses nothing past maxBytes)', () => {
const r = analyzePaste('[1,2,3,4,5]', { maxBytes: 4 });
assert.strictEqual(r.beautify, false);
assert.strictEqual(r.reason, 'too-large');
assert.ok(MAX_BYTES >= 1024 * 1024, 'default cap should be sizeable');
});
Comment on lines +83 to +88

test('size guard counts UTF-8 BYTES, not code units — multibyte payloads cannot slip past', () => {
// A CJK char is 1 UTF-16 code unit but 3 UTF-8 bytes. Build JSON whose .length is UNDER the cap but
// whose byte size is OVER it — a String#length guard would wrongly allow it through and parse it.
const json = '{"k":"' + '実'.repeat(50) + '"}';
assert.ok(json.length < 100, 'precondition: under cap by code units');
assert.ok(Buffer.byteLength(json, 'utf8') > 100, 'precondition: over cap by bytes');
const r = analyzePaste(json, { maxBytes: 100 });
assert.strictEqual(r.beautify, false);
assert.strictEqual(r.reason, 'too-large');
// Sanity: the SAME payload beautifies when the cap is raised above its byte size.
assert.strictEqual(analyzePaste(json, { maxBytes: 1000 }).beautify, true);
});

console.log('\njsonBeautify.js: ' + n + ' tests passed.');