@sous-io/sous 0.2.32 → 0.2.34
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/README.md +7 -6
- package/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/commands.md +8 -4
- package/docs/markdown/configuration.md +28 -4
- package/docs/markdown/repositories-authoring.md +22 -2
- package/docs/markdown/repositories-consuming.md +40 -11
- package/docs/markdown/repositories-file-formats.md +28 -13
- package/docs/markdown/repositories-quickstart.md +1 -1
- package/docs/markdown/repositories-variables.md +16 -0
- package/docs/markdown/repositories.md +8 -4
- package/package.json +2 -1
- package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +33 -20
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +4 -0
- package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +21 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/sous.config.schema.json +27 -15
- package/src/commands/lock/rebuild.ts +3 -6
- package/src/commands/namespace/show.ts +2 -2
- package/src/commands/recipe/show.ts +26 -9
- package/src/commands/repo/contribute.ts +32 -23
- package/src/commands/repo/gc.ts +1 -1
- package/src/commands/repo/remove.ts +1 -1
- package/src/commands/repo/search.ts +1 -1
- package/src/commands/vars/ask.ts +75 -54
- package/src/lib/build-service.ts +93 -15
- package/src/lib/config-schema.ts +59 -19
- package/src/lib/include-resolver.ts +307 -69
- package/src/lib/markdown-compiler.ts +289 -82
- package/src/lib/project-scaffold/templates.ts +19 -11
- package/src/lib/repos/catalog-inputs.ts +1 -1
- package/src/lib/repos/catalog.ts +98 -72
- package/src/lib/repos/declarations.ts +22 -12
- package/src/lib/repos/formats/index-file.ts +1 -1
- package/src/lib/repos/formats/recipe-manifest.ts +3 -4
- package/src/lib/repos/lock-service.ts +1 -1
- package/src/lib/repos/locked-namespace-resolver.ts +14 -6
- package/src/lib/repos/locked-recipe-lookup.ts +123 -0
- package/src/lib/repos/locked-recipes.ts +1 -1
- package/src/lib/repos/memories-hash-name.ts +33 -0
- package/src/lib/repos/namespace-resolver.ts +198 -119
- package/src/lib/repos/recipe-config-layers.ts +29 -1
- package/src/lib/repos/recipe-key-matcher.ts +62 -0
- package/src/lib/repos/recipe-memories.ts +175 -0
- package/src/lib/repos/recipe-targets.ts +32 -28
- package/src/lib/repos/ref-search.ts +1 -1
- package/src/lib/repos/release/index-builder.ts +23 -15
- package/src/lib/repos/release/plan.ts +14 -7
- package/src/lib/repos/release/settle.ts +38 -121
- package/src/lib/repos/release/submit-checkout.ts +42 -23
- package/src/lib/repos/release/tags.ts +1 -1
- package/src/lib/repos/resolver.ts +90 -72
- package/src/lib/repos/subscription-service.ts +192 -170
- package/src/lib/settings.ts +37 -33
- package/src/lib/vars/answers.ts +9 -1
- package/src/lib/vars/ask.ts +3 -3
- package/src/lib/vars/display.ts +1 -3
- package/src/lib/vars/index.ts +1 -0
- package/src/lib/vars/mappings.ts +17 -9
- package/src/lib/vars/secrets.ts +131 -0
- package/src/services/ref-resolver/container.ts +107 -0
- package/src/services/ref-resolver/dependency.ts +60 -0
- package/src/services/ref-resolver/format.ts +197 -0
- package/src/services/ref-resolver/glob.ts +115 -0
- package/src/services/ref-resolver/hash-names.ts +155 -0
- package/src/services/ref-resolver/index.ts +55 -0
- package/src/services/ref-resolver/injectable.ts +36 -0
- package/src/services/ref-resolver/location.ts +62 -0
- package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
- package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
- package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
- package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
- package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
- package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
- package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
- package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
- package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
- package/src/services/ref-resolver/named-refs.ts +144 -0
- package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
- package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
- package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
- package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
- package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
- package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
- package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
- package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
- package/src/services/ref-resolver/parts.ts +115 -0
- package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
- package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
- package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
- package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
- package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
- package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
- package/src/services/ref-resolver/ref-helpers.ts +62 -0
- package/src/services/ref-resolver/ref-picker-service.ts +204 -0
- package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
- package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
- package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
- package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
- package/src/services/ref-resolver/source.ts +48 -0
- package/src/services/ref-resolver/tokens.ts +25 -0
- package/src/services/ref-resolver/types.ts +155 -0
- package/src/templating/init-liquid-engine.ts +33 -18
- package/src/templating/lib/secret-scope.ts +41 -0
- package/src/templating/tags/exportScalarVarsJs.ts +5 -2
- package/src/templating/tags/showVars.ts +8 -2
- package/src/lib/refs/find.ts +0 -770
- package/src/lib/refs/index.ts +0 -14
- package/src/lib/refs/parse.ts +0 -844
- package/src/lib/refs/pick.ts +0 -147
- package/src/lib/refs/scopes.ts +0 -95
- package/src/lib/refs/settle.ts +0 -219
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settling on one of the things a ref could have meant.
|
|
3
|
+
*
|
|
4
|
+
* Resolving is separate from choosing on purpose: the resolver is pure and
|
|
5
|
+
* tells the caller everything a ref could have meant, and this is the one place
|
|
6
|
+
* that decides which of them the run proceeds with. Every command that takes a
|
|
7
|
+
* ref goes through it, so a word that means two things behaves the same
|
|
8
|
+
* everywhere:
|
|
9
|
+
*
|
|
10
|
+
* - one match proceeds, and what it resolved to is written as the facts about
|
|
11
|
+
* it followed by one sentence, so the reader sees what the word they typed
|
|
12
|
+
* actually meant;
|
|
13
|
+
* - several matches are offered as a list to choose from;
|
|
14
|
+
* - `--accept-first` takes the first one in the documented listing order;
|
|
15
|
+
* - a run with no terminal fails, naming the question it could not ask and
|
|
16
|
+
* the flag that would have answered it (`src/lib/interactive.ts` holds that
|
|
17
|
+
* rule, and every prompt in sous is gated by it).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { makeInjectable } from "./injectable.js";
|
|
21
|
+
import { ConfigError } from "../../lib/errors.js";
|
|
22
|
+
import { nonInteractiveError } from "../../lib/interactive.js";
|
|
23
|
+
import {
|
|
24
|
+
formatResolvedReference,
|
|
25
|
+
type ResolvedReferenceFacts,
|
|
26
|
+
} from "./ref-report.js";
|
|
27
|
+
import { formatParagraph, log } from "../../utils/formatting.js";
|
|
28
|
+
import { askChoice } from "../../utils/prompts.js";
|
|
29
|
+
import { describeRef, refKey, refKindLabel, refSpelling } from "./format.js";
|
|
30
|
+
import { namespaceOf, recipeOf, repoOf } from "./parts.js";
|
|
31
|
+
import type { SousRef } from "./types.js";
|
|
32
|
+
|
|
33
|
+
/** The flag that answers "which one did you mean?" ahead of time. */
|
|
34
|
+
export const ACCEPT_FIRST_FLAG = "--accept-first";
|
|
35
|
+
|
|
36
|
+
/** How `RefPickerService.pick` should behave. */
|
|
37
|
+
export class RefPickArguments {
|
|
38
|
+
/** The ref exactly as it was written, for every message. */
|
|
39
|
+
readonly search: string;
|
|
40
|
+
/** Whether a question may be asked. A run that may not fails instead. */
|
|
41
|
+
readonly interactive: boolean;
|
|
42
|
+
/** Take the first match rather than asking, because `--accept-first` was passed. */
|
|
43
|
+
readonly acceptFirst: boolean;
|
|
44
|
+
/** The question to ask when there is more than one match. */
|
|
45
|
+
readonly prompt?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Write the facts about what the ref resolved to. On by default; a caller
|
|
48
|
+
* that reports the resolution itself turns it off, and is still told when
|
|
49
|
+
* `--accept-first` settled an ambiguous word.
|
|
50
|
+
*/
|
|
51
|
+
readonly announce: boolean;
|
|
52
|
+
/** Extra lines for the error a ref that matched nothing raises. */
|
|
53
|
+
readonly details: string[];
|
|
54
|
+
/** Where a resolution is reported. Defaults to the console. */
|
|
55
|
+
readonly write: (message: string) => void;
|
|
56
|
+
/** How the choice is asked. Defaults to the shared choice prompt. */
|
|
57
|
+
readonly choose?: (message: string, matches: SousRef[]) => Promise<SousRef>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param init - What was searched for, and whether a question may be asked.
|
|
61
|
+
*/
|
|
62
|
+
constructor(init: {
|
|
63
|
+
search: string;
|
|
64
|
+
interactive: boolean;
|
|
65
|
+
acceptFirst?: boolean;
|
|
66
|
+
prompt?: string;
|
|
67
|
+
announce?: boolean;
|
|
68
|
+
details?: string[];
|
|
69
|
+
write?: (message: string) => void;
|
|
70
|
+
choose?: (message: string, matches: SousRef[]) => Promise<SousRef>;
|
|
71
|
+
}) {
|
|
72
|
+
this.search = init.search;
|
|
73
|
+
this.interactive = init.interactive;
|
|
74
|
+
this.acceptFirst = init.acceptFirst ?? false;
|
|
75
|
+
if (init.prompt !== undefined) this.prompt = init.prompt;
|
|
76
|
+
this.announce = init.announce ?? true;
|
|
77
|
+
this.details = init.details ?? [];
|
|
78
|
+
this.write = init.write ?? ((message: string) => log(message));
|
|
79
|
+
if (init.choose !== undefined) this.choose = init.choose;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The facts one resolved ref carries, ready for `formatResolvedReference`.
|
|
85
|
+
*
|
|
86
|
+
* The ref knows what it is; this decides which of its fields are facts worth
|
|
87
|
+
* showing. A repository's own name is already the resolved spelling, so it is
|
|
88
|
+
* not repeated as a line of its own. What a ref resolves to is always its
|
|
89
|
+
* fully qualified key; for an environment variable name that is the key of the
|
|
90
|
+
* variable it answers, because that is what the rest of the run proceeds with.
|
|
91
|
+
*
|
|
92
|
+
* @param ref - The ref the run proceeds with.
|
|
93
|
+
* @param search - The ref exactly as it was written.
|
|
94
|
+
* @param reason - The closing sentence, when the caller has one of its own.
|
|
95
|
+
*/
|
|
96
|
+
export function resolvedRefFacts(
|
|
97
|
+
ref: SousRef,
|
|
98
|
+
search: string,
|
|
99
|
+
reason?: string
|
|
100
|
+
): ResolvedReferenceFacts {
|
|
101
|
+
const subject = ref.kind === "envVar" ? (ref.variables?.[0] ?? ref) : ref;
|
|
102
|
+
const resolvedTo = refKey(subject);
|
|
103
|
+
const repoName = repoOf(subject)?.name;
|
|
104
|
+
const recipe = recipeOf(subject);
|
|
105
|
+
const namespace = namespaceOf(subject);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
search,
|
|
109
|
+
resolvedTo,
|
|
110
|
+
kind: refKindLabel(ref),
|
|
111
|
+
...(subject.kind === "variable" ? { variable: subject.name } : {}),
|
|
112
|
+
...(recipe === undefined || subject.kind === "namespace" ? {} : { recipe: recipe.name }),
|
|
113
|
+
...(namespace === undefined ? {} : { namespace: namespace.name }),
|
|
114
|
+
...(repoName === undefined || repoName === resolvedTo ? {} : { repository: repoName }),
|
|
115
|
+
...(ref.kind === "repo" && ref.location !== undefined ? { location: ref.location.url } : {}),
|
|
116
|
+
...(ref.kind !== "repo" && subject.description !== undefined
|
|
117
|
+
? { description: subject.description }
|
|
118
|
+
: {}),
|
|
119
|
+
...(reason === undefined ? {} : { reason }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Chooses the one ref a run proceeds with. */
|
|
124
|
+
export class RefPickerService {
|
|
125
|
+
/**
|
|
126
|
+
* The one ref a run proceeds with.
|
|
127
|
+
*
|
|
128
|
+
* @param matches - What the ref could have meant, in listing order.
|
|
129
|
+
* @param args - What was searched for, and whether a question may be asked.
|
|
130
|
+
* @returns The ref the run proceeds with.
|
|
131
|
+
*/
|
|
132
|
+
async pick(matches: SousRef[], args: RefPickArguments): Promise<SousRef> {
|
|
133
|
+
if (matches.length === 0) {
|
|
134
|
+
throw new ConfigError(
|
|
135
|
+
[`Nothing called '${args.search}' was found.`, ...args.details].join("\n")
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const first = matches[0]!;
|
|
140
|
+
|
|
141
|
+
if (matches.length === 1) {
|
|
142
|
+
if (args.announce) this.announce(args, first);
|
|
143
|
+
return first;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (args.acceptFirst) {
|
|
147
|
+
const reason =
|
|
148
|
+
`'${args.search}' named ${matches.length} things, and ` +
|
|
149
|
+
`'${ACCEPT_FIRST_FLAG}' was passed, so the first one listed is being used.`;
|
|
150
|
+
|
|
151
|
+
// A caller that reports the resolution itself still has to be told that the
|
|
152
|
+
// word was ambiguous, so the sentence is written even when the facts are not.
|
|
153
|
+
if (args.announce) {
|
|
154
|
+
this.announce(args, first, reason);
|
|
155
|
+
} else {
|
|
156
|
+
for (const line of formatParagraph(reason)) args.write(line);
|
|
157
|
+
}
|
|
158
|
+
return first;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!args.interactive) {
|
|
162
|
+
// The example is a spelling that says more than what was typed, so a
|
|
163
|
+
// ref that is already its own fully qualified name is not offered back
|
|
164
|
+
// unchanged.
|
|
165
|
+
const example = refSpelling(
|
|
166
|
+
matches.find((match) => refSpelling(match) !== args.search) ?? first
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
throw nonInteractiveError({
|
|
170
|
+
prompt: `which '${args.search}' you meant`,
|
|
171
|
+
remedy:
|
|
172
|
+
`write the full reference (for example '${example}'), or pass ` +
|
|
173
|
+
`'${ACCEPT_FIRST_FLAG}' to take the first candidate listed above.`,
|
|
174
|
+
details: [
|
|
175
|
+
`'${args.search}' matched ${matches.length} things:`,
|
|
176
|
+
...matches.map((match) => ` ${describeRef(match)}`),
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const choose =
|
|
182
|
+
args.choose ??
|
|
183
|
+
((message: string, offered: SousRef[]) =>
|
|
184
|
+
askChoice(
|
|
185
|
+
message,
|
|
186
|
+
offered.map((match) => ({ name: describeRef(match), value: match }))
|
|
187
|
+
));
|
|
188
|
+
|
|
189
|
+
return choose(args.prompt ?? `Which '${args.search}' did you mean?`, matches);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Writes what a ref resolved to, as the key and value list every other set
|
|
194
|
+
* of facts in the CLI is written as, followed by one sentence saying why that
|
|
195
|
+
* candidate won.
|
|
196
|
+
*/
|
|
197
|
+
private announce(args: RefPickArguments, ref: SousRef, reason?: string): void {
|
|
198
|
+
for (const line of formatResolvedReference(resolvedRefFacts(ref, args.search, reason))) {
|
|
199
|
+
args.write(line);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
makeInjectable(RefPickerService);
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* resolved to is a set of facts, so it is written as the same key and value
|
|
7
7
|
* list every other set of facts in the CLI is written as, with one short
|
|
8
8
|
* sentence after it saying why that candidate won. One function builds it, so
|
|
9
|
-
* every command that resolves a reference reports it the same way; `
|
|
10
|
-
* in `
|
|
9
|
+
* every command that resolves a reference reports it the same way; `RefPickerService`
|
|
10
|
+
* in `ref-picker-service.ts` is the single caller, which is how every command gets it.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import {
|
|
@@ -17,8 +17,6 @@ import {
|
|
|
17
17
|
wrapText,
|
|
18
18
|
type VariableEntry,
|
|
19
19
|
} from "../../utils/formatting.js";
|
|
20
|
-
import { SCOPE_LABELS, SousScope } from "../refs/scopes.js";
|
|
21
|
-
import type { ReferenceMatch } from "../refs/find.js";
|
|
22
20
|
|
|
23
21
|
/** What a resolved reference is, in the words the report shows. */
|
|
24
22
|
export interface ResolvedReferenceFacts {
|
|
@@ -84,39 +82,3 @@ export function formatResolvedReference(facts: ResolvedReferenceFacts): string[]
|
|
|
84
82
|
|
|
85
83
|
return lines;
|
|
86
84
|
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* The facts one match carries, ready for `formatResolvedReference`.
|
|
90
|
-
*
|
|
91
|
-
* The match knows what it is; this decides which of its fields are facts worth
|
|
92
|
-
* showing. A repository's detail is where it lives rather than a summary of it,
|
|
93
|
-
* and a repository's own name is already the resolved spelling, so neither is
|
|
94
|
-
* repeated as a line of its own. What a match resolves to is always its fully
|
|
95
|
-
* qualified key, including for an environment variable name, because the key is
|
|
96
|
-
* what the rest of the run proceeds with.
|
|
97
|
-
*
|
|
98
|
-
* @param match - The match the run proceeds with.
|
|
99
|
-
* @param search - The reference exactly as it was written.
|
|
100
|
-
* @param reason - The closing sentence, when the caller has one of its own.
|
|
101
|
-
*/
|
|
102
|
-
export function resolvedReferenceFacts(
|
|
103
|
-
match: ReferenceMatch,
|
|
104
|
-
search: string,
|
|
105
|
-
reason?: string
|
|
106
|
-
): ResolvedReferenceFacts {
|
|
107
|
-
const isRepository = match.scope === SousScope.Repository;
|
|
108
|
-
const resolvedTo = match.key;
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
search,
|
|
112
|
-
resolvedTo,
|
|
113
|
-
kind: SCOPE_LABELS[match.scope],
|
|
114
|
-
...(match.variable === undefined ? {} : { variable: match.variable }),
|
|
115
|
-
...(match.recipe === undefined ? {} : { recipe: match.recipe }),
|
|
116
|
-
...(match.namespace === undefined ? {} : { namespace: match.namespace }),
|
|
117
|
-
...(match.repo === undefined || match.repo === resolvedTo ? {} : { repository: match.repo }),
|
|
118
|
-
...(isRepository && match.detail !== undefined ? { location: match.detail } : {}),
|
|
119
|
-
...(!isRepository && match.detail !== undefined ? { description: match.detail } : {}),
|
|
120
|
-
...(reason === undefined ? {} : { reason }),
|
|
121
|
-
};
|
|
122
|
-
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { RefLookup } from "./lookups/ref-lookup.js";
|
|
2
|
+
import { RefSource } from "./source.js";
|
|
3
|
+
import type { RefKind } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/** What `RefResolverService.resolve` is asked. */
|
|
6
|
+
export class RefResolveArguments {
|
|
7
|
+
/** The ref exactly as it was written. */
|
|
8
|
+
readonly input: string;
|
|
9
|
+
/** Where it was written, which decides the forms allowed. */
|
|
10
|
+
readonly from: RefSource;
|
|
11
|
+
/**
|
|
12
|
+
* What exists. When given, the readings narrow to the ones it knows, spelled
|
|
13
|
+
* the way they are published; when absent, the readings the place allows are
|
|
14
|
+
* returned as they were read.
|
|
15
|
+
*/
|
|
16
|
+
readonly lookup?: RefLookup;
|
|
17
|
+
/**
|
|
18
|
+
* The kinds of ref the caller accepts. Readings of any other kind are set
|
|
19
|
+
* aside before the lookup is asked, so a word that is a repository exactly
|
|
20
|
+
* and a namespace only ignoring case is still found as the namespace by a
|
|
21
|
+
* caller that accepts namespaces alone.
|
|
22
|
+
*/
|
|
23
|
+
readonly kinds?: readonly RefKind[];
|
|
24
|
+
/**
|
|
25
|
+
* True when a ref the place refuses in every reading (an empty word, a path
|
|
26
|
+
* of three segments) names nothing instead of raising the refusal. A command
|
|
27
|
+
* that takes any word and answers "nothing called that was found" uses it.
|
|
28
|
+
*/
|
|
29
|
+
readonly refusedIsEmpty: boolean;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param init - The ref, where it was written (the command line by default), what exists, the kinds accepted, and whether a refused ref names nothing.
|
|
33
|
+
*/
|
|
34
|
+
constructor(init: {
|
|
35
|
+
input: string;
|
|
36
|
+
from?: RefSource;
|
|
37
|
+
lookup?: RefLookup;
|
|
38
|
+
kinds?: readonly RefKind[];
|
|
39
|
+
refusedIsEmpty?: boolean;
|
|
40
|
+
}) {
|
|
41
|
+
this.input = init.input;
|
|
42
|
+
this.from = init.from ?? RefSource.CommandLine;
|
|
43
|
+
if (init.lookup !== undefined) this.lookup = init.lookup;
|
|
44
|
+
if (init.kinds !== undefined) this.kinds = init.kinds;
|
|
45
|
+
this.refusedIsEmpty = init.refusedIsEmpty ?? false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DroppedRef } from "./pruners/ref-pruner.js";
|
|
2
|
+
import type { SousRef } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/** What `RefResolverService.resolve` found. */
|
|
5
|
+
export class RefResolveResult {
|
|
6
|
+
/**
|
|
7
|
+
* @param input - The ref exactly as it was written.
|
|
8
|
+
* @param refs - The refs it names, best first: the more qualified the spelling that
|
|
9
|
+
* matched, the earlier. Empty when a lookup knew none of the readings.
|
|
10
|
+
* @param dropped - The readings the place refused, with the reason for each.
|
|
11
|
+
* @param warnings - Sentences about readings that were kept all the same.
|
|
12
|
+
* @param usedLookup - True when `refs` were narrowed against what exists.
|
|
13
|
+
*/
|
|
14
|
+
constructor(
|
|
15
|
+
readonly input: string,
|
|
16
|
+
readonly refs: SousRef[],
|
|
17
|
+
readonly dropped: DroppedRef[],
|
|
18
|
+
readonly warnings: string[],
|
|
19
|
+
readonly usedLookup: boolean
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
/** True when exactly one ref is left, so the caller has nothing to decide. */
|
|
23
|
+
get isUnique(): boolean {
|
|
24
|
+
return this.refs.length === 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True when no ref is left. */
|
|
28
|
+
get isEmpty(): boolean {
|
|
29
|
+
return this.refs.length === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The best ref, or undefined when none is left. */
|
|
33
|
+
first(): SousRef | undefined {
|
|
34
|
+
return this.refs[0];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ref resolver: parse a written ref, prune it by where it was written,
|
|
3
|
+
* and, when the caller knows what exists, narrow it to what is there.
|
|
4
|
+
*
|
|
5
|
+
* It orchestrates three parts that know nothing of one another: a `RefParser`
|
|
6
|
+
* (every reading of the text), a `RefPruner` per place (which readings that
|
|
7
|
+
* place allows) and, per call, a `RefLookup` (which readings exist). What to do
|
|
8
|
+
* about a ref that is still ambiguous, and how to find the ref inside the text
|
|
9
|
+
* around it, are the caller's decisions; the helpers here and in
|
|
10
|
+
* `ref-helpers.ts` assist with both.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { makeInjectable } from "./injectable.js";
|
|
14
|
+
import { REF_TOKENS } from "./tokens.js";
|
|
15
|
+
import { ConfigError } from "../../lib/errors.js";
|
|
16
|
+
import { refIdentity } from "./format.js";
|
|
17
|
+
import type { RefLookup, RefMatch, SyncRefLookup } from "./lookups/ref-lookup.js";
|
|
18
|
+
import { repoOf } from "./parts.js";
|
|
19
|
+
import type { RefParser } from "./parser/ref-parser.js";
|
|
20
|
+
import { DroppedRef, type RefPruner } from "./pruners/ref-pruner.js";
|
|
21
|
+
import { RefResolveArguments } from "./ref-resolve-arguments.js";
|
|
22
|
+
import { RefResolveResult } from "./ref-resolve-result.js";
|
|
23
|
+
import { RefSource } from "./source.js";
|
|
24
|
+
import { REF_KINDS, type SousRef } from "./types.js";
|
|
25
|
+
|
|
26
|
+
/** One ref found inside a longer text. */
|
|
27
|
+
export type RefInString = {
|
|
28
|
+
/** The text that parsed, with the punctuation around it taken off. */
|
|
29
|
+
text: string;
|
|
30
|
+
/** Where it starts in the string. */
|
|
31
|
+
index: number;
|
|
32
|
+
/** What it resolved to. */
|
|
33
|
+
result: RefResolveResult;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How qualified the spelling that produced a candidate was. Lower is more
|
|
38
|
+
* specific, and the listing order follows it: a fully qualified ref first, then
|
|
39
|
+
* a partly qualified one, then a bare name, and an environment variable name
|
|
40
|
+
* last (the least likely reading of a plain word).
|
|
41
|
+
*
|
|
42
|
+
* @param candidate - One reading of a written ref.
|
|
43
|
+
*/
|
|
44
|
+
export function qualificationOf(candidate: SousRef): number {
|
|
45
|
+
if (candidate.kind === "envVar") return 3;
|
|
46
|
+
if (candidate.kind === "repo" || repoOf(candidate) !== undefined) return 0;
|
|
47
|
+
const partial =
|
|
48
|
+
(candidate.kind === "recipe" && candidate.namespace !== undefined) ||
|
|
49
|
+
candidate.kind === "recipeFile" ||
|
|
50
|
+
(candidate.kind === "variable" && candidate.recipe !== undefined);
|
|
51
|
+
return partial ? 1 : 2;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolves written refs. */
|
|
55
|
+
export class RefResolverService {
|
|
56
|
+
/**
|
|
57
|
+
* @param parser - Reads every way a text can be a ref.
|
|
58
|
+
* @param pruners - Decide, one place each, which readings count there.
|
|
59
|
+
*/
|
|
60
|
+
constructor(
|
|
61
|
+
private readonly parser: RefParser,
|
|
62
|
+
private readonly pruners: RefPruner[]
|
|
63
|
+
) {}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parses a ref and prunes it by where it was written, with no lookup.
|
|
67
|
+
*
|
|
68
|
+
* @param input - The ref exactly as it was written.
|
|
69
|
+
* @param from - Where it was written.
|
|
70
|
+
* @throws A ConfigError listing every reason when the place refuses every reading.
|
|
71
|
+
*/
|
|
72
|
+
parse(input: string, from: RefSource = RefSource.CommandLine): RefResolveResult {
|
|
73
|
+
const { kept, dropped, warnings, place } = this.prune(input, from);
|
|
74
|
+
if (kept.length === 0) throw this.refusal(input, place, dropped);
|
|
75
|
+
return new RefResolveResult(input, this.order(kept.map((ref) => ({ ref, rank: ref }))), dropped, warnings, false);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parses a ref, prunes it by where it was written and, when a lookup is
|
|
80
|
+
* given, narrows it to what exists. When `kinds` is given, readings of other
|
|
81
|
+
* kinds are set aside first, and so are the matches of other kinds. A browser
|
|
82
|
+
* URL is the one reading that is a repository until a lookup settles it into
|
|
83
|
+
* the namespace or recipe it names, so it is kept for a caller that accepts
|
|
84
|
+
* namespaces or recipes.
|
|
85
|
+
*
|
|
86
|
+
* With a lookup, every kept reading is asked; the exact-spelling matches are
|
|
87
|
+
* kept when there are any, and the case-insensitive ones otherwise; matches
|
|
88
|
+
* are de-duplicated and returned in the spelling they are published in. A
|
|
89
|
+
* ref nothing knows comes back with no refs; that is for the caller to
|
|
90
|
+
* report. A lookup that could not find out throws.
|
|
91
|
+
*
|
|
92
|
+
* @param args - The ref, where it was written, and what exists.
|
|
93
|
+
* @throws A ConfigError listing every reason when the place refuses every reading.
|
|
94
|
+
*/
|
|
95
|
+
async resolve(args: RefResolveArguments): Promise<RefResolveResult> {
|
|
96
|
+
const early = this.beginResolve(args);
|
|
97
|
+
if ("result" in early) return early.result;
|
|
98
|
+
const found: Array<{ match: RefMatch; rank: SousRef }> = [];
|
|
99
|
+
if (early.lookup !== undefined) {
|
|
100
|
+
for (const candidate of early.kept) {
|
|
101
|
+
for (const match of await early.lookup.find(candidate)) {
|
|
102
|
+
found.push({ match, rank: candidate });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return this.finishResolve(args, early, found);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The same as `resolve`, for a caller that cannot wait: it asks the lookup's
|
|
111
|
+
* `findSync`, so the lookup must have one. A template engine's path
|
|
112
|
+
* resolution is synchronous, and is why this exists.
|
|
113
|
+
*
|
|
114
|
+
* @param args - The ref, where it was written, and what exists (a lookup with `findSync`).
|
|
115
|
+
* @throws A ConfigError listing every reason when the place refuses every reading.
|
|
116
|
+
*/
|
|
117
|
+
resolveSync(args: RefResolveArguments): RefResolveResult {
|
|
118
|
+
const early = this.beginResolve(args);
|
|
119
|
+
if ("result" in early) return early.result;
|
|
120
|
+
const found: Array<{ match: RefMatch; rank: SousRef }> = [];
|
|
121
|
+
if (early.lookup !== undefined) {
|
|
122
|
+
const lookup = early.lookup as Partial<SyncRefLookup>;
|
|
123
|
+
if (lookup.findSync === undefined) {
|
|
124
|
+
throw new ConfigError("resolveSync needs a lookup that has a findSync method.");
|
|
125
|
+
}
|
|
126
|
+
for (const candidate of early.kept) {
|
|
127
|
+
for (const match of lookup.findSync(candidate)) found.push({ match, rank: candidate });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return this.finishResolve(args, early, found);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parses and prunes without refusing: the readings a place keeps and the
|
|
135
|
+
* ones it drops, with the reason for each. For a caller that explains a
|
|
136
|
+
* refusal in its own words.
|
|
137
|
+
*
|
|
138
|
+
* @param input - The ref exactly as it was written.
|
|
139
|
+
* @param from - Where it was written.
|
|
140
|
+
* @throws A ConfigError when the text is not a ref in any reading.
|
|
141
|
+
*/
|
|
142
|
+
inspect(input: string, from: RefSource): { kept: SousRef[]; dropped: DroppedRef[] } {
|
|
143
|
+
const { kept, dropped } = this.prune(input, from);
|
|
144
|
+
return { kept, dropped };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Everything `resolve` does before it asks a lookup, or the answer when it needs none. */
|
|
148
|
+
private beginResolve(
|
|
149
|
+
args: RefResolveArguments
|
|
150
|
+
):
|
|
151
|
+
| { result: RefResolveResult }
|
|
152
|
+
| {
|
|
153
|
+
kept: SousRef[];
|
|
154
|
+
dropped: DroppedRef[];
|
|
155
|
+
warnings: string[];
|
|
156
|
+
lookup: RefLookup | undefined;
|
|
157
|
+
} {
|
|
158
|
+
let pruned;
|
|
159
|
+
try {
|
|
160
|
+
pruned = this.prune(args.input, args.from);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// A text no reading exists for (empty, say) is refused the same way.
|
|
163
|
+
if (args.refusedIsEmpty) return { result: new RefResolveResult(args.input, [], [], [], true) };
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
const { dropped, warnings, place } = pruned;
|
|
167
|
+
if (pruned.kept.length === 0) {
|
|
168
|
+
if (args.refusedIsEmpty) {
|
|
169
|
+
return { result: new RefResolveResult(args.input, [], dropped, warnings, true) };
|
|
170
|
+
}
|
|
171
|
+
throw this.refusal(args.input, place, dropped);
|
|
172
|
+
}
|
|
173
|
+
const kinds = args.kinds;
|
|
174
|
+
const kept =
|
|
175
|
+
kinds === undefined
|
|
176
|
+
? pruned.kept
|
|
177
|
+
: pruned.kept.filter(
|
|
178
|
+
(ref) =>
|
|
179
|
+
kinds.includes(ref.kind) ||
|
|
180
|
+
(ref.kind === "repo" &&
|
|
181
|
+
ref.browsed !== undefined &&
|
|
182
|
+
(kinds.includes("namespace") || kinds.includes("recipe")))
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (args.lookup === undefined) {
|
|
186
|
+
return {
|
|
187
|
+
result: new RefResolveResult(
|
|
188
|
+
args.input,
|
|
189
|
+
this.order(kept.map((ref) => ({ ref, rank: ref }))),
|
|
190
|
+
dropped,
|
|
191
|
+
warnings,
|
|
192
|
+
false
|
|
193
|
+
),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return { kept, dropped, warnings, lookup: args.lookup };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Everything `resolve` does with what a lookup found. */
|
|
200
|
+
private finishResolve(
|
|
201
|
+
args: RefResolveArguments,
|
|
202
|
+
early: { dropped: DroppedRef[]; warnings: string[] },
|
|
203
|
+
found: Array<{ match: RefMatch; rank: SousRef }>
|
|
204
|
+
): RefResolveResult {
|
|
205
|
+
const kinds = args.kinds;
|
|
206
|
+
const matching = found.filter(
|
|
207
|
+
(entry) => kinds === undefined || kinds.includes(entry.match.ref.kind)
|
|
208
|
+
);
|
|
209
|
+
const exact = matching.filter((entry) => entry.match.exactSpelling);
|
|
210
|
+
const chosen = exact.length > 0 ? exact : matching;
|
|
211
|
+
|
|
212
|
+
return new RefResolveResult(
|
|
213
|
+
args.input,
|
|
214
|
+
this.order(chosen.map((entry) => ({ ref: entry.match.ref, rank: entry.rank }))),
|
|
215
|
+
early.dropped,
|
|
216
|
+
early.warnings,
|
|
217
|
+
true
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* True when the ref parses and the place allows at least one reading of it.
|
|
223
|
+
*
|
|
224
|
+
* @param input - The ref exactly as it was written.
|
|
225
|
+
* @param from - Where it was written.
|
|
226
|
+
*/
|
|
227
|
+
isValidRef(input: string, from: RefSource = RefSource.CommandLine): boolean {
|
|
228
|
+
try {
|
|
229
|
+
this.parse(input, from);
|
|
230
|
+
return true;
|
|
231
|
+
} catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Every ref found in a longer text, one per whitespace-separated word that
|
|
238
|
+
* is a valid ref where it was written, with the quotes, brackets and
|
|
239
|
+
* sentence punctuation around the word taken off. Words that are not refs
|
|
240
|
+
* are left out.
|
|
241
|
+
*
|
|
242
|
+
* getRefsInString("see 'workflow/task-files', then run it.");
|
|
243
|
+
* // -> workflow/task-files, and the other words that happen to read as a ref
|
|
244
|
+
*
|
|
245
|
+
* @param text - The text to search.
|
|
246
|
+
* @param from - Where the text was written.
|
|
247
|
+
*/
|
|
248
|
+
getRefsInString(text: string, from: RefSource = RefSource.CommandLine): RefInString[] {
|
|
249
|
+
const found: RefInString[] = [];
|
|
250
|
+
for (const word of text.matchAll(/\S+/g)) {
|
|
251
|
+
const bare = word[0].replace(/^[`"'([<{]+/, "").replace(/[`"')\]>},.;:!]+$/, "");
|
|
252
|
+
if (bare.length === 0) continue;
|
|
253
|
+
const start = word.index + word[0].indexOf(bare);
|
|
254
|
+
try {
|
|
255
|
+
found.push({ text: bare, index: start, result: this.parse(bare, from) });
|
|
256
|
+
} catch {
|
|
257
|
+
// Not a ref; the words around it may be.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return found;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Parses and prunes, and says where the ref was written in words. */
|
|
264
|
+
private prune(
|
|
265
|
+
input: string,
|
|
266
|
+
from: RefSource
|
|
267
|
+
): { kept: SousRef[]; dropped: DroppedRef[]; warnings: string[]; place: string } {
|
|
268
|
+
const pruners = this.pruners.filter((pruner) => pruner.source === from);
|
|
269
|
+
if (pruners.length === 0) {
|
|
270
|
+
throw new ConfigError(`No pruner is registered for a ref written as '${from}'.`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let kept = this.parser.parse(input);
|
|
274
|
+
const dropped: DroppedRef[] = [];
|
|
275
|
+
const warnings: string[] = [];
|
|
276
|
+
for (const pruner of pruners) {
|
|
277
|
+
const result = pruner.prune(kept, input.trim());
|
|
278
|
+
kept = result.kept;
|
|
279
|
+
dropped.push(...result.dropped);
|
|
280
|
+
warnings.push(...result.warnings);
|
|
281
|
+
}
|
|
282
|
+
return { kept, dropped, warnings, place: pruners[0]!.place };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The error for a ref the place refuses in every reading. */
|
|
286
|
+
private refusal(input: string, place: string, dropped: DroppedRef[]): ConfigError {
|
|
287
|
+
const reasons = [...new Set(dropped.map((entry) => entry.describe()))];
|
|
288
|
+
if (reasons.length === 1) {
|
|
289
|
+
return new ConfigError(`The ref '${input.trim()}' cannot be written ${place}: ${reasons[0]}`);
|
|
290
|
+
}
|
|
291
|
+
return new ConfigError(
|
|
292
|
+
`The ref '${input.trim()}' cannot be written ${place}. Every way it reads is refused:\n` +
|
|
293
|
+
reasons.map((reason) => ` - ${reason}`).join("\n")
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Sorts refs into the documented listing order and drops any the same
|
|
299
|
+
* search found twice: by how qualified the spelling that produced each was,
|
|
300
|
+
* then by kind, then in the order they came (a lookup lists repositories in
|
|
301
|
+
* the order the project searches them).
|
|
302
|
+
*/
|
|
303
|
+
private order(entries: Array<{ ref: SousRef; rank: SousRef }>): SousRef[] {
|
|
304
|
+
const indexed = entries.map((entry, position) => ({ ...entry, position }));
|
|
305
|
+
indexed.sort(
|
|
306
|
+
(left, right) =>
|
|
307
|
+
qualificationOf(left.rank) - qualificationOf(right.rank) ||
|
|
308
|
+
REF_KINDS.indexOf(left.ref.kind) - REF_KINDS.indexOf(right.ref.kind) ||
|
|
309
|
+
left.position - right.position
|
|
310
|
+
);
|
|
311
|
+
const seen = new Set<string>();
|
|
312
|
+
return indexed
|
|
313
|
+
.map((entry) => entry.ref)
|
|
314
|
+
.filter((ref) => {
|
|
315
|
+
const identity = `${refIdentity(ref)} ${JSON.stringify(ref.vars ?? {})}`;
|
|
316
|
+
if (seen.has(identity)) return false;
|
|
317
|
+
seen.add(identity);
|
|
318
|
+
return true;
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
makeInjectable(RefResolverService, [REF_TOKENS.Parser, { multi: REF_TOKENS.Pruner }]);
|