shell-aliasforge 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +293 -2
- package/dist/analyze/environment.js +84 -0
- package/dist/analyze/naming.js +84 -0
- package/dist/analyze/normalize.js +97 -0
- package/dist/analyze/render.js +23 -0
- package/dist/analyze/report.js +27 -0
- package/dist/analyze/reserved.js +33 -0
- package/dist/analyze/score.js +4 -0
- package/dist/analyze/stats.js +94 -0
- package/dist/analyze/suggest.js +70 -0
- package/dist/analyze/templates.js +90 -0
- package/dist/apply/apply.js +68 -0
- package/dist/apply/backup.js +20 -0
- package/dist/apply/block.js +98 -0
- package/dist/apply/diff.js +41 -0
- package/dist/apply/select.js +23 -0
- package/dist/cli.js +229 -0
- package/dist/errors.js +3 -0
- package/dist/history/bash.js +22 -0
- package/dist/history/detect.js +36 -0
- package/dist/history/fish.js +29 -0
- package/dist/history/index.js +24 -0
- package/dist/history/text.js +13 -0
- package/dist/history/types.js +1 -0
- package/dist/history/zsh.js +62 -0
- package/dist/risks/engine.js +76 -0
- package/dist/risks/mask.js +23 -0
- package/dist/risks/packs/cloud.js +60 -0
- package/dist/risks/packs/core.js +166 -0
- package/dist/risks/packs/docker.js +27 -0
- package/dist/risks/packs/git.js +27 -0
- package/dist/risks/packs/index.js +18 -0
- package/dist/risks/packs/kubectl.js +35 -0
- package/dist/risks/packs/systemd.js +19 -0
- package/dist/risks/report.js +30 -0
- package/dist/risks/types.js +1 -0
- package/package.json +49 -4
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { normalizeCommand, splitSubcommands, tokenize } from "./normalize.js";
|
|
2
|
+
const ASSIGNMENT = /^[A-Za-z_]\w*=/;
|
|
3
|
+
/** Words that run another command, so the real program is the next word. */
|
|
4
|
+
const WRAPPERS = new Set(["sudo", "doas", "time", "nohup", "command", "env", "exec", "nice", "builtin"]);
|
|
5
|
+
/** Shell syntax that can start a sub-command without being a program. */
|
|
6
|
+
const SYNTAX = new Set(["do", "then", "else", "elif", "!", "{", "(", "if", "while", "until"]);
|
|
7
|
+
const DAY_SECONDS = 86_400;
|
|
8
|
+
/** The program a sub-command runs, skipping `VAR=x`, `sudo`, `time` and similar. */
|
|
9
|
+
export function programOf(unit) {
|
|
10
|
+
const tokens = tokenize(unit);
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < tokens.length) {
|
|
13
|
+
const token = tokens[i];
|
|
14
|
+
if (ASSIGNMENT.test(token) || SYNTAX.has(token)) {
|
|
15
|
+
i++;
|
|
16
|
+
}
|
|
17
|
+
else if (WRAPPERS.has(token)) {
|
|
18
|
+
i++;
|
|
19
|
+
while (i < tokens.length && tokens[i].startsWith("-"))
|
|
20
|
+
i++;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const program = tokens[i];
|
|
27
|
+
if (program === undefined || program.startsWith("#"))
|
|
28
|
+
return undefined;
|
|
29
|
+
return program.slice(program.lastIndexOf("/") + 1) || undefined;
|
|
30
|
+
}
|
|
31
|
+
function isoDate(seconds) {
|
|
32
|
+
return new Date(seconds * 1000).toISOString().slice(0, 10);
|
|
33
|
+
}
|
|
34
|
+
function validTimestamps(entries) {
|
|
35
|
+
return entries.flatMap((entry) => {
|
|
36
|
+
const t = entry.timestamp;
|
|
37
|
+
return t !== undefined && Number.isFinite(t) && !Number.isNaN(new Date(t * 1000).getTime()) ? [t] : [];
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function countPrograms(entries) {
|
|
41
|
+
const counts = new Map();
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
for (const unit of splitSubcommands(normalizeCommand(entry.command))) {
|
|
44
|
+
const program = programOf(unit);
|
|
45
|
+
if (program !== undefined)
|
|
46
|
+
counts.set(program, (counts.get(program) ?? 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [...counts]
|
|
50
|
+
.map(([command, count]) => ({ command, count }))
|
|
51
|
+
.sort((a, b) => b.count - a.count || a.command.localeCompare(b.command));
|
|
52
|
+
}
|
|
53
|
+
/** Summarize parsed history: size, most-used programs and time span. */
|
|
54
|
+
export function computeStats(entries, top) {
|
|
55
|
+
const unique = new Set(entries.map((entry) => normalizeCommand(entry.command)));
|
|
56
|
+
unique.delete("");
|
|
57
|
+
const times = validTimestamps(entries);
|
|
58
|
+
const stats = {
|
|
59
|
+
historyEntries: entries.length,
|
|
60
|
+
uniqueCommands: unique.size,
|
|
61
|
+
timestampedEntries: times.length,
|
|
62
|
+
topCommands: countPrograms(entries).slice(0, top),
|
|
63
|
+
};
|
|
64
|
+
if (times.length === 0)
|
|
65
|
+
return stats;
|
|
66
|
+
const first = times.reduce((a, b) => Math.min(a, b));
|
|
67
|
+
const last = times.reduce((a, b) => Math.max(a, b));
|
|
68
|
+
return {
|
|
69
|
+
...stats,
|
|
70
|
+
firstDate: isoDate(first),
|
|
71
|
+
lastDate: isoDate(last),
|
|
72
|
+
spanDays: Math.floor((last - first) / DAY_SECONDS),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function renderStats(shell, stats) {
|
|
76
|
+
const lines = [
|
|
77
|
+
`Shell: ${shell}`,
|
|
78
|
+
`Entries: ${stats.historyEntries.toLocaleString("en-US")} (${stats.uniqueCommands.toLocaleString("en-US")} unique)`,
|
|
79
|
+
];
|
|
80
|
+
if (stats.firstDate !== undefined) {
|
|
81
|
+
lines.push(`Time span: ${stats.firstDate} to ${stats.lastDate} (${stats.spanDays} days, ${stats.timestampedEntries.toLocaleString("en-US")} entries with a time)`);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
lines.push("Time span: unknown (this history has no timestamps)");
|
|
85
|
+
}
|
|
86
|
+
if (stats.topCommands.length === 0)
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
const width = Math.max(...stats.topCommands.map((c) => c.command.length));
|
|
89
|
+
lines.push("", "Top commands:");
|
|
90
|
+
stats.topCommands.forEach((item, index) => {
|
|
91
|
+
lines.push(` ${String(index + 1).padStart(2)} ${item.command.padEnd(width)} ${item.count}`);
|
|
92
|
+
});
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { proposeNames } from "./naming.js";
|
|
2
|
+
import { normalizeCommand, splitSubcommands } from "./normalize.js";
|
|
3
|
+
import { renderDefinition } from "./render.js";
|
|
4
|
+
import { aliasScore } from "./score.js";
|
|
5
|
+
import { buildCandidates, kindOf } from "./templates.js";
|
|
6
|
+
/** Full normalized line plus its sub-commands, each counted once per entry. */
|
|
7
|
+
function unitsOf(entry) {
|
|
8
|
+
const line = normalizeCommand(entry.command);
|
|
9
|
+
if (line === "" || line.startsWith("#"))
|
|
10
|
+
return [];
|
|
11
|
+
return [...new Set([line, ...splitSubcommands(line)])];
|
|
12
|
+
}
|
|
13
|
+
function candidatePool(entries, options) {
|
|
14
|
+
const units = entries.map(unitsOf).filter((list) => list.length > 0);
|
|
15
|
+
const eligible = buildCandidates(units).filter((c) => c.count >= options.minCount && c.replacedLength >= options.minLength);
|
|
16
|
+
const upperBound = (c) => c.count * c.replacedLength;
|
|
17
|
+
eligible.sort((a, b) => upperBound(b) - upperBound(a));
|
|
18
|
+
return eligible.slice(0, Math.max(options.top * 10, 100));
|
|
19
|
+
}
|
|
20
|
+
function nameAndScore(candidate, isTaken) {
|
|
21
|
+
const names = proposeNames(candidate, isTaken);
|
|
22
|
+
const first = names[0];
|
|
23
|
+
if (first === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
return { candidate, names, score: aliasScore(candidate, first) };
|
|
26
|
+
}
|
|
27
|
+
/** An exact command already covered by a chosen `<prefix> <arg>` alias. */
|
|
28
|
+
function isCovered(candidate, chosen) {
|
|
29
|
+
if (candidate.shape !== "exact")
|
|
30
|
+
return false;
|
|
31
|
+
const prefix = candidate.command.slice(0, candidate.command.lastIndexOf(" "));
|
|
32
|
+
return chosen.some((c) => c.candidate.shape === "trailing" && c.candidate.command === prefix);
|
|
33
|
+
}
|
|
34
|
+
function select(named, top) {
|
|
35
|
+
const chosen = [];
|
|
36
|
+
const used = new Set();
|
|
37
|
+
for (const item of named) {
|
|
38
|
+
if (chosen.length >= top)
|
|
39
|
+
break;
|
|
40
|
+
const name = item.names.find((n) => !used.has(n));
|
|
41
|
+
if (name === undefined || isCovered(item.candidate, chosen))
|
|
42
|
+
continue;
|
|
43
|
+
used.add(name);
|
|
44
|
+
chosen.push({ ...item, names: [name, ...item.names.filter((n) => n !== name)] });
|
|
45
|
+
}
|
|
46
|
+
return chosen;
|
|
47
|
+
}
|
|
48
|
+
function toSuggestion(item, rank, shell) {
|
|
49
|
+
const { candidate, names } = item;
|
|
50
|
+
const kind = kindOf(candidate);
|
|
51
|
+
const name = names[0];
|
|
52
|
+
return {
|
|
53
|
+
rank,
|
|
54
|
+
kind,
|
|
55
|
+
pattern: candidate.pattern,
|
|
56
|
+
command: candidate.command,
|
|
57
|
+
count: candidate.count,
|
|
58
|
+
score: aliasScore(candidate, name),
|
|
59
|
+
names,
|
|
60
|
+
definition: renderDefinition(shell, { kind, name, command: candidate.command }),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Rank alias and function suggestions from parsed history. */
|
|
64
|
+
export function suggest(entries, isTaken, options) {
|
|
65
|
+
const named = candidatePool(entries, options)
|
|
66
|
+
.map((candidate) => nameAndScore(candidate, isTaken))
|
|
67
|
+
.filter((item) => item !== undefined && item.score > 0)
|
|
68
|
+
.sort((a, b) => b.score - a.score || a.candidate.pattern.localeCompare(b.candidate.pattern));
|
|
69
|
+
return select(named, options.top).map((item, index) => toSuggestion(item, index + 1, options.shell));
|
|
70
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { isBalanced, isFlag, tokenize } from "./normalize.js";
|
|
2
|
+
export const VAR = "<arg>";
|
|
3
|
+
/** Longest command (in words) we try to template. Keeps the work bounded. */
|
|
4
|
+
const MAX_TOKENS = 12;
|
|
5
|
+
function addToGroup(groups, tokens, index) {
|
|
6
|
+
const template = tokens.map((token, i) => (i === index ? VAR : token));
|
|
7
|
+
const key = template.join(" ");
|
|
8
|
+
const group = groups.get(key) ?? { tokens: template, values: new Set(), count: 0 };
|
|
9
|
+
group.values.add(tokens[index]);
|
|
10
|
+
group.count++;
|
|
11
|
+
groups.set(key, group);
|
|
12
|
+
}
|
|
13
|
+
function addTemplates(trailing, middle, tokens) {
|
|
14
|
+
if (tokens.length < 2 || tokens.length > MAX_TOKENS)
|
|
15
|
+
return;
|
|
16
|
+
const last = tokens.length - 1;
|
|
17
|
+
if (!isFlag(tokens[last]))
|
|
18
|
+
addToGroup(trailing, tokens, last);
|
|
19
|
+
for (let i = 1; i < last; i++) {
|
|
20
|
+
if (!isFlag(tokens[i]))
|
|
21
|
+
addToGroup(middle, tokens, i);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function exactCandidates(counts) {
|
|
25
|
+
return [...counts].map(([command, count]) => ({
|
|
26
|
+
shape: "exact",
|
|
27
|
+
command,
|
|
28
|
+
pattern: command,
|
|
29
|
+
count,
|
|
30
|
+
replacedLength: command.length,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
function trailingCandidates(groups) {
|
|
34
|
+
return [...groups.values()]
|
|
35
|
+
.filter((group) => group.values.size >= 2)
|
|
36
|
+
.map((group) => {
|
|
37
|
+
const command = group.tokens.slice(0, -1).join(" ");
|
|
38
|
+
return {
|
|
39
|
+
shape: "trailing",
|
|
40
|
+
command,
|
|
41
|
+
pattern: `${command} ${VAR}`,
|
|
42
|
+
count: group.count,
|
|
43
|
+
replacedLength: command.length,
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A function body is pasted into an rc file, so reject text that could break
|
|
49
|
+
* out of it: unbalanced quotes or brackets, comments, heredocs, a trailing
|
|
50
|
+
* `&`, `|` or backslash.
|
|
51
|
+
*/
|
|
52
|
+
function safeForFunction(tokens) {
|
|
53
|
+
const command = tokens.join(" ");
|
|
54
|
+
return isBalanced(command) && !/[&|\\]$/.test(command) && !command.includes("<<") && !tokens.some((t) => t.startsWith("#"));
|
|
55
|
+
}
|
|
56
|
+
function middleCandidates(groups) {
|
|
57
|
+
return [...groups.values()]
|
|
58
|
+
.filter((group) => group.values.size >= 2 && safeForFunction(group.tokens))
|
|
59
|
+
.map((group) => {
|
|
60
|
+
const command = group.tokens.join(" ");
|
|
61
|
+
return {
|
|
62
|
+
shape: "middle",
|
|
63
|
+
command,
|
|
64
|
+
pattern: command,
|
|
65
|
+
count: group.count,
|
|
66
|
+
replacedLength: command.length - VAR.length,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build alias/function candidates from per-entry lists of unique command units.
|
|
72
|
+
* Identical units group together; units that differ only in one argument group
|
|
73
|
+
* into a template. A last-argument template stays an alias (extra words pass
|
|
74
|
+
* through); a middle-argument template needs a function.
|
|
75
|
+
*/
|
|
76
|
+
export function buildCandidates(units) {
|
|
77
|
+
const exact = new Map();
|
|
78
|
+
const trailing = new Map();
|
|
79
|
+
const middle = new Map();
|
|
80
|
+
for (const entryUnits of units) {
|
|
81
|
+
for (const unit of entryUnits) {
|
|
82
|
+
exact.set(unit, (exact.get(unit) ?? 0) + 1);
|
|
83
|
+
addTemplates(trailing, middle, tokenize(unit));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...exactCandidates(exact), ...trailingCandidates(trailing), ...middleCandidates(middle)];
|
|
87
|
+
}
|
|
88
|
+
export function kindOf(candidate) {
|
|
89
|
+
return candidate.shape === "middle" ? "function" : "alias";
|
|
90
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { renderDefinition } from "../analyze/render.js";
|
|
4
|
+
import { createBackup } from "./backup.js";
|
|
5
|
+
import { mergeItems, readItems, writeItems } from "./block.js";
|
|
6
|
+
import { unifiedDiff } from "./diff.js";
|
|
7
|
+
export function rcPathFor(shell, home, env) {
|
|
8
|
+
if (shell === "bash")
|
|
9
|
+
return join(home, ".bashrc");
|
|
10
|
+
if (shell === "zsh")
|
|
11
|
+
return join(env.ZDOTDIR || home, ".zshrc");
|
|
12
|
+
return join(home, ".config", "fish", "config.fish");
|
|
13
|
+
}
|
|
14
|
+
function readIfExists(path) {
|
|
15
|
+
return existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
16
|
+
}
|
|
17
|
+
function itemFor(shell, selected, name) {
|
|
18
|
+
const { kind, command } = selected.suggestion;
|
|
19
|
+
return { name, lines: renderDefinition(shell, { kind, name, command }).split("\n") };
|
|
20
|
+
}
|
|
21
|
+
/** Choose a name per selection that does not clash with a different definition in the block. */
|
|
22
|
+
function resolve(shell, selected, existing, explicit) {
|
|
23
|
+
const taken = new Map(existing.flatMap((item) => (item.name ? [[item.name, item.lines.join("\n")]] : [])));
|
|
24
|
+
const incoming = [];
|
|
25
|
+
const skipped = [];
|
|
26
|
+
for (const choice of selected) {
|
|
27
|
+
const names = explicit ? [choice.name] : choice.suggestion.names;
|
|
28
|
+
const item = names
|
|
29
|
+
.map((name) => itemFor(shell, choice, name))
|
|
30
|
+
.find((candidate) => {
|
|
31
|
+
const current = taken.get(candidate.name);
|
|
32
|
+
return current === undefined || current === candidate.lines.join("\n");
|
|
33
|
+
});
|
|
34
|
+
if (!item) {
|
|
35
|
+
skipped.push(choice.suggestion.pattern);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
taken.set(item.name, item.lines.join("\n"));
|
|
39
|
+
incoming.push(item);
|
|
40
|
+
}
|
|
41
|
+
return { incoming, skipped };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Write the selected definitions into the managed block of the rc file.
|
|
45
|
+
* Existing definitions in the block are kept. A timestamped backup is made
|
|
46
|
+
* before any change, and nothing is touched when the result would be identical.
|
|
47
|
+
*/
|
|
48
|
+
export function applyToRc(input) {
|
|
49
|
+
const current = readIfExists(input.rcPath);
|
|
50
|
+
const content = current ?? "";
|
|
51
|
+
const existing = readItems(content, input.shell);
|
|
52
|
+
const { incoming, skipped } = resolve(input.shell, input.selected, existing, input.explicitNames);
|
|
53
|
+
const merged = mergeItems(existing, incoming);
|
|
54
|
+
const next = incoming.length === 0 ? content : writeItems(content, merged.items);
|
|
55
|
+
const result = {
|
|
56
|
+
rcPath: input.rcPath,
|
|
57
|
+
changes: merged.changes,
|
|
58
|
+
skipped,
|
|
59
|
+
diff: unifiedDiff(input.rcPath, content, next),
|
|
60
|
+
written: false,
|
|
61
|
+
};
|
|
62
|
+
if (next === content || input.dryRun)
|
|
63
|
+
return result;
|
|
64
|
+
const backupPath = current === undefined ? undefined : createBackup(input.rcPath, input.now);
|
|
65
|
+
mkdirSync(dirname(input.rcPath), { recursive: true });
|
|
66
|
+
writeFileSync(input.rcPath, next);
|
|
67
|
+
return backupPath === undefined ? { ...result, written: true } : { ...result, written: true, backupPath };
|
|
68
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { copyFileSync, constants } from "node:fs";
|
|
2
|
+
function stamp(now) {
|
|
3
|
+
return now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "").replace("T", "-");
|
|
4
|
+
}
|
|
5
|
+
/** Copy `path` to `<path>.aliasforge-backup-<UTC timestamp>`. Never overwrites a backup. */
|
|
6
|
+
export function createBackup(path, now) {
|
|
7
|
+
const base = `${path}.aliasforge-backup-${stamp(now)}`;
|
|
8
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
9
|
+
const target = attempt === 0 ? base : `${base}-${attempt}`;
|
|
10
|
+
try {
|
|
11
|
+
copyFileSync(path, target, constants.COPYFILE_EXCL);
|
|
12
|
+
return target;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code !== "EEXIST")
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
throw new Error(`Could not create a backup next to ${path}`);
|
|
20
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export const START = "# >>> aliasforge >>>";
|
|
2
|
+
export const END = "# <<< aliasforge <<<";
|
|
3
|
+
const NOTE = "# Managed by aliasforge. `aliasforge apply` edits only this block.";
|
|
4
|
+
/** The rc file has a damaged managed block. Nothing is written when this is thrown. */
|
|
5
|
+
export class BlockError extends Error {
|
|
6
|
+
}
|
|
7
|
+
function parse(content) {
|
|
8
|
+
const eol = content.includes("\r\n") ? "\r\n" : "\n";
|
|
9
|
+
const lines = content.split(/\r?\n/);
|
|
10
|
+
if (lines[lines.length - 1] === "")
|
|
11
|
+
lines.pop();
|
|
12
|
+
return { lines, eol };
|
|
13
|
+
}
|
|
14
|
+
function indexesOf(lines, marker) {
|
|
15
|
+
return lines.flatMap((line, index) => (line.trimEnd() === marker ? [index] : []));
|
|
16
|
+
}
|
|
17
|
+
function locate(lines) {
|
|
18
|
+
const starts = indexesOf(lines, START);
|
|
19
|
+
const ends = indexesOf(lines, END);
|
|
20
|
+
if (starts.length === 0 && ends.length === 0)
|
|
21
|
+
return undefined;
|
|
22
|
+
const [start] = starts;
|
|
23
|
+
const [end] = ends;
|
|
24
|
+
if (starts.length !== 1 || ends.length !== 1 || start === undefined || end === undefined || start > end) {
|
|
25
|
+
throw new BlockError(`The aliasforge block is damaged (expected exactly one "${START}" before one "${END}"). Fix it by hand; nothing was changed.`);
|
|
26
|
+
}
|
|
27
|
+
return { start, end };
|
|
28
|
+
}
|
|
29
|
+
const ALIAS_LINE = /^alias\s+([^\s=]+)/;
|
|
30
|
+
const FISH_FUNCTION = /^function\s+(\S+)/;
|
|
31
|
+
const POSIX_FUNCTION = /^([A-Za-z_]\w*)\(\)\s*\{/;
|
|
32
|
+
/** Split block lines into items. Multi-line fish functions stay together. */
|
|
33
|
+
export function parseItems(lines, shell) {
|
|
34
|
+
const items = [];
|
|
35
|
+
for (let i = 0; i < lines.length; i++) {
|
|
36
|
+
const line = lines[i];
|
|
37
|
+
if (line.trim() === "" || line === NOTE)
|
|
38
|
+
continue;
|
|
39
|
+
const fn = shell === "fish" ? FISH_FUNCTION.exec(line) : null;
|
|
40
|
+
if (fn) {
|
|
41
|
+
const close = lines.findIndex((candidate, at) => at > i && candidate.trim() === "end");
|
|
42
|
+
const last = close === -1 ? lines.length - 1 : close;
|
|
43
|
+
items.push({ name: fn[1], lines: lines.slice(i, last + 1) });
|
|
44
|
+
i = last;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const match = ALIAS_LINE.exec(line) ?? POSIX_FUNCTION.exec(line);
|
|
48
|
+
items.push(match ? { name: match[1], lines: [line] } : { lines: [line] });
|
|
49
|
+
}
|
|
50
|
+
return items;
|
|
51
|
+
}
|
|
52
|
+
/** Items in the managed block of an rc file's text. Empty when there is no block. */
|
|
53
|
+
export function readItems(content, shell) {
|
|
54
|
+
const { lines } = parse(content);
|
|
55
|
+
const located = locate(lines);
|
|
56
|
+
return located ? parseItems(lines.slice(located.start + 1, located.end), shell) : [];
|
|
57
|
+
}
|
|
58
|
+
/** The text with the managed block removed (unchanged if absent or damaged). */
|
|
59
|
+
export function stripManagedBlock(content) {
|
|
60
|
+
const { lines } = parse(content);
|
|
61
|
+
try {
|
|
62
|
+
const located = locate(lines);
|
|
63
|
+
if (!located)
|
|
64
|
+
return content;
|
|
65
|
+
return [...lines.slice(0, located.start), ...lines.slice(located.end + 1)].join("\n");
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return content;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Add incoming items the block lacks. Items with the same name and text are unchanged. */
|
|
72
|
+
export function mergeItems(existing, incoming) {
|
|
73
|
+
const items = [...existing];
|
|
74
|
+
const changes = [];
|
|
75
|
+
for (const item of incoming) {
|
|
76
|
+
const name = item.name;
|
|
77
|
+
const found = items.some((current) => current.name === name && current.lines.join("\n") === item.lines.join("\n"));
|
|
78
|
+
if (!found)
|
|
79
|
+
items.push(item);
|
|
80
|
+
changes.push({ name, status: found ? "unchanged" : "added" });
|
|
81
|
+
}
|
|
82
|
+
return { items, changes };
|
|
83
|
+
}
|
|
84
|
+
/** Insert or replace the managed block in the rc text, leaving everything else untouched. */
|
|
85
|
+
export function writeItems(content, items) {
|
|
86
|
+
const { lines, eol } = parse(content);
|
|
87
|
+
const block = [START, NOTE, ...items.flatMap((item) => item.lines), END];
|
|
88
|
+
const located = locate(lines);
|
|
89
|
+
let next;
|
|
90
|
+
if (located) {
|
|
91
|
+
next = [...lines.slice(0, located.start), ...block, ...lines.slice(located.end + 1)];
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const spacer = lines.length > 0 && lines[lines.length - 1].trim() !== "" ? [""] : [];
|
|
95
|
+
next = [...lines, ...spacer, ...block];
|
|
96
|
+
}
|
|
97
|
+
return next.join(eol) + eol;
|
|
98
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const CONTEXT = 3;
|
|
2
|
+
function split(text) {
|
|
3
|
+
const lines = text.split(/\r?\n/);
|
|
4
|
+
if (lines[lines.length - 1] === "")
|
|
5
|
+
lines.pop();
|
|
6
|
+
return lines;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Unified diff of two texts that differ in one contiguous region, which is
|
|
10
|
+
* all `apply` produces (the managed block). Empty string when equal.
|
|
11
|
+
*/
|
|
12
|
+
export function unifiedDiff(path, before, after) {
|
|
13
|
+
const a = split(before);
|
|
14
|
+
const b = split(after);
|
|
15
|
+
let prefix = 0;
|
|
16
|
+
while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix])
|
|
17
|
+
prefix++;
|
|
18
|
+
let suffix = 0;
|
|
19
|
+
while (suffix < a.length - prefix && suffix < b.length - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) {
|
|
20
|
+
suffix++;
|
|
21
|
+
}
|
|
22
|
+
const removed = a.slice(prefix, a.length - suffix);
|
|
23
|
+
const added = b.slice(prefix, b.length - suffix);
|
|
24
|
+
if (removed.length === 0 && added.length === 0)
|
|
25
|
+
return before === after ? "" : `--- ${path}\n+++ ${path}\n(line endings or final newline changed)`;
|
|
26
|
+
const from = Math.max(0, prefix - CONTEXT);
|
|
27
|
+
const before3 = a.slice(from, prefix);
|
|
28
|
+
const after3 = a.slice(a.length - suffix, Math.min(a.length, a.length - suffix + CONTEXT));
|
|
29
|
+
const oldCount = before3.length + removed.length + after3.length;
|
|
30
|
+
const newCount = before3.length + added.length + after3.length;
|
|
31
|
+
const header = `@@ -${oldCount === 0 ? from : from + 1},${oldCount} +${newCount === 0 ? from : from + 1},${newCount} @@`;
|
|
32
|
+
return [
|
|
33
|
+
`--- ${before === "" ? "/dev/null" : path}`,
|
|
34
|
+
`+++ ${path}`,
|
|
35
|
+
header,
|
|
36
|
+
...before3.map((line) => ` ${line}`),
|
|
37
|
+
...removed.map((line) => `-${line}`),
|
|
38
|
+
...added.map((line) => `+${line}`),
|
|
39
|
+
...after3.map((line) => ` ${line}`),
|
|
40
|
+
].join("\n");
|
|
41
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { UsageError } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Pick suggestions by `--select`: a comma-separated list of ranks (from the
|
|
4
|
+
* `suggest` table) and/or proposed names. No selection means all of them.
|
|
5
|
+
*/
|
|
6
|
+
export function selectSuggestions(suggestions, spec) {
|
|
7
|
+
if (spec === undefined || spec.trim() === "") {
|
|
8
|
+
return suggestions.map((suggestion) => ({ suggestion, name: suggestion.names[0] }));
|
|
9
|
+
}
|
|
10
|
+
return spec.split(",").map((raw) => pick(suggestions, raw.trim()));
|
|
11
|
+
}
|
|
12
|
+
function pick(suggestions, token) {
|
|
13
|
+
if (/^\d+$/.test(token)) {
|
|
14
|
+
const suggestion = suggestions.find((s) => s.rank === Number(token));
|
|
15
|
+
if (!suggestion)
|
|
16
|
+
throw new UsageError(`--select: no suggestion with rank ${token}`);
|
|
17
|
+
return { suggestion, name: suggestion.names[0] };
|
|
18
|
+
}
|
|
19
|
+
const suggestion = suggestions.find((s) => s.names.includes(token));
|
|
20
|
+
if (!suggestion)
|
|
21
|
+
throw new UsageError(`--select: "${token}" is not a rank or proposed name`);
|
|
22
|
+
return { suggestion, name: token };
|
|
23
|
+
}
|