Skip to content
Draft
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
17 changes: 9 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 2 additions & 4 deletions src/Code.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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(
Expand Down
164 changes: 143 additions & 21 deletions src/lib.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean | string | null>,
)) {
// 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[];
};