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[]; +};