From 2241b8c0f6a84d89455ad618984d9871e49459fa Mon Sep 17 00:00:00 2001 From: uncenter <47499684+uncenter@users.noreply.github.com> Date: Fri, 27 Dec 2024 22:38:55 -0500 Subject: [PATCH] feat: parse input to ast before nixification [WIP] Possibly opens the door to support for other input formats, such as TOML. Need to work on preserving comments, which involves comparing token ranges at every possibly step to check if a comment comes before a value or key or whatnot. --- package.json | 2 +- pnpm-lock.yaml | 17 ++--- src/Code.vue | 6 +- src/lib.ts | 164 ++++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 155 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 5edba63..58b5985 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "format": "prettier --write ." }, "dependencies": { + "@humanwhocodes/momoa": "^3.3.5", "@vueuse/core": "^12.2.0", "shiki": "1.24.4", - "tiny-jsonc": "^1.0.1", "vue": "^3.5.13" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71c8bad..400a62e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,15 +8,15 @@ importers: .: dependencies: + '@humanwhocodes/momoa': + specifier: ^3.3.5 + version: 3.3.5 '@vueuse/core': specifier: ^12.2.0 version: 12.2.0(typescript@5.7.2) shiki: specifier: 1.24.4 version: 1.24.4 - tiny-jsonc: - specifier: ^1.0.1 - version: 1.0.1 vue: specifier: ^3.5.13 version: 3.5.13(typescript@5.7.2) @@ -369,6 +369,10 @@ packages: cpu: [x64] os: [win32] + '@humanwhocodes/momoa@3.3.5': + resolution: {integrity: sha512-NI9codbQNjw9g4SS/cOizi8JDZ93B3oGVko8M3y0XF3gITaGDSQqea35V8fswWehnRQBLxPfZY5TJnuNhNCEzA==} + engines: {node: '>=18'} + '@iconify-json/carbon@1.2.5': resolution: {integrity: sha512-aI3TEzOrUDGhs74zIT3ym/ZQBUEziyu8JifntX2Hb4siVzsP5sQ/QEfVdmcCUj37kQUYT3TYBSeAw2vTfCJx9w==} @@ -1019,9 +1023,6 @@ packages: strip-literal@2.1.1: resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} - tiny-jsonc@1.0.1: - resolution: {integrity: sha512-ik6BCxzva9DoiEfDX/li0L2cWKPPENYvixUprFdl3YPi4bZZUhDnNI9YUkacrv+uIG90dnxR5mNqaoD6UhD6Bw==} - tinyexec@0.3.1: resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==} @@ -1345,6 +1346,8 @@ snapshots: '@esbuild/win32-x64@0.24.2': optional: true + '@humanwhocodes/momoa@3.3.5': {} + '@iconify-json/carbon@1.2.5': dependencies: '@iconify/types': 2.0.0 @@ -2138,8 +2141,6 @@ snapshots: dependencies: js-tokens: 9.0.1 - tiny-jsonc@1.0.1: {} - tinyexec@0.3.1: {} tinyglobby@0.2.10: diff --git a/src/Code.vue b/src/Code.vue index f1c4b30..2d5fc27 100644 --- a/src/Code.vue +++ b/src/Code.vue @@ -6,8 +6,7 @@ import githubLightTheme from 'shiki/themes/github-light.mjs'; import githubDarkTheme from 'shiki/themes/github-dark.mjs'; import nixLang from 'shiki/langs/nix.mjs'; -import JSONC from 'tiny-jsonc'; -import { nixify } from './lib'; +import { jsonToNix } from './lib'; const isDarkTheme = useDark(); @@ -26,8 +25,7 @@ const clipboard = useClipboard(); async function run() { try { - const parsed = JSONC.parse(get(input)); - const nixified = nixify(parsed || null); + const nixified = jsonToNix(get(input)); set(converted, nixified); set( diff --git a/src/lib.ts b/src/lib.ts index a825776..56e04b8 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -1,24 +1,146 @@ -export function nixify(value: unknown, level: number = 1): string | undefined { - const indent = ' '.repeat(level); - const subindent = ' '.repeat(level - 1); - if ( - typeof value === 'string' || - Number.isInteger(value) || - value === null || - value === true || - value === false - ) - return `${JSON.stringify(value)}`; - else if (Array.isArray(value)) - return `[\n${value.map((item) => `${indent}${nixify(item, level + 1)}`).join('\n')}\n${subindent}]`; - else { - let nix = '{\n'; - for (const [k, v] of Object.entries( - value as Record, - )) { - // https://nix.dev/manual/nix/2.18/language/values.html#attribute-set - nix += `${indent}${k.match(/^[a-zA-Z0-9_'-]*$/) ? k : `"${k}"`} = ${nixify(v, level + 1)};\n`; +import { + type ArrayNode, + type ObjectNode, + type Token, + type ValueNode, + parse, +} from '@humanwhocodes/momoa'; + +function getIndent(opts: IndentOptions): string { + return opts.string.repeat(opts.level); +} + +function stringToNix(str: string): string { + let escaped = str.replace('"', '\\"').replace('$', '\\$'); + return `"${escaped}"`; +} + +function valueToNix( + val: ValueNode, + iopts: IndentOptions, + gopts: GeneralOptions, +): string { + switch (val.type) { + case 'Object': + return objectToNix(val, iopts, gopts); + case 'Array': + return arrayToNix(val, iopts, gopts); + case 'String': + return stringToNix(val.value); + case 'Number': + case 'Boolean': + return val.value.toString(); + case 'Null': + case 'NaN': + case 'Infinity': + return 'null'; + } +} + +// TODO: Implement flattening of objects with a single key. +// { foo: { bar: true } } -> { foo.bar = true; } +// { foo: { bar: true, baz: false } } -> { foo = { bar = true; baz = false; }; } +function objectToNix( + obj: ObjectNode, + iopts: IndentOptions, + gopts: GeneralOptions, +): string { + if (obj.members.length === 0) return '{}'; + + let result = '{\n'; + iopts.level++; + for (const member of obj.members) { + let value = valueToNix(member.value, iopts, gopts); + let key; + switch (member.name.type) { + case 'String': + key = member.name.value; + break; + case 'Identifier': + key = member.name.name; + break; + } + // https://nix.dev/manual/nix/2.18/language/values.html#attribute-set + key = /^[a-zA-Z0-9_'-]*$/.test(key) ? key : stringToNix(key); + result += `${getIndent(iopts)}${key} = ${value};\n`; + } + iopts.level--; + result += `${getIndent(iopts)}}`; + return result; +} + +function arrayToNix( + arr: ArrayNode, + iopts: IndentOptions, + gopts: GeneralOptions, +): string { + if (arr.elements.length === 0) return '[]'; + + let result = '[\n'; + iopts.level++; + for (const element of arr.elements) { + let value = valueToNix(element.value, iopts, gopts); + result += `${getIndent(iopts)}${value}\n`; + } + iopts.level--; + result += `${getIndent(iopts)}]`; + return result; +} + +export function jsonToNix(json: string): string { + const { body, tokens } = parse(json, { + mode: 'jsonc', + allowTrailingCommas: true, + tokens: true, + }); + if (body.type !== 'Object') + throw new Error('Expected JSON document to be an object'); + const comments = gatherComments(json, tokens!); + console.log(comments); + console.log(body); + return objectToNix( + body, + { level: 0, string: ' ' }, + { + comments, + }, + ); +} + +function gatherComments(source: string, tokens: Token[]): CommentToken[] { + let comments = []; + for (const token of tokens) { + if (token.type === 'LineComment' || token.type === 'BlockComment') { + let type = token.type === 'LineComment' ? 'Line' : 'Block'; + let value = source.slice( + token.loc.start.offset, + token.loc.end.offset, + ); + if (type === 'Block') { + value = value.slice(2, -2); + } else { + value = value.slice(2); + } + comments.push({ + ...token, + type, + value, + } as CommentToken); } - return nix?.trimEnd() + `\n${subindent}}`; } + return comments; } + +type CommentToken = Token & { + type: 'Line' | 'Block'; + value: string; +}; + +type IndentOptions = { + level: number; + string: string; +}; + +type GeneralOptions = { + comments: CommentToken[]; +};