authorgravatar for nmarks413@gmail.comNatalie Marks <nmarks413@gmail.com> 2026-05-14 15:52:43-07:00
committergravatar for nmarks413@gmail.comNatalie Marks <nmarks413@gmail.com> 2026-05-14 15:52:43-07:00
logf9b0dd42278ef29912f099053f9bd412413031fe
tree8bb3ae9a5e8744e21a61e7ac514aad6af61199b8
parent96faebcbdbd8bac6d597f787bfd02e2f7b0ccf89
parent3ca6191b1bb7a67ad542962d501d3ad61c771bc0
signature Commit is signed but in an unrecognized format.

Merge branch 'main' of paperclover.dev:nix/config


11 files changed, 1021 insertions(+), 39 deletions(-)

flake.nix+2
...@@ -71,6 +71,7 @@...@@ -71,6 +71,7 @@
71 # custom packages71 # custom packages
72 (_: pkgs: {72 (_: pkgs: {
73 autofmt = pkgs.callPackage ./packages/autofmt { };73 autofmt = pkgs.callPackage ./packages/autofmt { };
74 "jj-import-merge" = pkgs.callPackage ./packages/jj-import-merge { };
74 exiled_exchange_2 = pkgs.callPackage ./packages/exiled_exchange_2 { };75 exiled_exchange_2 = pkgs.callPackage ./packages/exiled_exchange_2 { };
75 awakened_poe_trade = pkgs.callPackage ./packages/awakened_poe_trade { };76 awakened_poe_trade = pkgs.callPackage ./packages/awakened_poe_trade { };
76 })77 })
...@@ -104,6 +105,7 @@...@@ -104,6 +105,7 @@
104 nvim-natalie = mkNeovim "natalie" pkgs;105 nvim-natalie = mkNeovim "natalie" pkgs;
105106
106 inherit (pkgs) autofmt;107 inherit (pkgs) autofmt;
108 "jj-import-merge" = pkgs."jj-import-merge";
107 }109 }
108 // lib.optionalAttrs (system == "aarch64-darwin") {110 // lib.optionalAttrs (system == "aarch64-darwin") {
109 # "nix run .#darwin-rebuild"111 # "nix run .#darwin-rebuild"
packages/autofmt/autofmt.js+8-4
...@@ -100,12 +100,14 @@ const formatters = {...@@ -100,12 +100,14 @@ const formatters = {
100 },100 },
101 prettier: {101 prettier: {
102 languages: ["javascript", "markdown", "json", "yaml", "mdx", "html", "css"],102 languages: ["javascript", "markdown", "json", "yaml", "mdx", "html", "css"],
103 mustMatchFile: true,
103 files: ["node_modules/.bin/prettier"],104 files: ["node_modules/.bin/prettier"],
104 cmd: ["node_modules/.bin/prettier", "--write", "--", "$files"],105 cmd: ["node_modules/.bin/prettier", "--write", "--", "$files"],
105 stdin: (file) => ["node_modules/.bin/prettier", "--stdin-filepath", file],106 stdin: (file) => ["node_modules/.bin/prettier", "--stdin-filepath", file],
106 },107 },
107 biome: {108 biome: {
108 languages: ["javascript", "json", "css"],109 languages: ["javascript", "json", "css"],
110 mustMatchFile: true,
109 files: ["node_modules/.bin/biome"],111 files: ["node_modules/.bin/biome"],
110 cmd: ["node_modules/.bin/biome", "format", "--", "$files"],112 cmd: ["node_modules/.bin/biome", "format", "--", "$files"],
111 stdin: (file) => [113 stdin: (file) => [
...@@ -542,20 +544,22 @@ function pickFormatter(file) {...@@ -542,20 +544,22 @@ function pickFormatter(file) {
542 : Object.entries(formatters[fmt].files)544 : Object.entries(formatters[fmt].files)
543 .map(([base, check]) => ({ base, check }));545 .map(([base, check]) => ({ base, check }));
544 for (const { base, check } of filesToCheck) {546 for (const { base, check } of filesToCheck) {
547 let fileMatches;
545 if (base.includes("/")) {548 if (base.includes("/")) {
546 matches = readDir(path.join(x, path.dirname(base))) //549 fileMatches = readDir(path.join(x, path.dirname(base))) //
547 .includes(path.basename(base));550 .includes(path.basename(base));
548 } else {551 } else {
549 matches = children.includes(base);552 fileMatches = children.includes(base);
550 }553 }
551 if (matches && typeof check === "function") {554 if (fileMatches && typeof check === "function") {
552 let text;555 let text;
553 matches = !!check({556 fileMatches = !!check({
554 get text() {557 get text() {
555 return text ??= fs.readFileSync(path.join(x, base), "utf-8");558 return text ??= fs.readFileSync(path.join(x, base), "utf-8");
556 },559 },
557 });560 });
558 }561 }
562 matches ||= fileMatches;
559 }563 }
560 }564 }
561 if (matches) {565 if (matches) {
packages/autofmt/default.nix+13-3
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1{ pkgs, ... }:1{ pkgs, ... }:
2pkgs.writeShellApplication {2let
3 name = "autofmt";
4 runtimeInputs = with pkgs; [3 runtimeInputs = with pkgs; [
5 # include only a couple of formatters by default4 # include only a couple of formatters by default
6 clang-tools5 clang-tools
...@@ -11,5 +10,16 @@ pkgs.writeShellApplication {...@@ -11,5 +10,16 @@ pkgs.writeShellApplication {
11 rustfmt10 rustfmt
12 zig11 zig
13 ];12 ];
14 text = ''exec ${pkgs.nodejs}/bin/node ${./autofmt.js} "$@"'';13in
14pkgs.writeTextFile {
15 name = "autofmt";
16 destination = "/bin/autofmt";
17 executable = true;
18 text = builtins.replaceStrings
19 [ "#!/usr/bin/env node" ]
20 [
21 ''#!${pkgs.nodejs}/bin/node
22 process.env.PATH = "${pkgs.lib.makeBinPath runtimeInputs}:" + (process.env.PATH ?? "");''
23 ]
24 (builtins.readFile ./autofmt.js);
15}25}
packages/jj-import-merge/default.nix created+9
...@@ -0,0 +1,9 @@
1{ pkgs, ... }:
2pkgs.writeShellApplication {
3 name = "jj-import-merge";
4 runtimeInputs = with pkgs; [
5 autofmt
6 nodejs
7 ];
8 text = ''exec ${pkgs.nodejs}/bin/node ${./jj-import-merge.js} "$@"'';
9}
packages/jj-import-merge/jj-import-merge.js created+871
...@@ -0,0 +1,871 @@
1#!/usr/bin/env node
2// jj-import-merge
3// Merge top-of-file JS/TS imports from 2-sided Jujutsu conflicts.
4
5import * as child_process from "node:child_process";
6import * as fs from "node:fs";
7import * as path from "node:path";
8import process from "node:process";
9
10const SUPPORTED_EXTENSIONS = new Set([
11 ".cjs",
12 ".cts",
13 ".js",
14 ".jsx",
15 ".mjs",
16 ".mts",
17 ".ts",
18 ".tsx",
19]);
20
21const CONFLICT_START_RE = /^<<<<<<< conflict \d+ of \d+/m;
22const CONFLICT_END_RE = /^>>>>>>> conflict \d+ of \d+ ends$/m;
23
24main();
25
26function main() {
27 const options = parseArgs(process.argv.slice(2));
28 let files = options.files.map((file) => path.resolve(file));
29
30 if (options.allJj) {
31 files.push(...listJjConflictFiles().map((file) => path.resolve(file)));
32 }
33
34 files = [...new Set(files)];
35 if (files.length === 0) {
36 usage(1);
37 }
38
39 let failed = false;
40 for (const file of files) {
41 try {
42 const result = processFile(file);
43 if (result.kind === "formatted") {
44 console.info(`formatted ${path.relative(process.cwd(), file)}`);
45 } else if (result.kind === "rewritten") {
46 console.info(`rewrote ${path.relative(process.cwd(), file)}`);
47 }
48 } catch (error) {
49 failed = true;
50 const message = error instanceof Error ? error.message : String(error);
51 console.error(`error: ${message}`);
52 }
53 }
54
55 process.exit(failed ? 1 : 0);
56}
57
58function parseArgs(argv) {
59 const options = {
60 allJj: false,
61 files: [],
62 };
63
64 while (argv.length > 0) {
65 const arg = argv.shift();
66 if (arg === "-h" || arg === "--help") {
67 usage(0);
68 } else if (arg === "--all-jj") {
69 options.allJj = true;
70 } else if (arg === "--") {
71 options.files.push(...argv);
72 break;
73 } else if (arg.startsWith("-")) {
74 console.error(`error: unknown option ${JSON.stringify(arg)}`);
75 usage(1);
76 } else {
77 options.files.push(arg);
78 }
79 }
80
81 return options;
82}
83
84function usage(code) {
85 const exe = "jj-import-merge";
86 const stream = code === 0 ? process.stdout : process.stderr;
87 stream.write(`usage: ${exe} [--all-jj] [--] <file>...\n`);
88 stream.write(`\n`);
89 stream.write(`merge top-of-file JS/TS imports from 2-sided Jujutsu conflicts\n`);
90 stream.write(`\n`);
91 stream.write(`options:\n`);
92 stream.write(` --all-jj resolve all conflicted JS/TS files from 'jj resolve --list'\n`);
93 stream.write(` -h show help\n`);
94 process.exit(code);
95}
96
97function listJjConflictFiles() {
98 const result = child_process.spawnSync(
99 "jj",
100 ["--color=never", "--quiet", "resolve", "--list"],
101 { encoding: "utf8" },
102 );
103
104 if (result.error) {
105 throw new Error(`failed to run jj: ${result.error.message}`);
106 }
107 if (result.status !== 0) {
108 throw new Error(result.stderr.trim() || "jj resolve --list failed");
109 }
110
111 return result.stdout
112 .split(/\r?\n/)
113 .map((line) => line.trimEnd())
114 .filter(Boolean)
115 .map((line) => line.match(/^(.*?)\s{2,}.+conflict$/))
116 .filter(Boolean)
117 .map((match) => match[1]);
118}
119
120function processFile(file) {
121 if (!SUPPORTED_EXTENSIONS.has(path.extname(file))) {
122 return { kind: "noop" };
123 }
124
125 let original;
126 try {
127 original = fs.readFileSync(file, "utf8");
128 } catch (error) {
129 const message = error instanceof Error ? error.message : String(error);
130 throw new Error(`cannot read ${file}: ${message}`);
131 }
132
133 if (!CONFLICT_START_RE.test(original)) {
134 return { kind: "noop" };
135 }
136
137 const parsed = parseConflictFile(original);
138 if (!parsed.supported) {
139 return { kind: "noop" };
140 }
141
142 const leftText = parsed.parts.map((part) => part.a).join("");
143 const rightText = parsed.parts.map((part) => part.b).join("");
144 const leftImports = extractTopImportBlock(leftText);
145 const rightImports = extractTopImportBlock(rightText);
146 if (!leftImports || !rightImports) {
147 return { kind: "noop" };
148 }
149
150 const leftPrelude = leftText.slice(0, leftImports.start);
151 const rightPrelude = rightText.slice(0, rightImports.start);
152 if (leftPrelude !== rightPrelude) {
153 return { kind: "noop" };
154 }
155
156 const start = projectBoundaryToOriginal(
157 parsed.parts,
158 leftImports.start,
159 rightImports.start,
160 );
161 const end = projectBoundaryToOriginal(parsed.parts, leftImports.end, rightImports.end);
162 if (start == null || end == null || end < start) {
163 return { kind: "noop" };
164 }
165
166 const mergedImports = mergeImportStatements(
167 leftImports.statements,
168 rightImports.statements,
169 detectNewline(original),
170 );
171 const updated = original.slice(0, start) + mergedImports + original.slice(end);
172
173 if (updated === original) {
174 return { kind: "noop" };
175 }
176
177 try {
178 fs.writeFileSync(file, updated, "utf8");
179 } catch (error) {
180 const message = error instanceof Error ? error.message : String(error);
181 throw new Error(`cannot write ${file}: ${message}`);
182 }
183
184 if (hasConflictMarkers(updated)) {
185 return { kind: "rewritten" };
186 }
187
188 const formatResult = child_process.spawnSync("autofmt", [file], {
189 encoding: "utf8",
190 stdio: "inherit",
191 });
192
193 if (formatResult.error || formatResult.status !== 0) {
194 fs.writeFileSync(file, original, "utf8");
195 if (formatResult.error) {
196 throw new Error(`autofmt failed for ${file}: ${formatResult.error.message}`);
197 }
198 throw new Error(`autofmt failed for ${file}`);
199 }
200
201 return { kind: "formatted" };
202}
203
204function parseConflictFile(text) {
205 const lines = splitLines(text);
206 const parts = [];
207
208 let index = 0;
209 while (index < lines.length) {
210 const line = lines[index].text;
211 if (!line.startsWith("<<<<<<< conflict ")) {
212 const start = index;
213 index += 1;
214 while (
215 index < lines.length &&
216 !lines[index].text.startsWith("<<<<<<< conflict ")
217 ) {
218 index += 1;
219 }
220 const raw = joinLines(lines, start, index);
221 parts.push({ type: "plain", orig: raw, a: raw, b: raw });
222 continue;
223 }
224
225 const block = parseConflictBlock(lines, index);
226 if (!block.supported) {
227 return { supported: false, parts: [] };
228 }
229 parts.push(block.part);
230 index = block.nextIndex;
231 }
232
233 return { supported: true, parts };
234}
235
236function parseConflictBlock(lines, startIndex) {
237 let index = startIndex + 1;
238 const entries = [];
239
240 while (index < lines.length) {
241 const line = lines[index].text;
242 if (line.startsWith(">>>>>>> conflict ")) {
243 break;
244 }
245 if (line.startsWith("+++++++ ")) {
246 const contentStart = index + 1;
247 index += 1;
248 while (
249 index < lines.length &&
250 !isConflictEntryStart(lines[index].text) &&
251 !lines[index].text.startsWith(">>>>>>> conflict ")
252 ) {
253 index += 1;
254 }
255 entries.push({
256 kind: "explicit",
257 text: joinLines(lines, contentStart, index),
258 });
259 continue;
260 }
261 if (line.startsWith("%%%%%%% diff from:")) {
262 if (index + 1 >= lines.length || !/^\\\\+\s*to:/.test(lines[index + 1].text)) {
263 return { supported: false, nextIndex: startIndex + 1 };
264 }
265 index += 2;
266 let text = "";
267 while (
268 index < lines.length &&
269 !isConflictEntryStart(lines[index].text) &&
270 !lines[index].text.startsWith(">>>>>>> conflict ")
271 ) {
272 const diffLine = lines[index].text;
273 const prefix = diffLine[0];
274 if (prefix === " " || prefix === "+") {
275 text += diffLine.slice(1);
276 } else if (prefix !== "-") {
277 return { supported: false, nextIndex: startIndex + 1 };
278 }
279 index += 1;
280 }
281 entries.push({ kind: "diff", text });
282 continue;
283 }
284 return { supported: false, nextIndex: startIndex + 1 };
285 }
286
287 if (
288 index >= lines.length ||
289 !lines[index].text.startsWith(">>>>>>> conflict ") ||
290 entries.length !== 2
291 ) {
292 return { supported: false, nextIndex: startIndex + 1 };
293 }
294
295 return {
296 supported: true,
297 nextIndex: index + 1,
298 part: {
299 type: "conflict",
300 orig: joinLines(lines, startIndex, index + 1),
301 a: entries[0].text,
302 b: entries[1].text,
303 },
304 };
305}
306
307function isConflictEntryStart(line) {
308 return line.startsWith("+++++++ ") || line.startsWith("%%%%%%% diff from:");
309}
310
311function splitLines(text) {
312 const lines = [];
313 let start = 0;
314
315 for (let index = 0; index < text.length; index += 1) {
316 if (text[index] !== "\n") continue;
317 lines.push({
318 start,
319 end: index + 1,
320 text: text.slice(start, index + 1),
321 });
322 start = index + 1;
323 }
324
325 if (start < text.length) {
326 lines.push({ start, end: text.length, text: text.slice(start) });
327 }
328
329 return lines;
330}
331
332function joinLines(lines, start, end) {
333 let text = "";
334 for (let index = start; index < end; index += 1) {
335 text += lines[index].text;
336 }
337 return text;
338}
339
340function extractTopImportBlock(text) {
341 const lines = splitLines(text);
342 if (lines.length === 0) return null;
343
344 let index = 0;
345 if (lines[0]?.text.startsWith("#!")) {
346 index += 1;
347 }
348
349 index = skipPrelude(lines, index);
350 if (index == null || index >= lines.length) {
351 return null;
352 }
353
354 const first = parseImportStatement(lines, index);
355 if (!first) {
356 return null;
357 }
358
359 const start = lines[index].start;
360 const statements = [first.statement];
361 let end = offsetForLine(lines, first.nextIndex);
362 index = first.nextIndex;
363
364 while (true) {
365 const afterTrivia = skipBlankAndComments(lines, index);
366 if (afterTrivia == null) {
367 return null;
368 }
369
370 const next = parseImportStatement(lines, afterTrivia);
371 if (!next) {
372 break;
373 }
374
375 statements.push(next.statement);
376 end = offsetForLine(lines, next.nextIndex);
377 index = next.nextIndex;
378 }
379
380 return { start, end, statements };
381}
382
383function skipPrelude(lines, startIndex) {
384 let index = startIndex;
385 while (index < lines.length) {
386 const afterTrivia = skipBlankAndComments(lines, index);
387 if (afterTrivia == null) {
388 return null;
389 }
390 if (afterTrivia >= lines.length) {
391 return afterTrivia;
392 }
393
394 const trimmed = lines[afterTrivia].text.trim();
395 if (isDirectiveLine(trimmed)) {
396 index = afterTrivia + 1;
397 continue;
398 }
399 return afterTrivia;
400 }
401 return index;
402}
403
404function skipBlankAndComments(lines, startIndex) {
405 let index = startIndex;
406
407 while (index < lines.length) {
408 const trimmed = lines[index].text.trim();
409 if (trimmed === "") {
410 index += 1;
411 continue;
412 }
413 if (trimmed.startsWith("//")) {
414 index += 1;
415 continue;
416 }
417 if (trimmed.startsWith("/*")) {
418 index = skipBlockComment(lines, index);
419 if (index == null) {
420 return null;
421 }
422 continue;
423 }
424 break;
425 }
426
427 return index;
428}
429
430function skipBlockComment(lines, startIndex) {
431 for (let index = startIndex; index < lines.length; index += 1) {
432 if (lines[index].text.includes("*/")) {
433 return index + 1;
434 }
435 }
436 return null;
437}
438
439function isDirectiveLine(line) {
440 return /^["'][^"'\\]*(?:\\.[^"'\\]*)*["'];?$/.test(line);
441}
442
443function parseImportStatement(lines, startIndex) {
444 if (startIndex >= lines.length) {
445 return null;
446 }
447
448 const firstLine = lines[startIndex].text.trimStart();
449 if (!firstLine.startsWith("import")) {
450 return null;
451 }
452 if (/^import(?:\s*\(|\s*\.)/.test(firstLine)) {
453 return null;
454 }
455
456 let raw = "";
457 const state = {
458 braceDepth: 0,
459 inBlockComment: false,
460 stringQuote: null,
461 };
462
463 for (let index = startIndex; index < lines.length; index += 1) {
464 raw += lines[index].text;
465 scanLine(lines[index].text, state);
466
467 if (state.inBlockComment || state.braceDepth !== 0) {
468 continue;
469 }
470
471 const analysis = parseImportSemantics(raw);
472 if (analysis) {
473 return {
474 nextIndex: index + 1,
475 statement: raw,
476 };
477 }
478 }
479
480 return null;
481}
482
483function scanLine(line, state) {
484 for (let index = 0; index < line.length; index += 1) {
485 const char = line[index];
486 const next = line[index + 1];
487
488 if (state.inBlockComment) {
489 if (char === "*" && next === "/") {
490 state.inBlockComment = false;
491 index += 1;
492 }
493 continue;
494 }
495
496 if (state.stringQuote) {
497 if (char === "\\") {
498 index += 1;
499 continue;
500 }
501 if (char === state.stringQuote) {
502 state.stringQuote = null;
503 }
504 continue;
505 }
506
507 if (char === "/" && next === "/") {
508 break;
509 }
510 if (char === "/" && next === "*") {
511 state.inBlockComment = true;
512 index += 1;
513 continue;
514 }
515 if (char === "'" || char === "\"" || char === "`") {
516 state.stringQuote = char;
517 continue;
518 }
519 if (char === "{") {
520 state.braceDepth += 1;
521 } else if (char === "}") {
522 state.braceDepth = Math.max(0, state.braceDepth - 1);
523 }
524 }
525}
526
527function parseImportSemantics(raw) {
528 const normalized = collapseWhitespace(raw);
529 if (!normalized.startsWith("import ")) {
530 return null;
531 }
532
533 const sideEffectMatch = normalized.match(
534 /^import\s+(["'])(.*?)\1(\s+with\s+\{.*\})?\s*;?$/,
535 );
536 if (sideEffectMatch) {
537 return {
538 kind: "side-effect",
539 source: sideEffectMatch[2],
540 quote: sideEffectMatch[1],
541 attributes: sideEffectMatch[3] ?? "",
542 key: `side:${sideEffectMatch[2]}:${sideEffectMatch[3] ?? ""}`,
543 };
544 }
545
546 const bindingMatch = normalized.match(
547 /^import\s+(type\s+)?(.+?)\s+from\s+(["'])(.*?)\3(\s+with\s+\{.*\})?\s*;?$/,
548 );
549 if (!bindingMatch) {
550 return null;
551 }
552
553 const parsed = parseBindingSpec(bindingMatch[2].trim());
554 if (!parsed) {
555 return null;
556 }
557
558 const named = parsed.namedSpecifiers.join(",");
559 return {
560 kind: "binding",
561 isTypeImport: !!bindingMatch[1],
562 defaultBinding: parsed.defaultBinding,
563 namespaceImport: parsed.namespaceImport,
564 namedSpecifiers: parsed.namedSpecifiers,
565 source: bindingMatch[4],
566 quote: bindingMatch[3],
567 attributes: bindingMatch[5] ?? "",
568 key: [
569 "binding",
570 bindingMatch[1] ? "type" : "value",
571 parsed.defaultBinding ?? "",
572 parsed.namespaceImport ?? "",
573 named,
574 bindingMatch[4],
575 bindingMatch[5] ?? "",
576 ].join(":"),
577 };
578}
579
580function parseBindingSpec(specPart) {
581 let defaultBinding = null;
582 let namespaceImport = null;
583 let namedSpecifiers = [];
584
585 const braceStart = specPart.indexOf("{");
586 const braceEnd = specPart.lastIndexOf("}");
587 if (braceStart !== -1 || braceEnd !== -1) {
588 if (braceStart === -1 || braceEnd === -1 || braceEnd < braceStart) {
589 return null;
590 }
591
592 const beforeBrace = specPart
593 .slice(0, braceStart)
594 .trim()
595 .replace(/,\s*$/, "")
596 .trim();
597 const afterBrace = specPart.slice(braceEnd + 1).trim();
598 if (afterBrace !== "") {
599 return null;
600 }
601
602 const head = parseBindingHead(beforeBrace);
603 if (!head) {
604 return null;
605 }
606 defaultBinding = head.defaultBinding;
607 namespaceImport = head.namespaceImport;
608 namedSpecifiers = splitCommaList(specPart.slice(braceStart + 1, braceEnd));
609 } else {
610 const head = parseBindingHead(specPart);
611 if (!head) {
612 return null;
613 }
614 defaultBinding = head.defaultBinding;
615 namespaceImport = head.namespaceImport;
616 }
617
618 return {
619 defaultBinding,
620 namespaceImport,
621 namedSpecifiers,
622 };
623}
624
625function parseBindingHead(head) {
626 if (head === "") {
627 return { defaultBinding: null, namespaceImport: null };
628 }
629
630 if (head.startsWith("* as ")) {
631 return { defaultBinding: null, namespaceImport: head };
632 }
633
634 const parts = head.split(/\s*,\s*/).filter(Boolean);
635 if (parts.length === 1) {
636 return {
637 defaultBinding: parts[0],
638 namespaceImport: null,
639 };
640 }
641
642 if (parts.length === 2 && parts[1].startsWith("* as ")) {
643 return {
644 defaultBinding: parts[0],
645 namespaceImport: parts[1],
646 };
647 }
648
649 return null;
650}
651
652function splitCommaList(text) {
653 return text
654 .split(",")
655 .map((part) => collapseWhitespace(part))
656 .filter(Boolean);
657}
658
659function mergeImportStatements(leftStatements, rightStatements, newline) {
660 const merged = [];
661
662 for (const statement of [...leftStatements, ...rightStatements]) {
663 const analysis = parseImportSemantics(statement);
664 if (!analysis) {
665 const key = `raw:${collapseWhitespace(statement)}`;
666 if (!merged.some((entry) => entry.key === key)) {
667 merged.push({
668 key,
669 raw: ensureTrailingNewline(statement.trim(), newline),
670 analysis: null,
671 });
672 }
673 continue;
674 }
675
676 const exactIndex = merged.findIndex((entry) => entry.key === analysis.key);
677 if (exactIndex !== -1) {
678 continue;
679 }
680
681 const mergeIndex = merged.findIndex(
682 (entry) => entry.analysis && canMergeImports(entry.analysis, analysis),
683 );
684 if (mergeIndex !== -1) {
685 const nextAnalysis = mergeImports(merged[mergeIndex].analysis, analysis);
686 merged[mergeIndex] = {
687 key: nextAnalysis.key,
688 raw: renderImport(nextAnalysis, newline),
689 analysis: nextAnalysis,
690 };
691 continue;
692 }
693
694 merged.push({
695 key: analysis.key,
696 raw: renderImport(analysis, newline),
697 analysis,
698 });
699 }
700
701 return merged
702 .map((entry) => entry.raw.trimEnd())
703 .join(newline) + newline;
704}
705
706function canMergeImports(left, right) {
707 if (left.kind !== "binding" || right.kind !== "binding") {
708 return false;
709 }
710 if (
711 left.source !== right.source ||
712 left.attributes !== right.attributes ||
713 left.isTypeImport !== right.isTypeImport
714 ) {
715 return false;
716 }
717
718 const defaultBinding = mergeField(left.defaultBinding, right.defaultBinding);
719 const namespaceImport = mergeField(left.namespaceImport, right.namespaceImport);
720 if (defaultBinding === undefined || namespaceImport === undefined) {
721 return false;
722 }
723
724 const namedSpecifiers = unique([...left.namedSpecifiers, ...right.namedSpecifiers]);
725 if (namespaceImport && namedSpecifiers.length > 0) {
726 return false;
727 }
728
729 return true;
730}
731
732function mergeImports(left, right) {
733 const defaultBinding = mergeField(left.defaultBinding, right.defaultBinding);
734 const namespaceImport = mergeField(left.namespaceImport, right.namespaceImport);
735 const namedSpecifiers = unique([...left.namedSpecifiers, ...right.namedSpecifiers]);
736
737 return {
738 kind: "binding",
739 isTypeImport: left.isTypeImport,
740 defaultBinding,
741 namespaceImport,
742 namedSpecifiers,
743 source: left.source,
744 quote: left.quote,
745 attributes: left.attributes,
746 key: [
747 "binding",
748 left.isTypeImport ? "type" : "value",
749 defaultBinding ?? "",
750 namespaceImport ?? "",
751 namedSpecifiers.join(","),
752 left.source,
753 left.attributes,
754 ].join(":"),
755 };
756}
757
758function mergeField(left, right) {
759 if (left == null) return right ?? null;
760 if (right == null) return left;
761 if (left === right) return left;
762 return undefined;
763}
764
765function renderImport(analysis, newline) {
766 if (analysis.kind === "side-effect") {
767 return ensureTrailingNewline(
768 `import ${analysis.quote}${analysis.source}${analysis.quote}${analysis.attributes};`,
769 newline,
770 );
771 }
772
773 const pieces = [];
774 if (analysis.defaultBinding) {
775 pieces.push(analysis.defaultBinding);
776 }
777 if (analysis.namespaceImport) {
778 pieces.push(analysis.namespaceImport);
779 }
780 if (analysis.namedSpecifiers.length > 0) {
781 if (analysis.namedSpecifiers.length === 1) {
782 pieces.push(`{ ${analysis.namedSpecifiers[0]} }`);
783 } else {
784 pieces.push(
785 `{${newline}${analysis.namedSpecifiers.map((spec) => ` ${spec},`).join(newline)}${newline}}`,
786 );
787 }
788 }
789
790 return ensureTrailingNewline(
791 `import ${analysis.isTypeImport ? "type " : ""}${pieces.join(", ")} from ${analysis.quote}${analysis.source}${analysis.quote}${analysis.attributes};`,
792 newline,
793 );
794}
795
796function projectBoundaryToOriginal(parts, targetA, targetB) {
797 let offsetA = 0;
798 let offsetB = 0;
799 let offsetOrig = 0;
800
801 if (targetA === 0 && targetB === 0) {
802 return 0;
803 }
804
805 for (const part of parts) {
806 if (targetA === offsetA && targetB === offsetB) {
807 return offsetOrig;
808 }
809
810 const nextA = offsetA + part.a.length;
811 const nextB = offsetB + part.b.length;
812 const nextOrig = offsetOrig + part.orig.length;
813
814 if (part.type === "plain") {
815 if (
816 targetA >= offsetA &&
817 targetA <= nextA &&
818 targetB >= offsetB &&
819 targetB <= nextB
820 ) {
821 const deltaA = targetA - offsetA;
822 const deltaB = targetB - offsetB;
823 if (deltaA !== deltaB) {
824 return null;
825 }
826 return offsetOrig + deltaA;
827 }
828 }
829
830 if (targetA === nextA && targetB === nextB) {
831 return nextOrig;
832 }
833
834 offsetA = nextA;
835 offsetB = nextB;
836 offsetOrig = nextOrig;
837 }
838
839 if (targetA === offsetA && targetB === offsetB) {
840 return offsetOrig;
841 }
842
843 return null;
844}
845
846function hasConflictMarkers(text) {
847 return CONFLICT_START_RE.test(text) || CONFLICT_END_RE.test(text);
848}
849
850function detectNewline(text) {
851 return text.includes("\r\n") ? "\r\n" : "\n";
852}
853
854function ensureTrailingNewline(text, newline) {
855 return text.endsWith(newline) ? text : `${text}${newline}`;
856}
857
858function offsetForLine(lines, lineIndex) {
859 if (lineIndex >= lines.length) {
860 return lines.length === 0 ? 0 : lines[lines.length - 1].end;
861 }
862 return lines[lineIndex].start;
863}
864
865function collapseWhitespace(text) {
866 return text.replace(/\s+/g, " ").trim();
867}
868
869function unique(values) {
870 return [...new Set(values)];
871}
users/clover/AGENTS.md created+1
...@@ -0,0 +1 @@
1../../../.config/opencode/AGENTS.md
\ No newline at end of file
users/clover/home.nix+73-14
...@@ -12,6 +12,7 @@ in...@@ -12,6 +12,7 @@ in
12 stateVersion = "23.05"; # Please read the comment before changing.12 stateVersion = "23.05"; # Please read the comment before changing.
13 sessionPath = [13 sessionPath = [
14 "$HOME/.vite-plus/bin"14 "$HOME/.vite-plus/bin"
15 "$HOME/.local/bin"
15 ];16 ];
16 packages =17 packages =
17 with pkgs;18 with pkgs;
...@@ -23,8 +24,9 @@ in...@@ -23,8 +24,9 @@ in
23 uv24 uv
24 nh25 nh
25 autofmt26 autofmt
27 pkgs."jj-import-merge"
26 gnupg28 gnupg
27 nodejs_2429 just
28 ];30 ];
29 # packages to install for desktop environments (non-server)31 # packages to install for desktop environments (non-server)
30 desktop = [32 desktop = [
...@@ -72,13 +74,29 @@ in...@@ -72,13 +74,29 @@ in
72 jf = "jj git fetch";74 jf = "jj git fetch";
73 e = "jj edit";75 e = "jj edit";
74 d = "jj desc";76 d = "jj desc";
75 bup = "jj b m --from 'heads(::@- & bookmarks())' --to '@'";77 bup = "jj b m --from 'heads(::@- & bookmarks()) ~ (master | bookmarks(glob:\"deployment*\"))' --to '@'";
76 moveself = "jj rebase -r @ -d";78 moveself = "jj rebase -s @ -d";
77 logall = "jj log -r 'all()'";79 logall = "jj log -r 'all()'";
78 cherry = "jj git fetch && jj split -i -p -m 'SPLIT_CHANGES' && jj edit -r 'description(\"SPLIT_CHANGES\")' && jj desc -m '' --edit";80 cherry = "jj git fetch && jj split -i -p -m 'SPLIT_CHANGES' && jj edit -r 'description(\"SPLIT_CHANGES\")' && jj desc -m '' --edit";
79 };81 };
80 initContent = ''82 initContent = ''
81 export PS1="%{$(tput setaf 207)%}%m%{$(tput setaf 15)%}:%{$(tput setaf 87)%}%~ %{$(tput sgr0)%}$ "83 function _update_prompt_theme() {
84 local dark=false
85 if [[ "$OSTYPE" == darwin* ]]; then
86 [[ "$(defaults read -g AppleInterfaceStyle 2>/dev/null)" == "Dark" ]] && dark=true
87 else
88 local scheme
89 scheme=$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null)
90 [[ "$scheme" == *"dark"* ]] && dark=true
91 fi
92 if $dark; then
93 PS1="%{$(tput setaf 207)%}%m%{$(tput setaf 15)%}:%{$(tput setaf 87)%}%~ %{$(tput sgr0)%}$ "
94 else
95 PS1="%{$(tput setaf 168)%}%m%{$(tput setaf 238)%}:%{$(tput setaf 33)%}%~ %{$(tput sgr0)%}$ "
96 fi
97 }
98 precmd_functions+=(_update_prompt_theme)
99 _update_prompt_theme
82100
83 function python() {101 function python() {
84 dirname=$(dirname $1 2>/dev/null)102 dirname=$(dirname $1 2>/dev/null)
...@@ -99,14 +117,45 @@ in...@@ -99,14 +117,45 @@ in
99 fi117 fi
100 }118 }
101119
120 function wrapWithAutoInstall() {
121 root="$(command jj workspace root)"
122 if [ -n "$root" ]; then
123 if [ -e "$root/bun.lock" ]; then
124 file="$root/bun.lock"
125 cmd="bun i -f"
126 elif [ -e "$root/package-lock.json" ]; then
127 file="$root/package-lock.json"
128 cmd="npm i"
129 elif [ -e "$root/pnpm-lock.yaml" ]; then
130 file="$root/pnpm-lock.yaml"
131 cmd="pnpm i"
132 elif [ -e "$root/Cargo.lock" ]; then
133 file="$root/Cargo.lock"
134 cmd="cargo fetch"
135 fi
136 fi
137 if [ -n "$file" ]; then
138 before="$(sha1sum "$file")"
139 fi
140 command "$@"
141 if [ -n "$file" ] && [ -e "$file" ]; then
142 after="$(sha1sum "$file")"
143 if [ "$after" != "$before" ]; then
144 eval "$cmd"
145 fi
146 fi
147 }
148
102 function jj() {149 function jj() {
103 if [[ "$*" = "git push"* ]] && [[ "$*" != *"--dry-run"* ]]; then150 if [[ "$*" = "git push"* ]] && [[ "$*" != *"--dry-run"* ]]; then
104 command jj "$@" --dry-run151 if command jj "$@" --dry-run; then
105 printf "Press any key to continue... "152 printf "Press any key to continue... "
106 read -r153 read -r
107 command jj "$@"154 command jj "$@"
108 else155 fi
109 command jj "$@"156 else if [[ "$*" != "undo" ]] || [[ "$*" != "redo" ]]
157 # auto-install deps when switching commits
158 wrapWithAutoInstall jj "$@"
110 fi159 fi
111 }160 }
112 '';161 '';
...@@ -114,6 +163,7 @@ in...@@ -114,6 +163,7 @@ in
114163
115 git = {164 git = {
116 # use a git-specific email165 # use a git-specific email
166 ignores = [ "AGENTS.md" ];
117 settings.user.email = "git@paperclover.net";167 settings.user.email = "git@paperclover.net";
118 settings.gpg.ssh.allowedSignersFile = "${pkgs.writeText "allowed_signers" ''168 settings.gpg.ssh.allowedSignersFile = "${pkgs.writeText "allowed_signers" ''
119 git@paperclover.net ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAVa1aq1LEj/VXHJYlR5HJpK65lS/9jOL/dAB9ZS1chd169 git@paperclover.net ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAVa1aq1LEj/VXHJYlR5HJpK65lS/9jOL/dAB9ZS1chd
...@@ -142,15 +192,19 @@ in...@@ -142,15 +192,19 @@ in
142 "working_copy remote_bookmarks" = colors."working_copy bookmark";192 "working_copy remote_bookmarks" = colors."working_copy bookmark";
143193
144 "author" = {194 "author" = {
145 fg = "white";195 fg = "bright black";
146 bold = false;196 bold = false;
147 italic = true;197 italic = true;
148 };198 };
149 "working_copy author" = colors.author;199 "working_copy author" = colors.author;
200 committer = {
201 fg = "bright black";
202 bold = false;
203 };
150204
151 desc = {205 desc = {
152 bold = false;206 bold = true;
153 fg = "bright white";207 fg = "default";
154 };208 };
155 description = colors.desc;209 description = colors.desc;
156 cc_wip = {210 cc_wip = {
...@@ -160,7 +214,7 @@ in...@@ -160,7 +214,7 @@ in
160 };214 };
161 cc_header = {215 cc_header = {
162 bold = true;216 bold = true;
163 fg = "bright white";217 fg = "default";
164 };218 };
165 cc_feat = "green";219 cc_feat = "green";
166 cc_fix = "red";220 cc_fix = "red";
...@@ -179,6 +233,10 @@ in...@@ -179,6 +233,10 @@ in
179 bold = true;233 bold = true;
180 reverse = true;234 reverse = true;
181 };235 };
236 "change_id shortest rest" = {
237 fg = "bright black";
238 bold = true;
239 };
182 conflict_marker = {240 conflict_marker = {
183 fg = "bright red";241 fg = "bright red";
184 bold = true;242 bold = true;
...@@ -186,6 +244,7 @@ in...@@ -186,6 +244,7 @@ in
186 };244 };
187245
188 timestamp = "bright black";246 timestamp = "bright black";
247 "committer timestamp" = colors.committer;
189 commit_id = {248 commit_id = {
190 fg = "bright black";249 fg = "bright black";
191 bold = false;250 bold = false;
users/clover/paperback/home.nix+1-12
...@@ -1,23 +1,12 @@...@@ -1,23 +1,12 @@
1{ pkgs, ... }:1{ pkgs, ... }:
2{2{
3 # these programs are not globally installed to reduce distractions.
4 # most of these are needed for my work environment.
5 programs = {
6 zsh.profileExtra = ''
7 _bun() {
8 local context state line
9 _arguments -C '*:file:_files'
10 }
11 '';
12 };
13 home.packages = with pkgs; [3 home.packages = with pkgs; [
14 deno
15 rustup4 rustup
16 typescript5 typescript
17 pm2
18 pnpm6 pnpm
19 yt-dlp7 yt-dlp
20 spotdl8 spotdl
21 zig9 zig
10 nodejs_24
22 ];11 ];
23}12}
users/clover/sandwich/home.nix created+7
...@@ -0,0 +1,7 @@
1{ pkgs, ... }:
2{
3
4 home.packages = with pkgs; [
5 nodejs_24
6 ];
7}
users/clover/switch-admin.sh created+26
...@@ -0,0 +1,26 @@
1#!/bin/sh
2set -eu
3
4cd /Users/clo/config
5
6target_host="${1:-}"
7if [ -z "$target_host" ]; then
8 if [ -f .hostname ]; then
9 target_host="$(cat .hostname)"
10 else
11 target_host="$(hostname)"
12 fi
13fi
14
15case "$target_host" in
16 '' | *[!A-Za-z0-9._-]*)
17 printf 'invalid host: %s\n' "$target_host" >&2
18 exit 2
19 ;;
20esac
21
22if [ "$(uname -s)" != Darwin ]; then
23 exec ./switch "$target_host"
24fi
25
26exec osascript -e "do shell script \"cd /Users/clo/config && /nix/var/nix/profiles/default/bin/nix run .#darwin-rebuild -- switch --flake .#$target_host\" with administrator privileges"
users/clover/work/home.nix+10-6
...@@ -8,20 +8,24 @@...@@ -8,20 +8,24 @@
8 }8 }
9 '';9 '';
10 };10 };
11 programs.git.signing.key = "CE485807AE4068AF8508C8DC5CBB21841C7920AD";
12 home.packages = with pkgs; [11 home.packages = with pkgs; [
13 (bun.overrideAttrs (_: rec {12 (bun.overrideAttrs (_: rec {
14 version = "1.3.0";13 version = "1.3.5";
15 src = fetchurl {14 src = fetchurl {
16 url = "https://github.com/oven-sh/bun/releases/download/bun-v${version}/bun-darwin-aarch64.zip";15 url = "https://github.com/oven-sh/bun/releases/download/bun-v${version}/bun-darwin-aarch64.zip";
17 hash = "sha256-hYSOP5ZIHvyr51pQD9O5S5u5VoaretCjiSl2x74VA2o=";16 hash = "sha256-2xdYikrqiASFaCXUvq0/BeHzcnbKYG8342m09y810/s=";
18 };17 };
19 }))18 }))
20 doppler19 awscli
21 deno20 deno
21 doppler
22 nodejs_25
22 rustup23 rustup
23 typescript
24 ssm-session-manager-plugin24 ssm-session-manager-plugin
25 awscli25 typescript
26 zig
27 ];
28 home.sessionPath = [
29 "$HOME/.opencode/bin"
26 ];30 ];
27}31}