diff --git a/flake.nix b/flake.nix index 1284a2c3a95d402421224251a85c5080747ade08..b175ca2f7a34560757b8cfe1869b10c134fdba9a 100644 --- a/flake.nix +++ b/flake.nix @@ -71,6 +71,7 @@ # custom packages (_: pkgs: { autofmt = pkgs.callPackage ./packages/autofmt { }; + "jj-import-merge" = pkgs.callPackage ./packages/jj-import-merge { }; exiled_exchange_2 = pkgs.callPackage ./packages/exiled_exchange_2 { }; awakened_poe_trade = pkgs.callPackage ./packages/awakened_poe_trade { }; }) @@ -104,6 +105,7 @@ nvim-natalie = mkNeovim "natalie" pkgs; inherit (pkgs) autofmt; + "jj-import-merge" = pkgs."jj-import-merge"; } // lib.optionalAttrs (system == "aarch64-darwin") { # "nix run .#darwin-rebuild" diff --git a/packages/autofmt/autofmt.js b/packages/autofmt/autofmt.js index 7211f0b32c6e745f2c6807356e8d38b48cb79425..5bb0a0037307e72927401fa4f3db4859f9e45b26 100755 --- a/packages/autofmt/autofmt.js +++ b/packages/autofmt/autofmt.js @@ -100,12 +100,14 @@ const formatters = { }, prettier: { languages: ["javascript", "markdown", "json", "yaml", "mdx", "html", "css"], + mustMatchFile: true, files: ["node_modules/.bin/prettier"], cmd: ["node_modules/.bin/prettier", "--write", "--", "$files"], stdin: (file) => ["node_modules/.bin/prettier", "--stdin-filepath", file], }, biome: { languages: ["javascript", "json", "css"], + mustMatchFile: true, files: ["node_modules/.bin/biome"], cmd: ["node_modules/.bin/biome", "format", "--", "$files"], stdin: (file) => [ @@ -542,20 +544,22 @@ function pickFormatter(file) { : Object.entries(formatters[fmt].files) .map(([base, check]) => ({ base, check })); for (const { base, check } of filesToCheck) { + let fileMatches; if (base.includes("/")) { - matches = readDir(path.join(x, path.dirname(base))) // + fileMatches = readDir(path.join(x, path.dirname(base))) // .includes(path.basename(base)); } else { - matches = children.includes(base); + fileMatches = children.includes(base); } - if (matches && typeof check === "function") { + if (fileMatches && typeof check === "function") { let text; - matches = !!check({ + fileMatches = !!check({ get text() { return text ??= fs.readFileSync(path.join(x, base), "utf-8"); }, }); } + matches ||= fileMatches; } } if (matches) { diff --git a/packages/autofmt/default.nix b/packages/autofmt/default.nix index 45db7d83280d053850fcd1f77fc1a757e338ed38..64cebfb26f07b232564e8818e4732c0663c0dbd4 100644 --- a/packages/autofmt/default.nix +++ b/packages/autofmt/default.nix @@ -1,6 +1,5 @@ { pkgs, ... }: -pkgs.writeShellApplication { - name = "autofmt"; +let runtimeInputs = with pkgs; [ # include only a couple of formatters by default clang-tools @@ -11,5 +10,16 @@ pkgs.writeShellApplication { rustfmt zig ]; - text = ''exec ${pkgs.nodejs}/bin/node ${./autofmt.js} "$@"''; +in +pkgs.writeTextFile { + name = "autofmt"; + destination = "/bin/autofmt"; + executable = true; + text = builtins.replaceStrings + [ "#!/usr/bin/env node" ] + [ + ''#!${pkgs.nodejs}/bin/node + process.env.PATH = "${pkgs.lib.makeBinPath runtimeInputs}:" + (process.env.PATH ?? "");'' + ] + (builtins.readFile ./autofmt.js); } diff --git a/packages/jj-import-merge/default.nix b/packages/jj-import-merge/default.nix new file mode 100644 index 0000000000000000000000000000000000000000..281bac05ddff1e3ccdab9d2dfc39e9144749f80f --- /dev/null +++ b/packages/jj-import-merge/default.nix @@ -0,0 +1,9 @@ +{ pkgs, ... }: +pkgs.writeShellApplication { + name = "jj-import-merge"; + runtimeInputs = with pkgs; [ + autofmt + nodejs + ]; + text = ''exec ${pkgs.nodejs}/bin/node ${./jj-import-merge.js} "$@"''; +} diff --git a/packages/jj-import-merge/jj-import-merge.js b/packages/jj-import-merge/jj-import-merge.js new file mode 100644 index 0000000000000000000000000000000000000000..df56d84e771df5b1f29c3de95638a1c4eb6c84f4 --- /dev/null +++ b/packages/jj-import-merge/jj-import-merge.js @@ -0,0 +1,871 @@ +#!/usr/bin/env node +// jj-import-merge +// Merge top-of-file JS/TS imports from 2-sided Jujutsu conflicts. + +import * as child_process from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import process from "node:process"; + +const SUPPORTED_EXTENSIONS = new Set([ + ".cjs", + ".cts", + ".js", + ".jsx", + ".mjs", + ".mts", + ".ts", + ".tsx", +]); + +const CONFLICT_START_RE = /^<<<<<<< conflict \d+ of \d+/m; +const CONFLICT_END_RE = /^>>>>>>> conflict \d+ of \d+ ends$/m; + +main(); + +function main() { + const options = parseArgs(process.argv.slice(2)); + let files = options.files.map((file) => path.resolve(file)); + + if (options.allJj) { + files.push(...listJjConflictFiles().map((file) => path.resolve(file))); + } + + files = [...new Set(files)]; + if (files.length === 0) { + usage(1); + } + + let failed = false; + for (const file of files) { + try { + const result = processFile(file); + if (result.kind === "formatted") { + console.info(`formatted ${path.relative(process.cwd(), file)}`); + } else if (result.kind === "rewritten") { + console.info(`rewrote ${path.relative(process.cwd(), file)}`); + } + } catch (error) { + failed = true; + const message = error instanceof Error ? error.message : String(error); + console.error(`error: ${message}`); + } + } + + process.exit(failed ? 1 : 0); +} + +function parseArgs(argv) { + const options = { + allJj: false, + files: [], + }; + + while (argv.length > 0) { + const arg = argv.shift(); + if (arg === "-h" || arg === "--help") { + usage(0); + } else if (arg === "--all-jj") { + options.allJj = true; + } else if (arg === "--") { + options.files.push(...argv); + break; + } else if (arg.startsWith("-")) { + console.error(`error: unknown option ${JSON.stringify(arg)}`); + usage(1); + } else { + options.files.push(arg); + } + } + + return options; +} + +function usage(code) { + const exe = "jj-import-merge"; + const stream = code === 0 ? process.stdout : process.stderr; + stream.write(`usage: ${exe} [--all-jj] [--] ...\n`); + stream.write(`\n`); + stream.write(`merge top-of-file JS/TS imports from 2-sided Jujutsu conflicts\n`); + stream.write(`\n`); + stream.write(`options:\n`); + stream.write(` --all-jj resolve all conflicted JS/TS files from 'jj resolve --list'\n`); + stream.write(` -h show help\n`); + process.exit(code); +} + +function listJjConflictFiles() { + const result = child_process.spawnSync( + "jj", + ["--color=never", "--quiet", "resolve", "--list"], + { encoding: "utf8" }, + ); + + if (result.error) { + throw new Error(`failed to run jj: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(result.stderr.trim() || "jj resolve --list failed"); + } + + return result.stdout + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter(Boolean) + .map((line) => line.match(/^(.*?)\s{2,}.+conflict$/)) + .filter(Boolean) + .map((match) => match[1]); +} + +function processFile(file) { + if (!SUPPORTED_EXTENSIONS.has(path.extname(file))) { + return { kind: "noop" }; + } + + let original; + try { + original = fs.readFileSync(file, "utf8"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`cannot read ${file}: ${message}`); + } + + if (!CONFLICT_START_RE.test(original)) { + return { kind: "noop" }; + } + + const parsed = parseConflictFile(original); + if (!parsed.supported) { + return { kind: "noop" }; + } + + const leftText = parsed.parts.map((part) => part.a).join(""); + const rightText = parsed.parts.map((part) => part.b).join(""); + const leftImports = extractTopImportBlock(leftText); + const rightImports = extractTopImportBlock(rightText); + if (!leftImports || !rightImports) { + return { kind: "noop" }; + } + + const leftPrelude = leftText.slice(0, leftImports.start); + const rightPrelude = rightText.slice(0, rightImports.start); + if (leftPrelude !== rightPrelude) { + return { kind: "noop" }; + } + + const start = projectBoundaryToOriginal( + parsed.parts, + leftImports.start, + rightImports.start, + ); + const end = projectBoundaryToOriginal(parsed.parts, leftImports.end, rightImports.end); + if (start == null || end == null || end < start) { + return { kind: "noop" }; + } + + const mergedImports = mergeImportStatements( + leftImports.statements, + rightImports.statements, + detectNewline(original), + ); + const updated = original.slice(0, start) + mergedImports + original.slice(end); + + if (updated === original) { + return { kind: "noop" }; + } + + try { + fs.writeFileSync(file, updated, "utf8"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`cannot write ${file}: ${message}`); + } + + if (hasConflictMarkers(updated)) { + return { kind: "rewritten" }; + } + + const formatResult = child_process.spawnSync("autofmt", [file], { + encoding: "utf8", + stdio: "inherit", + }); + + if (formatResult.error || formatResult.status !== 0) { + fs.writeFileSync(file, original, "utf8"); + if (formatResult.error) { + throw new Error(`autofmt failed for ${file}: ${formatResult.error.message}`); + } + throw new Error(`autofmt failed for ${file}`); + } + + return { kind: "formatted" }; +} + +function parseConflictFile(text) { + const lines = splitLines(text); + const parts = []; + + let index = 0; + while (index < lines.length) { + const line = lines[index].text; + if (!line.startsWith("<<<<<<< conflict ")) { + const start = index; + index += 1; + while ( + index < lines.length && + !lines[index].text.startsWith("<<<<<<< conflict ") + ) { + index += 1; + } + const raw = joinLines(lines, start, index); + parts.push({ type: "plain", orig: raw, a: raw, b: raw }); + continue; + } + + const block = parseConflictBlock(lines, index); + if (!block.supported) { + return { supported: false, parts: [] }; + } + parts.push(block.part); + index = block.nextIndex; + } + + return { supported: true, parts }; +} + +function parseConflictBlock(lines, startIndex) { + let index = startIndex + 1; + const entries = []; + + while (index < lines.length) { + const line = lines[index].text; + if (line.startsWith(">>>>>>> conflict ")) { + break; + } + if (line.startsWith("+++++++ ")) { + const contentStart = index + 1; + index += 1; + while ( + index < lines.length && + !isConflictEntryStart(lines[index].text) && + !lines[index].text.startsWith(">>>>>>> conflict ") + ) { + index += 1; + } + entries.push({ + kind: "explicit", + text: joinLines(lines, contentStart, index), + }); + continue; + } + if (line.startsWith("%%%%%%% diff from:")) { + if (index + 1 >= lines.length || !/^\\\\+\s*to:/.test(lines[index + 1].text)) { + return { supported: false, nextIndex: startIndex + 1 }; + } + index += 2; + let text = ""; + while ( + index < lines.length && + !isConflictEntryStart(lines[index].text) && + !lines[index].text.startsWith(">>>>>>> conflict ") + ) { + const diffLine = lines[index].text; + const prefix = diffLine[0]; + if (prefix === " " || prefix === "+") { + text += diffLine.slice(1); + } else if (prefix !== "-") { + return { supported: false, nextIndex: startIndex + 1 }; + } + index += 1; + } + entries.push({ kind: "diff", text }); + continue; + } + return { supported: false, nextIndex: startIndex + 1 }; + } + + if ( + index >= lines.length || + !lines[index].text.startsWith(">>>>>>> conflict ") || + entries.length !== 2 + ) { + return { supported: false, nextIndex: startIndex + 1 }; + } + + return { + supported: true, + nextIndex: index + 1, + part: { + type: "conflict", + orig: joinLines(lines, startIndex, index + 1), + a: entries[0].text, + b: entries[1].text, + }, + }; +} + +function isConflictEntryStart(line) { + return line.startsWith("+++++++ ") || line.startsWith("%%%%%%% diff from:"); +} + +function splitLines(text) { + const lines = []; + let start = 0; + + for (let index = 0; index < text.length; index += 1) { + if (text[index] !== "\n") continue; + lines.push({ + start, + end: index + 1, + text: text.slice(start, index + 1), + }); + start = index + 1; + } + + if (start < text.length) { + lines.push({ start, end: text.length, text: text.slice(start) }); + } + + return lines; +} + +function joinLines(lines, start, end) { + let text = ""; + for (let index = start; index < end; index += 1) { + text += lines[index].text; + } + return text; +} + +function extractTopImportBlock(text) { + const lines = splitLines(text); + if (lines.length === 0) return null; + + let index = 0; + if (lines[0]?.text.startsWith("#!")) { + index += 1; + } + + index = skipPrelude(lines, index); + if (index == null || index >= lines.length) { + return null; + } + + const first = parseImportStatement(lines, index); + if (!first) { + return null; + } + + const start = lines[index].start; + const statements = [first.statement]; + let end = offsetForLine(lines, first.nextIndex); + index = first.nextIndex; + + while (true) { + const afterTrivia = skipBlankAndComments(lines, index); + if (afterTrivia == null) { + return null; + } + + const next = parseImportStatement(lines, afterTrivia); + if (!next) { + break; + } + + statements.push(next.statement); + end = offsetForLine(lines, next.nextIndex); + index = next.nextIndex; + } + + return { start, end, statements }; +} + +function skipPrelude(lines, startIndex) { + let index = startIndex; + while (index < lines.length) { + const afterTrivia = skipBlankAndComments(lines, index); + if (afterTrivia == null) { + return null; + } + if (afterTrivia >= lines.length) { + return afterTrivia; + } + + const trimmed = lines[afterTrivia].text.trim(); + if (isDirectiveLine(trimmed)) { + index = afterTrivia + 1; + continue; + } + return afterTrivia; + } + return index; +} + +function skipBlankAndComments(lines, startIndex) { + let index = startIndex; + + while (index < lines.length) { + const trimmed = lines[index].text.trim(); + if (trimmed === "") { + index += 1; + continue; + } + if (trimmed.startsWith("//")) { + index += 1; + continue; + } + if (trimmed.startsWith("/*")) { + index = skipBlockComment(lines, index); + if (index == null) { + return null; + } + continue; + } + break; + } + + return index; +} + +function skipBlockComment(lines, startIndex) { + for (let index = startIndex; index < lines.length; index += 1) { + if (lines[index].text.includes("*/")) { + return index + 1; + } + } + return null; +} + +function isDirectiveLine(line) { + return /^["'][^"'\\]*(?:\\.[^"'\\]*)*["'];?$/.test(line); +} + +function parseImportStatement(lines, startIndex) { + if (startIndex >= lines.length) { + return null; + } + + const firstLine = lines[startIndex].text.trimStart(); + if (!firstLine.startsWith("import")) { + return null; + } + if (/^import(?:\s*\(|\s*\.)/.test(firstLine)) { + return null; + } + + let raw = ""; + const state = { + braceDepth: 0, + inBlockComment: false, + stringQuote: null, + }; + + for (let index = startIndex; index < lines.length; index += 1) { + raw += lines[index].text; + scanLine(lines[index].text, state); + + if (state.inBlockComment || state.braceDepth !== 0) { + continue; + } + + const analysis = parseImportSemantics(raw); + if (analysis) { + return { + nextIndex: index + 1, + statement: raw, + }; + } + } + + return null; +} + +function scanLine(line, state) { + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const next = line[index + 1]; + + if (state.inBlockComment) { + if (char === "*" && next === "/") { + state.inBlockComment = false; + index += 1; + } + continue; + } + + if (state.stringQuote) { + if (char === "\\") { + index += 1; + continue; + } + if (char === state.stringQuote) { + state.stringQuote = null; + } + continue; + } + + if (char === "/" && next === "/") { + break; + } + if (char === "/" && next === "*") { + state.inBlockComment = true; + index += 1; + continue; + } + if (char === "'" || char === "\"" || char === "`") { + state.stringQuote = char; + continue; + } + if (char === "{") { + state.braceDepth += 1; + } else if (char === "}") { + state.braceDepth = Math.max(0, state.braceDepth - 1); + } + } +} + +function parseImportSemantics(raw) { + const normalized = collapseWhitespace(raw); + if (!normalized.startsWith("import ")) { + return null; + } + + const sideEffectMatch = normalized.match( + /^import\s+(["'])(.*?)\1(\s+with\s+\{.*\})?\s*;?$/, + ); + if (sideEffectMatch) { + return { + kind: "side-effect", + source: sideEffectMatch[2], + quote: sideEffectMatch[1], + attributes: sideEffectMatch[3] ?? "", + key: `side:${sideEffectMatch[2]}:${sideEffectMatch[3] ?? ""}`, + }; + } + + const bindingMatch = normalized.match( + /^import\s+(type\s+)?(.+?)\s+from\s+(["'])(.*?)\3(\s+with\s+\{.*\})?\s*;?$/, + ); + if (!bindingMatch) { + return null; + } + + const parsed = parseBindingSpec(bindingMatch[2].trim()); + if (!parsed) { + return null; + } + + const named = parsed.namedSpecifiers.join(","); + return { + kind: "binding", + isTypeImport: !!bindingMatch[1], + defaultBinding: parsed.defaultBinding, + namespaceImport: parsed.namespaceImport, + namedSpecifiers: parsed.namedSpecifiers, + source: bindingMatch[4], + quote: bindingMatch[3], + attributes: bindingMatch[5] ?? "", + key: [ + "binding", + bindingMatch[1] ? "type" : "value", + parsed.defaultBinding ?? "", + parsed.namespaceImport ?? "", + named, + bindingMatch[4], + bindingMatch[5] ?? "", + ].join(":"), + }; +} + +function parseBindingSpec(specPart) { + let defaultBinding = null; + let namespaceImport = null; + let namedSpecifiers = []; + + const braceStart = specPart.indexOf("{"); + const braceEnd = specPart.lastIndexOf("}"); + if (braceStart !== -1 || braceEnd !== -1) { + if (braceStart === -1 || braceEnd === -1 || braceEnd < braceStart) { + return null; + } + + const beforeBrace = specPart + .slice(0, braceStart) + .trim() + .replace(/,\s*$/, "") + .trim(); + const afterBrace = specPart.slice(braceEnd + 1).trim(); + if (afterBrace !== "") { + return null; + } + + const head = parseBindingHead(beforeBrace); + if (!head) { + return null; + } + defaultBinding = head.defaultBinding; + namespaceImport = head.namespaceImport; + namedSpecifiers = splitCommaList(specPart.slice(braceStart + 1, braceEnd)); + } else { + const head = parseBindingHead(specPart); + if (!head) { + return null; + } + defaultBinding = head.defaultBinding; + namespaceImport = head.namespaceImport; + } + + return { + defaultBinding, + namespaceImport, + namedSpecifiers, + }; +} + +function parseBindingHead(head) { + if (head === "") { + return { defaultBinding: null, namespaceImport: null }; + } + + if (head.startsWith("* as ")) { + return { defaultBinding: null, namespaceImport: head }; + } + + const parts = head.split(/\s*,\s*/).filter(Boolean); + if (parts.length === 1) { + return { + defaultBinding: parts[0], + namespaceImport: null, + }; + } + + if (parts.length === 2 && parts[1].startsWith("* as ")) { + return { + defaultBinding: parts[0], + namespaceImport: parts[1], + }; + } + + return null; +} + +function splitCommaList(text) { + return text + .split(",") + .map((part) => collapseWhitespace(part)) + .filter(Boolean); +} + +function mergeImportStatements(leftStatements, rightStatements, newline) { + const merged = []; + + for (const statement of [...leftStatements, ...rightStatements]) { + const analysis = parseImportSemantics(statement); + if (!analysis) { + const key = `raw:${collapseWhitespace(statement)}`; + if (!merged.some((entry) => entry.key === key)) { + merged.push({ + key, + raw: ensureTrailingNewline(statement.trim(), newline), + analysis: null, + }); + } + continue; + } + + const exactIndex = merged.findIndex((entry) => entry.key === analysis.key); + if (exactIndex !== -1) { + continue; + } + + const mergeIndex = merged.findIndex( + (entry) => entry.analysis && canMergeImports(entry.analysis, analysis), + ); + if (mergeIndex !== -1) { + const nextAnalysis = mergeImports(merged[mergeIndex].analysis, analysis); + merged[mergeIndex] = { + key: nextAnalysis.key, + raw: renderImport(nextAnalysis, newline), + analysis: nextAnalysis, + }; + continue; + } + + merged.push({ + key: analysis.key, + raw: renderImport(analysis, newline), + analysis, + }); + } + + return merged + .map((entry) => entry.raw.trimEnd()) + .join(newline) + newline; +} + +function canMergeImports(left, right) { + if (left.kind !== "binding" || right.kind !== "binding") { + return false; + } + if ( + left.source !== right.source || + left.attributes !== right.attributes || + left.isTypeImport !== right.isTypeImport + ) { + return false; + } + + const defaultBinding = mergeField(left.defaultBinding, right.defaultBinding); + const namespaceImport = mergeField(left.namespaceImport, right.namespaceImport); + if (defaultBinding === undefined || namespaceImport === undefined) { + return false; + } + + const namedSpecifiers = unique([...left.namedSpecifiers, ...right.namedSpecifiers]); + if (namespaceImport && namedSpecifiers.length > 0) { + return false; + } + + return true; +} + +function mergeImports(left, right) { + const defaultBinding = mergeField(left.defaultBinding, right.defaultBinding); + const namespaceImport = mergeField(left.namespaceImport, right.namespaceImport); + const namedSpecifiers = unique([...left.namedSpecifiers, ...right.namedSpecifiers]); + + return { + kind: "binding", + isTypeImport: left.isTypeImport, + defaultBinding, + namespaceImport, + namedSpecifiers, + source: left.source, + quote: left.quote, + attributes: left.attributes, + key: [ + "binding", + left.isTypeImport ? "type" : "value", + defaultBinding ?? "", + namespaceImport ?? "", + namedSpecifiers.join(","), + left.source, + left.attributes, + ].join(":"), + }; +} + +function mergeField(left, right) { + if (left == null) return right ?? null; + if (right == null) return left; + if (left === right) return left; + return undefined; +} + +function renderImport(analysis, newline) { + if (analysis.kind === "side-effect") { + return ensureTrailingNewline( + `import ${analysis.quote}${analysis.source}${analysis.quote}${analysis.attributes};`, + newline, + ); + } + + const pieces = []; + if (analysis.defaultBinding) { + pieces.push(analysis.defaultBinding); + } + if (analysis.namespaceImport) { + pieces.push(analysis.namespaceImport); + } + if (analysis.namedSpecifiers.length > 0) { + if (analysis.namedSpecifiers.length === 1) { + pieces.push(`{ ${analysis.namedSpecifiers[0]} }`); + } else { + pieces.push( + `{${newline}${analysis.namedSpecifiers.map((spec) => ` ${spec},`).join(newline)}${newline}}`, + ); + } + } + + return ensureTrailingNewline( + `import ${analysis.isTypeImport ? "type " : ""}${pieces.join(", ")} from ${analysis.quote}${analysis.source}${analysis.quote}${analysis.attributes};`, + newline, + ); +} + +function projectBoundaryToOriginal(parts, targetA, targetB) { + let offsetA = 0; + let offsetB = 0; + let offsetOrig = 0; + + if (targetA === 0 && targetB === 0) { + return 0; + } + + for (const part of parts) { + if (targetA === offsetA && targetB === offsetB) { + return offsetOrig; + } + + const nextA = offsetA + part.a.length; + const nextB = offsetB + part.b.length; + const nextOrig = offsetOrig + part.orig.length; + + if (part.type === "plain") { + if ( + targetA >= offsetA && + targetA <= nextA && + targetB >= offsetB && + targetB <= nextB + ) { + const deltaA = targetA - offsetA; + const deltaB = targetB - offsetB; + if (deltaA !== deltaB) { + return null; + } + return offsetOrig + deltaA; + } + } + + if (targetA === nextA && targetB === nextB) { + return nextOrig; + } + + offsetA = nextA; + offsetB = nextB; + offsetOrig = nextOrig; + } + + if (targetA === offsetA && targetB === offsetB) { + return offsetOrig; + } + + return null; +} + +function hasConflictMarkers(text) { + return CONFLICT_START_RE.test(text) || CONFLICT_END_RE.test(text); +} + +function detectNewline(text) { + return text.includes("\r\n") ? "\r\n" : "\n"; +} + +function ensureTrailingNewline(text, newline) { + return text.endsWith(newline) ? text : `${text}${newline}`; +} + +function offsetForLine(lines, lineIndex) { + if (lineIndex >= lines.length) { + return lines.length === 0 ? 0 : lines[lines.length - 1].end; + } + return lines[lineIndex].start; +} + +function collapseWhitespace(text) { + return text.replace(/\s+/g, " ").trim(); +} + +function unique(values) { + return [...new Set(values)]; +} diff --git a/users/clover/AGENTS.md b/users/clover/AGENTS.md new file mode 120000 index 0000000000000000000000000000000000000000..3c17c141dc2d1847af202b7719ec399f6237c92a --- /dev/null +++ b/users/clover/AGENTS.md @@ -0,0 +1 @@ +../../../.config/opencode/AGENTS.md \ No newline at end of file diff --git a/users/clover/home.nix b/users/clover/home.nix index 673e3b5cb39ed98b2ce92fea51331f2069b90d03..24c202b2afc2368abc297926f9ff384894748bad 100644 --- a/users/clover/home.nix +++ b/users/clover/home.nix @@ -12,6 +12,7 @@ in stateVersion = "23.05"; # Please read the comment before changing. sessionPath = [ "$HOME/.vite-plus/bin" + "$HOME/.local/bin" ]; packages = with pkgs; @@ -23,8 +24,9 @@ in uv nh autofmt + pkgs."jj-import-merge" gnupg - nodejs_24 + just ]; # packages to install for desktop environments (non-server) desktop = [ @@ -72,13 +74,29 @@ in jf = "jj git fetch"; e = "jj edit"; d = "jj desc"; - bup = "jj b m --from 'heads(::@- & bookmarks())' --to '@'"; - moveself = "jj rebase -r @ -d"; + bup = "jj b m --from 'heads(::@- & bookmarks()) ~ (master | bookmarks(glob:\"deployment*\"))' --to '@'"; + moveself = "jj rebase -s @ -d"; logall = "jj log -r 'all()'"; cherry = "jj git fetch && jj split -i -p -m 'SPLIT_CHANGES' && jj edit -r 'description(\"SPLIT_CHANGES\")' && jj desc -m '' --edit"; }; initContent = '' - export PS1="%{$(tput setaf 207)%}%m%{$(tput setaf 15)%}:%{$(tput setaf 87)%}%~ %{$(tput sgr0)%}$ " + function _update_prompt_theme() { + local dark=false + if [[ "$OSTYPE" == darwin* ]]; then + [[ "$(defaults read -g AppleInterfaceStyle 2>/dev/null)" == "Dark" ]] && dark=true + else + local scheme + scheme=$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null) + [[ "$scheme" == *"dark"* ]] && dark=true + fi + if $dark; then + PS1="%{$(tput setaf 207)%}%m%{$(tput setaf 15)%}:%{$(tput setaf 87)%}%~ %{$(tput sgr0)%}$ " + else + PS1="%{$(tput setaf 168)%}%m%{$(tput setaf 238)%}:%{$(tput setaf 33)%}%~ %{$(tput sgr0)%}$ " + fi + } + precmd_functions+=(_update_prompt_theme) + _update_prompt_theme function python() { dirname=$(dirname $1 2>/dev/null) @@ -99,14 +117,45 @@ in fi } + function wrapWithAutoInstall() { + root="$(command jj workspace root)" + if [ -n "$root" ]; then + if [ -e "$root/bun.lock" ]; then + file="$root/bun.lock" + cmd="bun i -f" + elif [ -e "$root/package-lock.json" ]; then + file="$root/package-lock.json" + cmd="npm i" + elif [ -e "$root/pnpm-lock.yaml" ]; then + file="$root/pnpm-lock.yaml" + cmd="pnpm i" + elif [ -e "$root/Cargo.lock" ]; then + file="$root/Cargo.lock" + cmd="cargo fetch" + fi + fi + if [ -n "$file" ]; then + before="$(sha1sum "$file")" + fi + command "$@" + if [ -n "$file" ] && [ -e "$file" ]; then + after="$(sha1sum "$file")" + if [ "$after" != "$before" ]; then + eval "$cmd" + fi + fi + } + function jj() { if [[ "$*" = "git push"* ]] && [[ "$*" != *"--dry-run"* ]]; then - command jj "$@" --dry-run - printf "Press any key to continue... " - read -r - command jj "$@" - else - command jj "$@" + if command jj "$@" --dry-run; then + printf "Press any key to continue... " + read -r + command jj "$@" + fi + else if [[ "$*" != "undo" ]] || [[ "$*" != "redo" ]] + # auto-install deps when switching commits + wrapWithAutoInstall jj "$@" fi } ''; @@ -114,6 +163,7 @@ in git = { # use a git-specific email + ignores = [ "AGENTS.md" ]; settings.user.email = "git@paperclover.net"; settings.gpg.ssh.allowedSignersFile = "${pkgs.writeText "allowed_signers" '' git@paperclover.net ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAVa1aq1LEj/VXHJYlR5HJpK65lS/9jOL/dAB9ZS1chd @@ -142,15 +192,19 @@ in "working_copy remote_bookmarks" = colors."working_copy bookmark"; "author" = { - fg = "white"; + fg = "bright black"; bold = false; italic = true; }; "working_copy author" = colors.author; + committer = { + fg = "bright black"; + bold = false; + }; desc = { - bold = false; - fg = "bright white"; + bold = true; + fg = "default"; }; description = colors.desc; cc_wip = { @@ -160,7 +214,7 @@ in }; cc_header = { bold = true; - fg = "bright white"; + fg = "default"; }; cc_feat = "green"; cc_fix = "red"; @@ -179,6 +233,10 @@ in bold = true; reverse = true; }; + "change_id shortest rest" = { + fg = "bright black"; + bold = true; + }; conflict_marker = { fg = "bright red"; bold = true; @@ -186,6 +244,7 @@ in }; timestamp = "bright black"; + "committer timestamp" = colors.committer; commit_id = { fg = "bright black"; bold = false; diff --git a/users/clover/paperback/home.nix b/users/clover/paperback/home.nix index 6686bc71ada574880cc557bb0207d2d8cef33c11..26a293cf2031554ebe307f8b3f40f21595508ecd 100644 --- a/users/clover/paperback/home.nix +++ b/users/clover/paperback/home.nix @@ -1,23 +1,12 @@ { pkgs, ... }: { - # these programs are not globally installed to reduce distractions. - # most of these are needed for my work environment. - programs = { - zsh.profileExtra = '' - _bun() { - local context state line - _arguments -C '*:file:_files' - } - ''; - }; home.packages = with pkgs; [ - deno rustup typescript - pm2 pnpm yt-dlp spotdl zig + nodejs_24 ]; } diff --git a/users/clover/sandwich/home.nix b/users/clover/sandwich/home.nix new file mode 100644 index 0000000000000000000000000000000000000000..60fe8437d1309bd834f1e7b0f7852826c4f3285e --- /dev/null +++ b/users/clover/sandwich/home.nix @@ -0,0 +1,7 @@ +{ pkgs, ... }: +{ + + home.packages = with pkgs; [ + nodejs_24 + ]; +} diff --git a/users/clover/switch-admin.sh b/users/clover/switch-admin.sh new file mode 100755 index 0000000000000000000000000000000000000000..5ccb8d5aa54c63541bc4c965b07c8fcb219711da --- /dev/null +++ b/users/clover/switch-admin.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +cd /Users/clo/config + +target_host="${1:-}" +if [ -z "$target_host" ]; then + if [ -f .hostname ]; then + target_host="$(cat .hostname)" + else + target_host="$(hostname)" + fi +fi + +case "$target_host" in + '' | *[!A-Za-z0-9._-]*) + printf 'invalid host: %s\n' "$target_host" >&2 + exit 2 + ;; +esac + +if [ "$(uname -s)" != Darwin ]; then + exec ./switch "$target_host" +fi + +exec 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" diff --git a/users/clover/work/home.nix b/users/clover/work/home.nix index a6687ddcafbae66923c46d6cd449de1514f6eccc..14a2e1138b94fa2dd40cf806bc66e286aca82961 100644 --- a/users/clover/work/home.nix +++ b/users/clover/work/home.nix @@ -8,20 +8,24 @@ } ''; }; - programs.git.signing.key = "CE485807AE4068AF8508C8DC5CBB21841C7920AD"; home.packages = with pkgs; [ (bun.overrideAttrs (_: rec { - version = "1.3.0"; + version = "1.3.5"; src = fetchurl { url = "https://github.com/oven-sh/bun/releases/download/bun-v${version}/bun-darwin-aarch64.zip"; - hash = "sha256-hYSOP5ZIHvyr51pQD9O5S5u5VoaretCjiSl2x74VA2o="; + hash = "sha256-2xdYikrqiASFaCXUvq0/BeHzcnbKYG8342m09y810/s="; }; })) - doppler + awscli deno + doppler + nodejs_25 rustup - typescript ssm-session-manager-plugin - awscli + typescript + zig + ]; + home.sessionPath = [ + "$HOME/.opencode/bin" ]; }