authorgravatar for nmarks413@gmail.comNatalie Marks <nmarks413@gmail.com> 2025-08-19 21:36:21-07:00
committergravatar for nmarks413@gmail.comNatalie Marks <nmarks413@gmail.com> 2025-08-19 21:36:21-07:00
logfa1fc5801aa23eda69311c94bc80ee1111414983
tree40ab5106928217b5d334b3ea3d999873e46457c9
parent68598cc7301b9986ceb8bb0750f12af4c0d0b207
parent6c74c73de2c38f7cf501d378a0f9cb4325cd1c85
signature Commit is signed but in an unrecognized format.

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


11 files changed, 651 insertions(+), 633 deletions(-)

files/autofmt.js deleted-593
...@@ -1,593 +0,0 @@
1#!/usr/bin/env node
2// autofmt v1 - https://paperclover.dev/nix/config/src/branch/main/files/autofmt.js
3//
4// Different codebases use different formatters. Autofmt looks for project
5// configuration to pick the correct formatter, allowing an editor to simply
6// point to this script and ambiguities resolved. This single file program
7// depends only on Node.js v22.
8//
9// When using this repository's Nix-based Neovim configuration, autofmt is
10// automatically configured as the default formatter. To configure manually,
11// set the editor's formatter to run `autofmt --stdio=<filename>`. The filename
12// is used to determine which formatter to invoke.
13//
14// With `conform.nvim`:
15// formatters = {
16// autofmt = { command = "autofmt" }
17// }
18//
19// With Zed:
20// "formatter": {
21// "external": {
22// "command": "autofmt",
23// "arguments": ["--stdio", "{buffer_path}"]
24// }
25// },
26//
27// @ts-nocheck
28
29// -- definitions --
30const extensions = {
31 c: [".c", ".h"],
32 cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".hh"],
33 css: [".css"],
34 html: [".html"],
35 javascript: [".js", ".ts", ".cjs", ".cts", ".mjs", ".mts", ".jsx", ".tsx"],
36 json: [".json", ".jsonc"],
37 markdown: [".md", ".markdown"],
38 mdx: [".mdx"],
39 nix: [".nix"],
40 rust: [".rs"],
41 toml: [".toml"],
42 yaml: [".yml", ".yaml"],
43 zig: [".zig"],
44};
45const formatters = {
46 // this object is sorted by priority
47 //
48 // `languages: true` are multiplexers that apply to all listed
49 // languages, as it is assumed a project will setup their multiplexer
50 // correctly for all tracked files.
51 //
52 // if a formatter doesnt match a config file, the first one is picked
53 // as a default, making things like `deno fmt file.ts` or `clang-format`
54 // the default when there are no config files.
55 dprint: {
56 languages: true,
57 files: ["dprint.json", "dprint.jsonc"],
58 cmd: ["dprint", "fmt", "--", "$files"],
59 stdin: (file) => ["dprint", "fmt", "--stdin", file],
60 },
61 treefmt: {
62 languages: true,
63 files: ["treefmt.toml", ".treefmt.toml"],
64 cmd: ["treefmt", "--", "$files"],
65 stdin: (file) => ["treefmt", "--stdin", file],
66 },
67 // -- web ecosystem --
68 deno: {
69 languages: ["javascript", "markdown", "json", "yaml", "html", "css"],
70 files: ["dprint.json", "dprint.jsonc"],
71 cmd: ["deno", "fmt", "--", "$files"],
72 stdin: (file) => ["deno", "fmt", "--ext", path.extname(file).slice(1), "-"],
73 },
74 prettier: {
75 languages: ["javascript", "markdown", "json", "yaml", "mdx", "html", "css"],
76 files: ["node_modules/.bin/prettier"],
77 cmd: ["node_modules/.bin/prettier", "--write", "--", "$files"],
78 stdin: (file) => ["node_modules/.bin/prettier", "--stdin-filepath", file],
79 },
80 biome: {
81 languages: ["javascript", "json", "css"],
82 files: ["node_modules/.bin/biome"],
83 cmd: ["node_modules/.bin/biome", "format", "--", "$files"],
84 stdin: (file) => [
85 "node_modules/bin/biome",
86 "format",
87 `--stdin-file-path=${file}`,
88 ],
89 },
90 "nixfmt-rfc-style": {
91 languages: ["nix"],
92 files: {
93 "flake.nix": (contents) => contents.includes("nixfmt"),
94 },
95 cmd: ["nixfmt", "--", "$files"],
96 stdin: (file) => ["nixfmt", `--filename=${file}`],
97 },
98 alejandra: {
99 languages: ["nix"],
100 files: {
101 "flake.nix": (contents) => contents.includes("alejandra"),
102 },
103 cmd: ["alejandra", "--", "$files"],
104 stdin: () => ["alejandra"],
105 },
106 clang: {
107 languages: ["c", "cpp"],
108 files: true,
109 cmd: ["clang-format", "--", "$files"],
110 stdin: (file) => ["clang-format", `--assume-filename=${file}`],
111 },
112 zig: {
113 languages: ["zig"],
114 files: true,
115 cmd: ["zig", "fmt", "$files"],
116 stdin: (file) => [
117 "zig",
118 "fmt",
119 "--stdin",
120 ...file.endsWith(".zon") ? ["--zon"] : [],
121 ],
122 },
123 rustfmt: {
124 languages: ["rust"],
125 files: true,
126 cmd: ["rustfmt", "--", "$files"],
127 stdin: () => ["rustfmt"],
128 },
129 taplo: {
130 languages: ["toml"],
131 files: ["taplo.toml"],
132 cmd: ["taplo", "format", "--", "$files"],
133 cmd: () => ["taplo", "format", "-"],
134 },
135};
136
137// -- cli --
138if (!fs.globSync) {
139 console.error(`error: autofmt must be run with Node.js v22 or newer`);
140 process.exit(1);
141}
142const [, bin, ...argv] = process.argv;
143let inputs = [];
144let globalExclude = [];
145let gitignore = true;
146let dryRun = false;
147let stdio = null;
148let excludes = [".git"];
149while (argv.length > 0) {
150 const arg = argv.shift();
151 if (arg === "-h" || arg === "--help") usage();
152 else if (arg === "--no-gitignore") gitignore = false;
153 else if (arg === "--dry-run") dryRun = true;
154 else if (arg.match(/^--stdio=./)) {
155 if (stdio) {
156 console.error("error: can only pass --stdio once");
157 usage();
158 }
159 stdio = arg.slice("--stdio=".length);
160 } else if (arg === "--stdio") {
161 const value = argv.shift();
162 if (!value) {
163 console.error("error: missing value for --stdio");
164 usage();
165 }
166 if (stdio) {
167 console.error("error: can only pass --stdio once");
168 usage();
169 }
170 stdio = value;
171 } else if (arg.match(/^--exclude=./)) {
172 excludes.push(arg.slice("--exclude=".length));
173 } else if (arg === "--") {
174 inputs.push(...argv);
175 break;
176 } else if (arg.startsWith("-")) {
177 console.error("error: unknown option " + JSON.stringify(arg));
178 usage();
179 } else inputs.push(arg);
180}
181function usage() {
182 const exe = path.basename(bin);
183 console.error(`usage: ${exe} [...files or directories]`);
184 console.error(``);
185 console.error(`uses the right formatter for the job (by scanning config)`);
186 console.error(`when autofmt reads dirs, it will respect gitignore`);
187 console.error(``);
188 console.error(`to format current directory recursively, run '${exe} .'`);
189 console.error(``);
190 console.error(`options:`);
191 console.error(` --dry-run print commands instead of running them`);
192 console.error(` --no-gitignore do not read '.gitignore'`);
193 console.error(` --exclude=<glob> add an exclusion glob`);
194 console.error(` --stdio=<filename> read/write contents via stdin/stdout`);
195 console.error(``);
196 process.exit(1);
197}
198if (inputs.length === 0 && !stdio) usage();
199if (stdio && inputs.length > 0) {
200 console.error("error: stdio mode only operates on one file");
201 process.exit(1);
202}
203const { sep } = path;
204
205// -- disable warnings --
206const { emit: originalEmit } = process;
207const warnings = ["ExperimentalWarning"];
208process.emit = function (event, error) {
209 return event === "warning" && warnings.includes(error.name)
210 ? false
211 : originalEmit.apply(process, arguments);
212};
213
214// -- vars --
215const extToLanguage = Object.fromEntries(
216 Object.entries(extensions).flatMap(([lang, exts]) =>
217 exts.map((ext) => [ext, lang])
218 ),
219);
220const multis = Object.keys(formatters).filter(
221 (x) => formatters[x].languages === true,
222);
223const files = [];
224const gitignores = new Map();
225const dirs = new Map();
226const cached = new Map();
227
228// -- stdin mode --
229if (stdio) {
230 const fmtWithPath = pickFormatter(stdio);
231 if (!fmtWithPath) {
232 console.error(`No formatter configured for ${path.relative(".", stdio)}`);
233 process.exit(1);
234 }
235 let [fmt, cwd] = fmtWithPath.split("\0");
236 let cmd = formatters[fmt].stdin(stdio);
237 cwd ??= process.cwd();
238 if (dryRun) {
239 console.info(cmd.join(" "));
240 process.exit(0);
241 }
242 const proc = child_process.spawn(cmd[0], cmd.slice(1), {
243 stdio: ["inherit", "inherit", "inherit"],
244 });
245 proc.on("error", (e) => {
246 let message = "";
247 if (e?.code === "ENOENT") {
248 message = `${cmd[0]} is not installed`;
249 } else {
250 message = String(e?.message ?? e);
251 }
252 console.error(`error: ${message}`);
253 process.exit(1);
254 });
255 const [code] = await events.once(proc, "exit");
256 process.exit(code ?? 1);
257}
258
259// -- decide what formatters to run
260inputs = inputs.map((x) => path.resolve(x));
261inputs.forEach(walk);
262const toRun = new Map();
263for (const file of new Set(files)) {
264 const fmt = pickFormatter(file);
265 if (!fmt) {
266 if (inputs.includes(file)) {
267 console.warn(`No formatter configured for ${path.relative(".", file)}`);
268 }
269 continue;
270 }
271 let list = toRun.get(fmt);
272 list ?? toRun.set(fmt, list = []);
273 list.push(file);
274}
275
276// -- create a list of commands --
277const commands = [];
278let totalFiles = 0;
279for (const [fmtWithPath, files] of toRun) {
280 let [fmt, cwd] = fmtWithPath.split("\0");
281 let { cmd } = formatters[fmt];
282 if (cwd) cmd = [path.join(cwd, cmd[0]), ...cmd.slice(1)];
283 cwd ??= process.cwd();
284 let i = 0;
285 totalFiles += files.length;
286 if ((i = cmd.indexOf("$files")) != -1) {
287 const c = cmd.slice();
288 c.splice(i, 1, ...files);
289 commands.push({ cmd: c, cwd, files });
290 } else if ((i = cmd.indexOf("$file")) != -1) {
291 for (const file of files) {
292 const c = cmd.slice();
293 c.splice(i, 1, file);
294 commands.push({ cmd: c, cwd, files: [file] });
295 }
296 } else {
297 throw new Error(`Formatter ${fmt} has incorrectly configured command.`);
298 }
299}
300if (commands.length === 0) {
301 console.error("No formattable files");
302 process.exit(0);
303}
304
305// -- dry run mode --
306if (dryRun) {
307 for (const { cmd } of commands) {
308 console.info(cmd);
309 }
310 process.exit(0);
311}
312
313// -- user interface --
314let filesComplete = 0;
315let lastFile = commands[0].cmd;
316const syncStart = "\u001B[?2026h";
317const syncEnd = "\u001B[?2026l";
318let buffer = "";
319let statusVisible = false;
320const tty = process.stderr.isTTY;
321function writeStatus() {
322 if (!tty) return;
323 clearStatus();
324 buffer ||= syncStart;
325 buffer += `${filesComplete}/${totalFiles} - ${lastFile}`;
326 statusVisible = true;
327}
328function clearStatus() {
329 if (!tty) return;
330 if (!statusVisible) return;
331 buffer ||= syncStart;
332 buffer += "\r\x1b[2K\r";
333 statusVisible = false;
334}
335function flush() {
336 if (!buffer) return;
337 const width = Math.max(1, process.stderr.columns - 1);
338 process.stderr.write(
339 buffer.split("\n").map((x) => x.slice(0, width)).join("\n") + syncEnd,
340 );
341 buffer = "";
342}
343
344// -- async process queue --
345let running = 0;
346/** @param cmd {{ cmd: string, cwd: string, files: string[] }} */
347function run({ cmd, cwd, files }) {
348 running += 1;
349 let c = child_process.spawn(cmd[0], cmd.slice(1), {
350 stdio: ["ignore", "pipe", "pipe"],
351 cwd,
352 });
353 const relatives = new Set(
354 files.map((file) => file.startsWith(cwd) ? path.relative(cwd, file) : file),
355 );
356 function onLine(line) {
357 for (const file of relatives) {
358 if (line.includes(file)) {
359 filesComplete += 1;
360 relatives.delete(file);
361 lastFile = path.relative(process.cwd(), path.resolve(cwd, file));
362 if (tty) {
363 writeStatus();
364 flush();
365 } else {
366 console.info(lastFile);
367 }
368 return;
369 }
370 }
371 }
372 let errBuffer = "";
373 let exited = false;
374 c.on("error", (e) => {
375 let message = "";
376 if (e?.code === "ENOENT") {
377 if (cmd[0].includes("/")) {
378 running -= 1;
379 run({ cmd: [path.basename(cmd[0]), ...cmd.slice(1)], cwd, files });
380 return;
381 }
382 message = `${cmd[0]} is not installed`;
383 } else {
384 message = String(e?.message ?? e);
385 }
386 clearStatus();
387 const filesConcise =
388 path.relative(".", path.resolve(cwd, relatives.keys().next().value)) + (
389 relatives.size > 1 ? ` and ${relatives.size - 1} more` : ""
390 );
391 buffer += errBuffer + `error: ${message}, cannot format ${filesConcise}.\n`;
392 flush();
393 exited = true;
394 runNext();
395 });
396 readline.createInterface(c.stderr).addListener("line", (line) => {
397 errBuffer += line + "\n";
398 onLine(line);
399 });
400 readline.createInterface(c.stdout).addListener("line", onLine);
401 c.on("exit", (code, signal) => {
402 if (exited) return;
403 exited = true;
404 if (code !== 0) {
405 clearStatus();
406 const exitStatus = code != null ? `code ${code}` : `signal ${signal}`;
407 buffer += errBuffer + `error: ${cmd[0]} exited with ${exitStatus}\n`;
408 flush();
409 } else {
410 filesComplete += relatives.size;
411 if (relatives.size) {
412 lastFile = path.relative(
413 ".",
414 path.resolve(cwd, relatives.keys().next().value),
415 );
416 }
417 writeStatus();
418 flush();
419 }
420 runNext();
421 });
422 function runNext() {
423 running -= 1;
424 const next = commands.pop();
425 if (next) run(next);
426 else if (running == 0) {
427 clearStatus();
428 flush();
429 console.info(
430 `Formatted ${filesComplete} file${filesComplete !== 1 ? "s" : ""}`,
431 );
432 }
433 }
434}
435
436for (let i = 0; i < navigator.hardwareConcurrency; i++) {
437 const cmd = commands.pop();
438 if (cmd) run(cmd);
439 else break;
440}
441
442// -- library functions --
443
444/** @param file {string} */
445function walk(file) {
446 file = path.resolve(file);
447 try {
448 if (fs.statSync(file).isDirectory()) {
449 const exclude = getGitIgnores(file);
450 const read = fs
451 .globSync(escapeGlob(file) + "/{**,.**}", {
452 exclude,
453 withFileTypes: true,
454 })
455 .filter((file) => !file.isDirectory())
456 .map((file) => path.join(file.parentPath, file.name));
457 files.push(...read);
458 } else {
459 files.push(file);
460 }
461 } catch (err) {
462 console.error(
463 `Failed to stat ${file}: ${err?.code ?? err?.message ?? err}`,
464 );
465 process.exit(1);
466 }
467}
468
469/** @param dir {string} @returns {Array<string>} */
470function readDir(dir) {
471 dir = path.resolve(dir);
472 let contents = dirs.get(dir);
473 if (!contents) {
474 try {
475 contents = fs.readdirSync(dir);
476 } catch {
477 contents = [];
478 }
479 dirs.set(dir, contents);
480 }
481 return contents;
482}
483
484/** @param dir {string} @returns {string} */
485function pickFormatter(file) {
486 const lang = extToLanguage[path.extname(file)];
487 const dir = path.dirname(file);
488 let c = walkUp(dir, (x) => cached.get(x) ?? cached.get(`${x}:${lang}`));
489 if (c) return c;
490
491 const possible = Object.keys(formatters).filter(
492 (x) =>
493 Array.isArray(formatters[x].languages) &&
494 formatters[x].languages.includes(lang),
495 );
496 const order = [...multis, ...possible];
497 return walkUp(dir, (x) => {
498 const children = readDir(x);
499 for (const fmt of order) {
500 let matches = false;
501 if (formatters[fmt].files === true) {
502 matches = true;
503 }
504 if (!matches) {
505 const filesToCheck = Array.isArray(formatters[fmt].files)
506 ? formatters[fmt].files.map((base) => ({ base, contents: true }))
507 : Object.entries(formatters[fmt].files)
508 .map(([base, contents]) => ({ base, contents }));
509 for (const { base, contents } of filesToCheck) {
510 if (base.includes("/")) {
511 matches = readDir(path.join(x, path.dirname(base))) //
512 .includes(path.basename(base));
513 } else {
514 matches = children.includes(base);
515 }
516 if (matches && typeof contents === "function") {
517 matches = !!contents(fs.readFileSync(path.join(x, base), "utf-8"));
518 }
519 }
520 }
521 if (matches) {
522 const formatterId = `${fmt}\0${x}`;
523 const k = multis.includes(fmt) ? x : `${x}:${lang}`;
524 cached.set(k, formatterId);
525 return formatterId;
526 }
527 }
528 }) ?? possible[0];
529}
530
531/** @param dir {string} @param find {(x: string) => any} */
532function walkUp(dir, find) {
533 do {
534 const found = find(dir);
535 if (found != null) return found;
536 const parent = path.dirname(dir);
537 if (parent === dir) break;
538 dir = parent;
539 } while (true);
540 return null;
541}
542
543/** @param dir {string} */
544function getGitIgnores(dir) {
545 if (!gitignore) return [];
546 if (dir.endsWith(sep)) dir = dir.slice(0, -1);
547 const files = fs.globSync(`${dir}${sep}{**${sep}*${sep},}.gitignore`, {});
548 const referenced = [];
549 for (const abs of files) {
550 const dir = path.dirname(abs);
551 referenced.push(dir);
552 if (!gitignores.has(dir)) gitignores.set(dir, readGitIgnore(dir));
553 }
554 do {
555 const parent = path.dirname(dir);
556 if (parent === dir || fs.existsSync(path.join(dir, ".git"))) break;
557 referenced.push(parent);
558 if (!gitignores.has(parent)) gitignores.set(parent, readGitIgnore(parent));
559 dir = parent;
560 } while (true);
561 return referenced.flatMap((root) =>
562 (gitignores.get(root) ?? [])
563 .filter((x) => x[0] !== "!")
564 .map(
565 (rule) => `${root}${sep}${rule[0] === "/" ? "" : `**${sep}`}${rule}`,
566 )
567 );
568}
569
570/** @param dir {string} */
571function readGitIgnore(dir) {
572 try {
573 return fs
574 .readFileSync(`${dir}${sep}.gitignore`, "utf-8")
575 .split("\n")
576 .map((line) => line.replace(/#.*$/, "").trim())
577 .filter(Boolean);
578 } catch {
579 return [];
580 }
581}
582
583function escapeGlob(str) {
584 return str.replace(/[\\*,{}]/g, "\\$&");
585}
586
587import * as child_process from "node:child_process";
588import * as fs from "node:fs";
589import * as path from "node:path";
590import * as readline from "node:readline";
591import * as events from "node:events";
592import process from "node:process";
593import assert from "node:assert";
flake.nix+1-1
...@@ -69,7 +69,7 @@...@@ -69,7 +69,7 @@
6969
70 # custom packages70 # custom packages
71 (_: pkgs: {71 (_: pkgs: {
72 autofmt = pkgs.callPackage ./packages/autofmt.nix { };72 autofmt = pkgs.callPackage ./packages/autofmt { };
73 })73 })
74 ];74 ];
7575
modules/neovim/default.nix+3
...@@ -120,6 +120,9 @@...@@ -120,6 +120,9 @@
120 enable = true;120 enable = true;
121 setupOpts = {121 setupOpts = {
122 fzf_colors = true;122 fzf_colors = true;
123 keymap.fzf = {
124 "ctrl-q" = "select-all+accept";
125 };
123 };126 };
124 };127 };
125 autocomplete.blink-cmp = {128 autocomplete.blink-cmp = {
modules/neovim/keybind.nix+5-4
...@@ -24,11 +24,12 @@ in...@@ -24,11 +24,12 @@ in
2424
25 # pickers25 # pickers
26 pick-file = keyCmd "n" "<leader><leader>" "FzfLua files";26 pick-file = keyCmd "n" "<leader><leader>" "FzfLua files";
27 pick-buffer = keyCmd "n" "<leader>b" "FzfLua buffers";
27 pick-mark = keyCmd "n" "<leader>'" "FzfLua marks";28 pick-mark = keyCmd "n" "<leader>'" "FzfLua marks";
28 #pick-buffer = keyCmd "n" "<leader>b" "FzfLua buffers";
29 pick-grep = keyCmd "n" "<leader>ff" "FzfLua grep_project";
30 pick-recent-command = keyCmd "n" "<leader>fc" "FzfLua command_history";29 pick-recent-command = keyCmd "n" "<leader>fc" "FzfLua command_history";
31 pick-other = keyCmd "n" "<leader>f?" "FzfLua builtin"; # picker of Fzf pickers30 pick-other = keyCmd "n" "<leader><tab>" "FzfLua builtin"; # picker of Fzf pickers
31 find-fuzzy = keyCmd "n" "<leader>ff" "FzfLua grep_project";
32 find-grep = keyCmd "n" "<leader>fg" "FzfLua live_grep";
3233
33 # lsp34 # lsp
34 code-action =35 code-action =
...@@ -38,7 +39,7 @@ in...@@ -38,7 +39,7 @@ in
38 # subtle nice features39 # subtle nice features
39 visual-dedent = keyRemap "v" "<" "<gv"; # keep selection40 visual-dedent = keyRemap "v" "<" "<gv"; # keep selection
40 visual-indent = keyRemap "v" ">" ">gv"; # keep selection41 visual-indent = keyRemap "v" ">" ">gv"; # keep selection
41 clear-search-highlights = keyRemap "n" "<esc" ":noh<Return><esc>";42 clear-search-highlights = keyRemap "n" "<esc>" ":noh<Return><esc>";
42 };43 };
4344
44 # implementation45 # implementation
nvim+1-1
...@@ -11,4 +11,4 @@ if [ -z "$name" ]; then...@@ -11,4 +11,4 @@ if [ -z "$name" ]; then
11 echo "Configure this wrapper script with your name." >&211 echo "Configure this wrapper script with your name." >&2
12 exit 112 exit 1
13fi13fi
14exec nix run ".#nvim-$name" -- "$@"14exec nix run "$(dirname "$0")#nvim-$name" -- "$@"
packages/autofmt.nix deleted-14
...@@ -1,14 +0,0 @@
1{ pkgs, ... }:
2pkgs.writeShellApplication {
3 name = "autofmt";
4 runtimeInputs = with pkgs; [
5 # include only a couple of formatters by default
6 deno
7 nixfmt-rfc-style
8 dprint
9 rustfmt
10 zig
11 clang-tools
12 ];
13 text = ''exec deno run -A ${../files/autofmt.js} "$@"'';
14}
packages/autofmt/autofmt.js created+594
...@@ -0,0 +1,594 @@
1#!/usr/bin/env node
2// autofmt v1 by paper clover
3// https://paperclover.dev/nix/config/src/branch/main/packages/autofmt/autofmt.js
4//
5// Different codebases use different formatters. Autofmt looks for project
6// configuration to pick the correct formatter, allowing an editor to simply
7// point to this script and ambiguities resolved. This single file program
8// depends only on Node.js v22.
9//
10// When using this repository's Nix-based Neovim configuration, autofmt is
11// automatically configured as the default formatter. To configure manually,
12// set the editor's formatter to run `autofmt --stdio=<filename>`. The filename
13// is used to determine which formatter to invoke.
14//
15// With `conform.nvim`:
16// formatters = {
17// autofmt = { command = "autofmt" }
18// }
19//
20// With Zed:
21// "formatter": {
22// "external": {
23// "command": "autofmt",
24// "arguments": ["--stdio", "{buffer_path}"]
25// }
26// },
27//
28// @ts-nocheck
29
30// -- definitions --
31const extensions = {
32 c: [".c", ".h"],
33 cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".hh"],
34 css: [".css"],
35 html: [".html"],
36 javascript: [".js", ".ts", ".cjs", ".cts", ".mjs", ".mts", ".jsx", ".tsx"],
37 json: [".json", ".jsonc"],
38 markdown: [".md", ".markdown"],
39 mdx: [".mdx"],
40 nix: [".nix"],
41 rust: [".rs"],
42 toml: [".toml"],
43 yaml: [".yml", ".yaml"],
44 zig: [".zig"],
45};
46const formatters = {
47 // this object is sorted by priority
48 //
49 // `languages: true` are multiplexers that apply to all listed
50 // languages, as it is assumed a project will setup their multiplexer
51 // correctly for all tracked files.
52 //
53 // if a formatter doesnt match a config file, the first one is picked
54 // as a default, making things like `deno fmt file.ts` or `clang-format`
55 // the default when there are no config files.
56 dprint: {
57 languages: true,
58 files: ["dprint.json", "dprint.jsonc"],
59 cmd: ["dprint", "fmt", "--", "$files"],
60 stdin: (file) => ["dprint", "fmt", "--stdin", file],
61 },
62 treefmt: {
63 languages: true,
64 files: ["treefmt.toml", ".treefmt.toml"],
65 cmd: ["treefmt", "--", "$files"],
66 stdin: (file) => ["treefmt", "--stdin", file],
67 },
68 // -- web ecosystem --
69 deno: {
70 languages: ["javascript", "markdown", "json", "yaml", "html", "css"],
71 files: ["dprint.json", "dprint.jsonc"],
72 cmd: ["deno", "fmt", "--", "$files"],
73 stdin: (file) => ["deno", "fmt", "--ext", path.extname(file).slice(1), "-"],
74 },
75 prettier: {
76 languages: ["javascript", "markdown", "json", "yaml", "mdx", "html", "css"],
77 files: ["node_modules/.bin/prettier"],
78 cmd: ["node_modules/.bin/prettier", "--write", "--", "$files"],
79 stdin: (file) => ["node_modules/.bin/prettier", "--stdin-filepath", file],
80 },
81 biome: {
82 languages: ["javascript", "json", "css"],
83 files: ["node_modules/.bin/biome"],
84 cmd: ["node_modules/.bin/biome", "format", "--", "$files"],
85 stdin: (file) => [
86 "node_modules/bin/biome",
87 "format",
88 `--stdin-file-path=${file}`,
89 ],
90 },
91 "nixfmt-rfc-style": {
92 languages: ["nix"],
93 files: {
94 "flake.nix": (contents) => contents.includes("nixfmt"),
95 },
96 cmd: ["nixfmt", "--", "$files"],
97 stdin: (file) => ["nixfmt", `--filename=${file}`],
98 },
99 alejandra: {
100 languages: ["nix"],
101 files: {
102 "flake.nix": (contents) => contents.includes("alejandra"),
103 },
104 cmd: ["alejandra", "--", "$files"],
105 stdin: () => ["alejandra"],
106 },
107 clang: {
108 languages: ["c", "cpp"],
109 files: true,
110 cmd: ["clang-format", "--", "$files"],
111 stdin: (file) => ["clang-format", `--assume-filename=${file}`],
112 },
113 zig: {
114 languages: ["zig"],
115 files: true,
116 cmd: ["zig", "fmt", "$files"],
117 stdin: (file) => [
118 "zig",
119 "fmt",
120 "--stdin",
121 ...file.endsWith(".zon") ? ["--zon"] : [],
122 ],
123 },
124 rustfmt: {
125 languages: ["rust"],
126 files: true,
127 cmd: ["rustfmt", "--", "$files"],
128 stdin: () => ["rustfmt"],
129 },
130 taplo: {
131 languages: ["toml"],
132 files: ["taplo.toml"],
133 cmd: ["taplo", "format", "--", "$files"],
134 cmd: () => ["taplo", "format", "-"],
135 },
136};
137
138// -- cli --
139if (!fs.globSync) {
140 console.error(`error: autofmt must be run with Node.js v22 or newer`);
141 process.exit(1);
142}
143const [, bin, ...argv] = process.argv;
144let inputs = [];
145let globalExclude = [];
146let gitignore = true;
147let dryRun = false;
148let stdio = null;
149let excludes = [".git"];
150while (argv.length > 0) {
151 const arg = argv.shift();
152 if (arg === "-h" || arg === "--help") usage();
153 else if (arg === "--no-gitignore") gitignore = false;
154 else if (arg === "--dry-run") dryRun = true;
155 else if (arg.match(/^--stdio=./)) {
156 if (stdio) {
157 console.error("error: can only pass --stdio once");
158 usage();
159 }
160 stdio = arg.slice("--stdio=".length);
161 } else if (arg === "--stdio") {
162 const value = argv.shift();
163 if (!value) {
164 console.error("error: missing value for --stdio");
165 usage();
166 }
167 if (stdio) {
168 console.error("error: can only pass --stdio once");
169 usage();
170 }
171 stdio = value;
172 } else if (arg.match(/^--exclude=./)) {
173 excludes.push(arg.slice("--exclude=".length));
174 } else if (arg === "--") {
175 inputs.push(...argv);
176 break;
177 } else if (arg.startsWith("-")) {
178 console.error("error: unknown option " + JSON.stringify(arg));
179 usage();
180 } else inputs.push(arg);
181}
182function usage() {
183 const exe = path.basename(bin);
184 console.error(`usage: ${exe} [...files or directories]`);
185 console.error(``);
186 console.error(`uses the right formatter for the job (by scanning config)`);
187 console.error(`when autofmt reads dirs, it will respect gitignore`);
188 console.error(``);
189 console.error(`to format current directory recursively, run '${exe} .'`);
190 console.error(``);
191 console.error(`options:`);
192 console.error(` --dry-run print commands instead of running them`);
193 console.error(` --no-gitignore do not read '.gitignore'`);
194 console.error(` --exclude=<glob> add an exclusion glob`);
195 console.error(` --stdio=<filename> read/write contents via stdin/stdout`);
196 console.error(``);
197 process.exit(1);
198}
199if (inputs.length === 0 && !stdio) usage();
200if (stdio && inputs.length > 0) {
201 console.error("error: stdio mode only operates on one file");
202 process.exit(1);
203}
204const { sep } = path;
205
206// -- disable warnings --
207const { emit: originalEmit } = process;
208const warnings = ["ExperimentalWarning"];
209process.emit = function (event, error) {
210 return event === "warning" && warnings.includes(error.name)
211 ? false
212 : originalEmit.apply(process, arguments);
213};
214
215// -- vars --
216const extToLanguage = Object.fromEntries(
217 Object.entries(extensions).flatMap(([lang, exts]) =>
218 exts.map((ext) => [ext, lang])
219 ),
220);
221const multis = Object.keys(formatters).filter(
222 (x) => formatters[x].languages === true,
223);
224const files = [];
225const gitignores = new Map();
226const dirs = new Map();
227const cached = new Map();
228
229// -- stdin mode --
230if (stdio) {
231 const fmtWithPath = pickFormatter(stdio);
232 if (!fmtWithPath) {
233 console.error(`No formatter configured for ${path.relative(".", stdio)}`);
234 process.exit(1);
235 }
236 let [fmt, cwd] = fmtWithPath.split("\0");
237 let cmd = formatters[fmt].stdin(stdio);
238 cwd ??= process.cwd();
239 if (dryRun) {
240 console.info(cmd.join(" "));
241 process.exit(0);
242 }
243 const proc = child_process.spawn(cmd[0], cmd.slice(1), {
244 stdio: ["inherit", "inherit", "inherit"],
245 });
246 proc.on("error", (e) => {
247 let message = "";
248 if (e?.code === "ENOENT") {
249 message = `${cmd[0]} is not installed`;
250 } else {
251 message = String(e?.message ?? e);
252 }
253 console.error(`error: ${message}`);
254 process.exit(1);
255 });
256 const [code] = await events.once(proc, "exit");
257 process.exit(code ?? 1);
258}
259
260// -- decide what formatters to run
261inputs = inputs.map((x) => path.resolve(x));
262inputs.forEach(walk);
263const toRun = new Map();
264for (const file of new Set(files)) {
265 const fmt = pickFormatter(file);
266 if (!fmt) {
267 if (inputs.includes(file)) {
268 console.warn(`No formatter configured for ${path.relative(".", file)}`);
269 }
270 continue;
271 }
272 let list = toRun.get(fmt);
273 list ?? toRun.set(fmt, list = []);
274 list.push(file);
275}
276
277// -- create a list of commands --
278const commands = [];
279let totalFiles = 0;
280for (const [fmtWithPath, files] of toRun) {
281 let [fmt, cwd] = fmtWithPath.split("\0");
282 let { cmd } = formatters[fmt];
283 if (cwd) cmd = [path.join(cwd, cmd[0]), ...cmd.slice(1)];
284 cwd ??= process.cwd();
285 let i = 0;
286 totalFiles += files.length;
287 if ((i = cmd.indexOf("$files")) != -1) {
288 const c = cmd.slice();
289 c.splice(i, 1, ...files);
290 commands.push({ cmd: c, cwd, files });
291 } else if ((i = cmd.indexOf("$file")) != -1) {
292 for (const file of files) {
293 const c = cmd.slice();
294 c.splice(i, 1, file);
295 commands.push({ cmd: c, cwd, files: [file] });
296 }
297 } else {
298 throw new Error(`Formatter ${fmt} has incorrectly configured command.`);
299 }
300}
301if (commands.length === 0) {
302 console.error("No formattable files");
303 process.exit(0);
304}
305
306// -- dry run mode --
307if (dryRun) {
308 for (const { cmd } of commands) {
309 console.info(cmd);
310 }
311 process.exit(0);
312}
313
314// -- user interface --
315let filesComplete = 0;
316let lastFile = commands[0].cmd;
317const syncStart = "\u001B[?2026h";
318const syncEnd = "\u001B[?2026l";
319let buffer = "";
320let statusVisible = false;
321const tty = process.stderr.isTTY;
322function writeStatus() {
323 if (!tty) return;
324 clearStatus();
325 buffer ||= syncStart;
326 buffer += `${filesComplete}/${totalFiles} - ${lastFile}`;
327 statusVisible = true;
328}
329function clearStatus() {
330 if (!tty) return;
331 if (!statusVisible) return;
332 buffer ||= syncStart;
333 buffer += "\r\x1b[2K\r";
334 statusVisible = false;
335}
336function flush() {
337 if (!buffer) return;
338 const width = Math.max(1, process.stderr.columns - 1);
339 process.stderr.write(
340 buffer.split("\n").map((x) => x.slice(0, width)).join("\n") + syncEnd,
341 );
342 buffer = "";
343}
344
345// -- async process queue --
346let running = 0;
347/** @param cmd {{ cmd: string, cwd: string, files: string[] }} */
348function run({ cmd, cwd, files }) {
349 running += 1;
350 let c = child_process.spawn(cmd[0], cmd.slice(1), {
351 stdio: ["ignore", "pipe", "pipe"],
352 cwd,
353 });
354 const relatives = new Set(
355 files.map((file) => file.startsWith(cwd) ? path.relative(cwd, file) : file),
356 );
357 function onLine(line) {
358 for (const file of relatives) {
359 if (line.includes(file)) {
360 filesComplete += 1;
361 relatives.delete(file);
362 lastFile = path.relative(process.cwd(), path.resolve(cwd, file));
363 if (tty) {
364 writeStatus();
365 flush();
366 } else {
367 console.info(lastFile);
368 }
369 return;
370 }
371 }
372 }
373 let errBuffer = "";
374 let exited = false;
375 c.on("error", (e) => {
376 let message = "";
377 if (e?.code === "ENOENT") {
378 if (cmd[0].includes("/")) {
379 running -= 1;
380 run({ cmd: [path.basename(cmd[0]), ...cmd.slice(1)], cwd, files });
381 return;
382 }
383 message = `${cmd[0]} is not installed`;
384 } else {
385 message = String(e?.message ?? e);
386 }
387 clearStatus();
388 const filesConcise =
389 path.relative(".", path.resolve(cwd, relatives.keys().next().value)) + (
390 relatives.size > 1 ? ` and ${relatives.size - 1} more` : ""
391 );
392 buffer += errBuffer + `error: ${message}, cannot format ${filesConcise}.\n`;
393 flush();
394 exited = true;
395 runNext();
396 });
397 readline.createInterface(c.stderr).addListener("line", (line) => {
398 errBuffer += line + "\n";
399 onLine(line);
400 });
401 readline.createInterface(c.stdout).addListener("line", onLine);
402 c.on("exit", (code, signal) => {
403 if (exited) return;
404 exited = true;
405 if (code !== 0) {
406 clearStatus();
407 const exitStatus = code != null ? `code ${code}` : `signal ${signal}`;
408 buffer += errBuffer + `error: ${cmd[0]} exited with ${exitStatus}\n`;
409 flush();
410 } else {
411 filesComplete += relatives.size;
412 if (relatives.size) {
413 lastFile = path.relative(
414 ".",
415 path.resolve(cwd, relatives.keys().next().value),
416 );
417 }
418 writeStatus();
419 flush();
420 }
421 runNext();
422 });
423 function runNext() {
424 running -= 1;
425 const next = commands.pop();
426 if (next) run(next);
427 else if (running == 0) {
428 clearStatus();
429 flush();
430 console.info(
431 `Formatted ${filesComplete} file${filesComplete !== 1 ? "s" : ""}`,
432 );
433 }
434 }
435}
436
437for (let i = 0; i < navigator.hardwareConcurrency; i++) {
438 const cmd = commands.pop();
439 if (cmd) run(cmd);
440 else break;
441}
442
443// -- library functions --
444
445/** @param file {string} */
446function walk(file) {
447 file = path.resolve(file);
448 try {
449 if (fs.statSync(file).isDirectory()) {
450 const exclude = getGitIgnores(file);
451 const read = fs
452 .globSync(escapeGlob(file) + "/{**,.**}", {
453 exclude,
454 withFileTypes: true,
455 })
456 .filter((file) => !file.isDirectory())
457 .map((file) => path.join(file.parentPath, file.name));
458 files.push(...read);
459 } else {
460 files.push(file);
461 }
462 } catch (err) {
463 console.error(
464 `Failed to stat ${file}: ${err?.code ?? err?.message ?? err}`,
465 );
466 process.exit(1);
467 }
468}
469
470/** @param dir {string} @returns {Array<string>} */
471function readDir(dir) {
472 dir = path.resolve(dir);
473 let contents = dirs.get(dir);
474 if (!contents) {
475 try {
476 contents = fs.readdirSync(dir);
477 } catch {
478 contents = [];
479 }
480 dirs.set(dir, contents);
481 }
482 return contents;
483}
484
485/** @param dir {string} @returns {string} */
486function pickFormatter(file) {
487 const lang = extToLanguage[path.extname(file)];
488 const dir = path.dirname(file);
489 let c = walkUp(dir, (x) => cached.get(x) ?? cached.get(`${x}:${lang}`));
490 if (c) return c;
491
492 const possible = Object.keys(formatters).filter(
493 (x) =>
494 Array.isArray(formatters[x].languages) &&
495 formatters[x].languages.includes(lang),
496 );
497 const order = [...multis, ...possible];
498 return walkUp(dir, (x) => {
499 const children = readDir(x);
500 for (const fmt of order) {
501 let matches = false;
502 if (formatters[fmt].files === true) {
503 matches = true;
504 }
505 if (!matches) {
506 const filesToCheck = Array.isArray(formatters[fmt].files)
507 ? formatters[fmt].files.map((base) => ({ base, contents: true }))
508 : Object.entries(formatters[fmt].files)
509 .map(([base, contents]) => ({ base, contents }));
510 for (const { base, contents } of filesToCheck) {
511 if (base.includes("/")) {
512 matches = readDir(path.join(x, path.dirname(base))) //
513 .includes(path.basename(base));
514 } else {
515 matches = children.includes(base);
516 }
517 if (matches && typeof contents === "function") {
518 matches = !!contents(fs.readFileSync(path.join(x, base), "utf-8"));
519 }
520 }
521 }
522 if (matches) {
523 const formatterId = `${fmt}\0${x}`;
524 const k = multis.includes(fmt) ? x : `${x}:${lang}`;
525 cached.set(k, formatterId);
526 return formatterId;
527 }
528 }
529 }) ?? possible[0];
530}
531
532/** @param dir {string} @param find {(x: string) => any} */
533function walkUp(dir, find) {
534 do {
535 const found = find(dir);
536 if (found != null) return found;
537 const parent = path.dirname(dir);
538 if (parent === dir) break;
539 dir = parent;
540 } while (true);
541 return null;
542}
543
544/** @param dir {string} */
545function getGitIgnores(dir) {
546 if (!gitignore) return [];
547 if (dir.endsWith(sep)) dir = dir.slice(0, -1);
548 const files = fs.globSync(`${dir}${sep}{**${sep}*${sep},}.gitignore`, {});
549 const referenced = [];
550 for (const abs of files) {
551 const dir = path.dirname(abs);
552 referenced.push(dir);
553 if (!gitignores.has(dir)) gitignores.set(dir, readGitIgnore(dir));
554 }
555 do {
556 const parent = path.dirname(dir);
557 if (parent === dir || fs.existsSync(path.join(dir, ".git"))) break;
558 referenced.push(parent);
559 if (!gitignores.has(parent)) gitignores.set(parent, readGitIgnore(parent));
560 dir = parent;
561 } while (true);
562 return referenced.flatMap((root) =>
563 (gitignores.get(root) ?? [])
564 .filter((x) => x[0] !== "!")
565 .map(
566 (rule) => `${root}${sep}${rule[0] === "/" ? "" : `**${sep}`}${rule}`,
567 )
568 );
569}
570
571/** @param dir {string} */
572function readGitIgnore(dir) {
573 try {
574 return fs
575 .readFileSync(`${dir}${sep}.gitignore`, "utf-8")
576 .split("\n")
577 .map((line) => line.replace(/#.*$/, "").trim())
578 .filter(Boolean);
579 } catch {
580 return [];
581 }
582}
583
584function escapeGlob(str) {
585 return str.replace(/[\\*,{}]/g, "\\$&");
586}
587
588import * as child_process from "node:child_process";
589import * as fs from "node:fs";
590import * as path from "node:path";
591import * as readline from "node:readline";
592import * as events from "node:events";
593import process from "node:process";
594import assert from "node:assert";
packages/autofmt/default.nix created+14
...@@ -0,0 +1,14 @@
1{ pkgs, ... }:
2pkgs.writeShellApplication {
3 name = "autofmt";
4 runtimeInputs = with pkgs; [
5 # include only a couple of formatters by default
6 deno
7 nixfmt-rfc-style
8 dprint
9 rustfmt
10 zig
11 clang-tools
12 ];
13 text = ''exec deno run -A ${./autofmt.js} "$@"'';
14}
users/fish/cattop/configuration.nix+24-1
...@@ -44,6 +44,25 @@...@@ -44,6 +44,25 @@
44 # Enable the GNOME Desktop Environment.44 # Enable the GNOME Desktop Environment.
45 services.xserver.displayManager.gdm.enable = true;45 services.xserver.displayManager.gdm.enable = true;
46 services.xserver.desktopManager.gnome.enable = true;46 services.xserver.desktopManager.gnome.enable = true;
47 environment.gnome.excludePackages = (
48 with pkgs;
49 [
50 atomix
51 cheese
52 epiphany
53 geary
54 gedit
55 gnome-characters
56 gnome-music
57 gnome-photos
58 gnome-terminal
59 gnome-tour
60 hitori
61 iagno
62 totem
63
64 ]
65 );
4766
48 # Configure keymap in X1167 # Configure keymap in X11
49 # services.xserver.xkb.layout = "us";68 # services.xserver.xkb.layout = "us";
...@@ -66,7 +85,10 @@...@@ -66,7 +85,10 @@
66 # Define a user account. Don't forget to set a password with ‘passwd’.85 # Define a user account. Don't forget to set a password with ‘passwd’.
67 users.users.fish = {86 users.users.fish = {
68 isNormalUser = true;87 isNormalUser = true;
69 extraGroups = [ "wheel" ]; # Enable ‘sudo’ for the user.88 extraGroups = [
89 "wheel" # Enable ‘sudo’ for the user.
90 "dialout" # Enable serial access for the user.
91 ];
70 packages = with pkgs; [92 packages = with pkgs; [
71 tree93 tree
72 git94 git
...@@ -77,6 +99,7 @@...@@ -77,6 +99,7 @@
77 };99 };
78100
79 programs.firefox.enable = true;101 programs.firefox.enable = true;
102 programs.neovim.defaultEditor.enable = true;
80103
81 # List packages installed in system profile.104 # List packages installed in system profile.
82 # You can use https://search.nixos.org/ to find more packages (and options).105 # You can use https://search.nixos.org/ to find more packages (and options).
users/fish/cattop/home.nix+8-18
...@@ -17,23 +17,14 @@...@@ -17,23 +17,14 @@
17 ghostty17 ghostty
18 stremio18 stremio
19 julia19 julia
20 qbittorrent
21 calibre20 calibre
22 mpv21 mpv
23 signal-desktop22 signal-desktop
24 python323 python3
25 gh24 gh
25 spotify
2626
27 # Gaming27 # Gaming
28 bottles
29 lutris
30 mangohud
31 dxvk_2
32 steam-run
33 vulkan-tools
34 path-of-building
35 wineWowPackages.stable
36 winetricks
37 (prismlauncher.override { gamemodeSupport = true; })28 (prismlauncher.override { gamemodeSupport = true; })
3829
39 # System & desktop tools30 # System & desktop tools
...@@ -47,18 +38,17 @@...@@ -47,18 +38,17 @@
47 grub238 grub2
48 efibootmgr39 efibootmgr
49 distrobox40 distrobox
41 pqiv
5042
51 # Dev tools43 # Dev tools
52 legcord44 legcord
53 hyfetch45 hyfetch
54 arduino-cli46 kicad
55 rust-bin.stable.latest.default47
56 tytools48 # Unsorted
57 inputs.zls.packages.x86_64-linux.zls49 ripgrep
58 platformio50 busybox
59 usbutils51 imagemagick
60 teensy-loader-cli
61 teensyduino
62 ];52 ];
63 };53 };
6454
users/fish/user.nix+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1rec {1rec {
2 username = "fish"; # username2 username = "fish"; # username
3 name = "Fish"; # name/identifier3 name = "Fish"; # name/identifier
4 email = "fish@fishcat.fish"; # email (used for certain configurations)4 email = "77413091+JulianBarbera@users.noreply.github.com"; # email (used for certain configurations)
5 dotfilesDir = "~/config"; # absolute path of the local repo5 dotfilesDir = "~/config"; # absolute path of the local repo
6 theme = "catppuccin-mocha"; # name of theme that stylix will use6 theme = "catppuccin-mocha"; # name of theme that stylix will use
7 browser = "firefox"; # Default browser; must select one from ./user/app/browser/7 browser = "firefox"; # Default browser; must select one from ./user/app/browser/