@sous-io/sous 0.2.31 → 0.2.33

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.
Files changed (104) hide show
  1. package/README.md +7 -6
  2. package/docs/markdown/_sidebar.md +2 -0
  3. package/docs/markdown/commands.md +8 -4
  4. package/docs/markdown/configuration.md +28 -4
  5. package/docs/markdown/repositories-authoring.md +22 -2
  6. package/docs/markdown/repositories-consuming.md +40 -11
  7. package/docs/markdown/repositories-file-formats.md +28 -13
  8. package/docs/markdown/repositories-quickstart.md +1 -1
  9. package/docs/markdown/repositories.md +14 -9
  10. package/package.json +2 -1
  11. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +32 -17
  12. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +8 -2
  13. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +4 -0
  14. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +29 -9
  15. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  16. package/sous.config.schema.json +27 -15
  17. package/src/commands/lock/rebuild.ts +3 -6
  18. package/src/commands/namespace/show.ts +2 -2
  19. package/src/commands/recipe/show.ts +26 -9
  20. package/src/commands/repo/contribute.ts +32 -23
  21. package/src/commands/repo/gc.ts +1 -1
  22. package/src/commands/repo/remove.ts +1 -1
  23. package/src/commands/repo/search.ts +1 -1
  24. package/src/commands/vars/ask.ts +75 -54
  25. package/src/lib/build-service.ts +85 -11
  26. package/src/lib/config-schema.ts +59 -19
  27. package/src/lib/include-resolver.ts +307 -69
  28. package/src/lib/markdown-compiler.ts +273 -81
  29. package/src/lib/project-scaffold/templates.ts +19 -11
  30. package/src/lib/repos/catalog-inputs.ts +1 -1
  31. package/src/lib/repos/catalog.ts +98 -72
  32. package/src/lib/repos/declarations.ts +22 -12
  33. package/src/lib/repos/formats/index-file.ts +1 -1
  34. package/src/lib/repos/formats/recipe-manifest.ts +3 -4
  35. package/src/lib/repos/lock-service.ts +1 -1
  36. package/src/lib/repos/locked-namespace-resolver.ts +14 -6
  37. package/src/lib/repos/locked-recipe-lookup.ts +123 -0
  38. package/src/lib/repos/locked-recipes.ts +1 -1
  39. package/src/lib/repos/memories-hash-name.ts +33 -0
  40. package/src/lib/repos/namespace-resolver.ts +198 -119
  41. package/src/lib/repos/recipe-config-layers.ts +29 -1
  42. package/src/lib/repos/recipe-key-matcher.ts +62 -0
  43. package/src/lib/repos/recipe-memories.ts +175 -0
  44. package/src/lib/repos/recipe-targets.ts +27 -28
  45. package/src/lib/repos/ref-search.ts +1 -1
  46. package/src/lib/repos/release/index-builder.ts +23 -15
  47. package/src/lib/repos/release/plan.ts +14 -7
  48. package/src/lib/repos/release/settle.ts +38 -121
  49. package/src/lib/repos/release/submit-checkout.ts +42 -23
  50. package/src/lib/repos/release/tags.ts +1 -1
  51. package/src/lib/repos/resolver.ts +90 -72
  52. package/src/lib/repos/subscription-service.ts +192 -170
  53. package/src/lib/settings.ts +37 -33
  54. package/src/lib/vars/ask.ts +3 -3
  55. package/src/lib/vars/mappings.ts +17 -9
  56. package/src/services/ref-resolver/container.ts +107 -0
  57. package/src/services/ref-resolver/dependency.ts +60 -0
  58. package/src/services/ref-resolver/format.ts +197 -0
  59. package/src/services/ref-resolver/glob.ts +115 -0
  60. package/src/services/ref-resolver/hash-names.ts +155 -0
  61. package/src/services/ref-resolver/index.ts +55 -0
  62. package/src/services/ref-resolver/injectable.ts +36 -0
  63. package/src/services/ref-resolver/location.ts +62 -0
  64. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
  65. package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
  66. package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
  67. package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
  68. package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
  69. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
  70. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
  71. package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
  72. package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
  73. package/src/services/ref-resolver/named-refs.ts +144 -0
  74. package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
  75. package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
  76. package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
  77. package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
  78. package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
  79. package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
  80. package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
  81. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
  82. package/src/services/ref-resolver/parts.ts +115 -0
  83. package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
  84. package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
  85. package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
  86. package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
  87. package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
  88. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
  89. package/src/services/ref-resolver/ref-helpers.ts +62 -0
  90. package/src/services/ref-resolver/ref-picker-service.ts +204 -0
  91. package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
  92. package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
  93. package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
  94. package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
  95. package/src/services/ref-resolver/source.ts +48 -0
  96. package/src/services/ref-resolver/tokens.ts +25 -0
  97. package/src/services/ref-resolver/types.ts +155 -0
  98. package/src/templating/init-liquid-engine.ts +25 -18
  99. package/src/lib/refs/find.ts +0 -770
  100. package/src/lib/refs/index.ts +0 -14
  101. package/src/lib/refs/parse.ts +0 -844
  102. package/src/lib/refs/pick.ts +0 -147
  103. package/src/lib/refs/scopes.ts +0 -95
  104. package/src/lib/refs/settle.ts +0 -219
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Reads the names and paths of a ref: the namespace, the recipe, the path of a
3
+ * file inside a recipe, and the name of a variable after a `.`.
4
+ */
5
+
6
+ import { makeInjectable } from "../injectable.js";
7
+ import {
8
+ ENV_VAR_NAME,
9
+ hasGlob,
10
+ isGlobName,
11
+ NAME_ANY_CASE,
12
+ splitSegments,
13
+ VARIABLE_NAME,
14
+ } from "../glob.js";
15
+ import type { NamespaceRef, RecipeRef, SousRef } from "../types.js";
16
+ import { BaseRefSplitter, finishRef, type PartialRef } from "./partial-ref.js";
17
+
18
+ /** The characters a path inside a recipe may use: anything but whitespace and a backslash. */
19
+ const FILE_PATH = /^[^\s\\]+$/;
20
+
21
+ /** True when a segment is a name (in any case) or a glob pattern standing for one. */
22
+ function isName(segment: string): boolean {
23
+ return NAME_ANY_CASE.test(segment) || isGlobName(segment);
24
+ }
25
+
26
+ /** The sentence for a name that is not one. */
27
+ function badName(what: string, name: string): string {
28
+ return `the ${what} '${name}' must be kebab-case: a letter, then letters, digits or hyphens.`;
29
+ }
30
+
31
+ /** The sentence for a range that has nothing to apply to. */
32
+ const NAMESPACE_NOT_VERSIONED =
33
+ "a version range applies to a recipe, and namespaces are not versioned. Name a " +
34
+ "recipe, as in 'workflow/task-files@^1.2.0'.";
35
+
36
+ /**
37
+ * Splits what is left of the text at `/` and reads it every way it can be
38
+ * read:
39
+ *
40
+ * workflow a namespace, a recipe, a repository or a variable
41
+ * workflow/alpha a recipe
42
+ * workflow/* a namespace spelled out, and every recipe in it
43
+ * task-files.apiUrl a variable of a recipe
44
+ * workflow/alpha.apiUrl a variable of a recipe in a namespace
45
+ * workflow/alpha/SKILL.md a file inside a recipe
46
+ *
47
+ * Any name or path may be a glob pattern. The text is also left unread, so the
48
+ * environment variable splitter may read the same word.
49
+ */
50
+ export class NamePathSplitter extends BaseRefSplitter {
51
+ readonly order = 500;
52
+
53
+ protected read(state: PartialRef): PartialRef[] {
54
+ const segments = splitSegments(state.rest);
55
+ const out: PartialRef[] = [state];
56
+ const make = (ref: SousRef): void => {
57
+ out.push(finishRef(state, ref));
58
+ };
59
+
60
+ if (segments[0] === "") {
61
+ state.problems.push("the namespace is empty.");
62
+ return out;
63
+ }
64
+ const last = segments[segments.length - 1];
65
+ if (segments.length > 1 && segments.includes("")) {
66
+ state.problems.push(
67
+ last === ""
68
+ ? "the recipe name after '/' is empty."
69
+ : "a ref has an empty segment between two '/'."
70
+ );
71
+ return out;
72
+ }
73
+
74
+ const repo = state.repo;
75
+ const range = state.range;
76
+ const withGlob = (names: string[]) => (names.some(hasGlob) ? { glob: true as const } : {});
77
+ const ns = (name: string): NamespaceRef => ({
78
+ kind: "namespace",
79
+ name,
80
+ ...(repo === undefined ? {} : { repo }),
81
+ });
82
+
83
+ if (segments.length === 1) {
84
+ const text = segments[0]!;
85
+ const dot = text.indexOf(".");
86
+ // A location names a repository, so only a namespace can follow it here.
87
+ const located = repo?.location !== undefined;
88
+
89
+ if (!located && dot > 0 && dot < text.length - 1) {
90
+ const left = text.slice(0, dot);
91
+ const right = text.slice(dot + 1);
92
+ if (isName(left) && (VARIABLE_NAME.test(right) || isGlobName(right))) {
93
+ if (range === undefined) {
94
+ make({
95
+ kind: "variable",
96
+ name: right,
97
+ recipe: {
98
+ kind: "recipe",
99
+ name: left,
100
+ ...(repo === undefined ? {} : { repo }),
101
+ },
102
+ ...withGlob([left, right]),
103
+ });
104
+ }
105
+ }
106
+ }
107
+
108
+ if (isName(text)) {
109
+ if (range === undefined) {
110
+ make({ ...ns(text), ...withGlob([text]) });
111
+ if (repo === undefined) make({ kind: "repo", name: text, ...withGlob([text]) });
112
+ } else {
113
+ state.problems.push(NAMESPACE_NOT_VERSIONED);
114
+ }
115
+ if (!located) {
116
+ make({
117
+ kind: "recipe",
118
+ name: text,
119
+ ...(repo === undefined ? {} : { repo }),
120
+ ...(range === undefined ? {} : { range }),
121
+ ...withGlob([text]),
122
+ });
123
+ }
124
+ }
125
+ if (!located && range === undefined && (VARIABLE_NAME.test(text) || isGlobName(text))) {
126
+ make({
127
+ kind: "variable",
128
+ name: text,
129
+ ...(repo === undefined ? {} : { repo }),
130
+ ...withGlob([text]),
131
+ });
132
+ }
133
+ const readsAsOther =
134
+ repo === undefined && (VARIABLE_NAME.test(text) || ENV_VAR_NAME.test(text));
135
+ if (!isName(text) && !readsAsOther && dot <= 0) {
136
+ state.problems.push(badName("namespace", text));
137
+ }
138
+ return out;
139
+ }
140
+
141
+ const [namespace, second] = segments as [string, string, ...string[]];
142
+ if (!isName(namespace)) {
143
+ state.problems.push(badName("namespace", namespace));
144
+ return out;
145
+ }
146
+
147
+ if (segments.length === 2) {
148
+ const dot = second.indexOf(".");
149
+ if (second === "*") {
150
+ if (range === undefined) make({ ...ns(namespace), wildcard: true, ...withGlob([namespace]) });
151
+ else state.problems.push(NAMESPACE_NOT_VERSIONED);
152
+ }
153
+ if (dot > 0 && dot < second.length - 1 && range === undefined) {
154
+ const left = second.slice(0, dot);
155
+ const right = second.slice(dot + 1);
156
+ if (isName(left) && (VARIABLE_NAME.test(right) || isGlobName(right))) {
157
+ make({
158
+ kind: "variable",
159
+ name: right,
160
+ recipe: { kind: "recipe", name: left, namespace: ns(namespace) },
161
+ ...withGlob([namespace, left, right]),
162
+ });
163
+ }
164
+ }
165
+ if (isName(second)) {
166
+ make({
167
+ kind: "recipe",
168
+ name: second,
169
+ namespace: ns(namespace),
170
+ ...(range === undefined ? {} : { range }),
171
+ ...withGlob([namespace, second]),
172
+ });
173
+ } else if (dot <= 0) {
174
+ state.problems.push(badName("recipe name", second));
175
+ }
176
+ return out;
177
+ }
178
+
179
+ const path = segments.slice(2).join("/");
180
+ if (!isName(second)) {
181
+ state.problems.push(badName("recipe name", second));
182
+ return out;
183
+ }
184
+ if (!FILE_PATH.test(path)) {
185
+ state.problems.push(`the path '${path}' inside a recipe holds a space or a backslash.`);
186
+ return out;
187
+ }
188
+ const recipe: RecipeRef = { kind: "recipe", name: second, namespace: ns(namespace) };
189
+ make({
190
+ kind: "recipeFile",
191
+ path,
192
+ recipe,
193
+ ...(range === undefined ? {} : { range }),
194
+ ...withGlob([namespace, second, path]),
195
+ });
196
+ return out;
197
+ }
198
+ }
199
+
200
+ makeInjectable(NamePathSplitter);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The shapes the parser works with: a reading in progress, and the splitter
3
+ * that advances it.
4
+ */
5
+
6
+ import type { RepoRef, SousRef } from "../types.js";
7
+
8
+ /**
9
+ * A reading in progress. A splitter takes some of `rest` and records what it
10
+ * found beside it; a reading is finished when `ref` is set and `rest` is empty.
11
+ */
12
+ export type PartialRef = {
13
+ /** The whole input, trimmed. Never changes. */
14
+ input: string;
15
+ /** The text no splitter has read yet. */
16
+ rest: string;
17
+ /** The `?name=value` pairs read so far. */
18
+ vars?: Record<string, string>;
19
+ /** The version range read so far. */
20
+ range?: string;
21
+ /** The repository qualifier read so far. */
22
+ repo?: RepoRef;
23
+ /** The finished ref, once a splitter has made one. */
24
+ ref?: SousRef;
25
+ /**
26
+ * Why a splitter turned the text down, shared by every reading of one input.
27
+ * The parser quotes these when nothing reads, and says nothing of them
28
+ * otherwise.
29
+ */
30
+ problems: string[];
31
+ };
32
+
33
+ /**
34
+ * One step of the parser. A splitter reads whatever part of `rest` it
35
+ * recognizes (the front or the end) and returns EVERY way to read it, as
36
+ * partial refs of its own. A splitter that does not recognize anything returns
37
+ * the state unchanged, and one that could also leave the text for a later
38
+ * splitter returns the state among its readings. A splitter never looks at
39
+ * where the ref was written.
40
+ */
41
+ export interface RefSplitter {
42
+ /** Where this splitter runs among the others: lowest first. */
43
+ readonly order: number;
44
+ /**
45
+ * Advances one reading.
46
+ *
47
+ * @param state - The reading so far.
48
+ * @returns Every reading that can follow it.
49
+ */
50
+ split(state: PartialRef): PartialRef[];
51
+ }
52
+
53
+ /**
54
+ * A splitter that leaves a finished reading alone, so each subclass only says
55
+ * how it reads text that is still unread.
56
+ */
57
+ export abstract class BaseRefSplitter implements RefSplitter {
58
+ abstract readonly order: number;
59
+
60
+ split(state: PartialRef): PartialRef[] {
61
+ if (state.ref !== undefined) return [state];
62
+ return this.read(state);
63
+ }
64
+
65
+ /**
66
+ * Reads an unfinished reading.
67
+ *
68
+ * @param state - A reading with no finished ref.
69
+ */
70
+ protected abstract read(state: PartialRef): PartialRef[];
71
+ }
72
+
73
+ /**
74
+ * The state with a finished ref in it, carrying the query values and, for the
75
+ * kinds that take one, the version range.
76
+ *
77
+ * @param state - The reading so far.
78
+ * @param ref - The finished ref.
79
+ */
80
+ export function finishRef(state: PartialRef, ref: SousRef): PartialRef {
81
+ const withVars: SousRef =
82
+ state.vars === undefined ? ref : ({ ...ref, vars: state.vars } as SousRef);
83
+ return { ...state, rest: "", ref: withVars };
84
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Reads the `?name=value&name=value` at the end of a ref.
3
+ */
4
+
5
+ import { makeInjectable } from "../injectable.js";
6
+ import { BaseRefSplitter, type PartialRef } from "./partial-ref.js";
7
+
8
+ /** A URL that names its scheme, such as `https://` or `github://`. */
9
+ const SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
10
+
11
+ /** One pair of a query: a percent-encoded name, `=`, and a percent-encoded value. */
12
+ const PAIR = /^([A-Za-z0-9\-._~%+!*'(),:@/$]+)=([A-Za-z0-9\-._~%+!*'(),:@/$]*)$/;
13
+
14
+ /**
15
+ * The decoded pairs of a query, or undefined when it is not a valid
16
+ * percent-encoded query string.
17
+ *
18
+ * decodeQuery("a=1&b=two%20words"); // -> { a: "1", b: "two words" }
19
+ * decodeQuery("a=%zz"); // -> undefined
20
+ *
21
+ * @param query - The text after the `?`.
22
+ */
23
+ export function decodeQuery(query: string): Record<string, string> | undefined {
24
+ if (query.length === 0) return undefined;
25
+ const vars: Record<string, string> = {};
26
+ for (const pair of query.split("&")) {
27
+ const match = PAIR.exec(pair);
28
+ if (match === null) return undefined;
29
+ try {
30
+ vars[decodeURIComponent(match[1]!.replace(/\+/g, " "))] = decodeURIComponent(
31
+ match[2]!.replace(/\+/g, " ")
32
+ );
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ return vars;
38
+ }
39
+
40
+ /**
41
+ * Takes a valid, percent-encoded query off the end of the text and keeps its
42
+ * pairs. A string that starts with a URL scheme is left alone, because a URL's
43
+ * query belongs to the URL. When the text before the `?` could also be a glob
44
+ * path holding a `?` and an `=`, both readings are kept.
45
+ */
46
+ export class QuerySplitter extends BaseRefSplitter {
47
+ readonly order = 100;
48
+
49
+ protected read(state: PartialRef): PartialRef[] {
50
+ if (SCHEME.test(state.rest)) return [state];
51
+ const mark = state.rest.lastIndexOf("?");
52
+ if (mark <= 0) return [state];
53
+
54
+ const vars = decodeQuery(state.rest.slice(mark + 1));
55
+ if (vars === undefined) return [state];
56
+
57
+ return [{ ...state, rest: state.rest.slice(0, mark), vars: { ...state.vars, ...vars } }, state];
58
+ }
59
+ }
60
+
61
+ makeInjectable(QuerySplitter);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Reads the `@^1.2` version range at the end of a ref.
3
+ */
4
+
5
+ import { makeInjectable } from "../injectable.js";
6
+ import semver from "semver";
7
+ import { BaseRefSplitter, type PartialRef } from "./partial-ref.js";
8
+
9
+ /** A URL that names its scheme, such as `https://` or `github://`. */
10
+ const SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
11
+
12
+ /** The `scp`-style SSH form, as in `git@github.com:owner/name.git`. */
13
+ const SCP = /^[^@\s/:]+@([^@\s/:]+):(.+)$/s;
14
+
15
+ /**
16
+ * Takes a version range off the end of the text: whatever follows the last `@`
17
+ * that comes after the last `/`, so the `@` of an SSH remote
18
+ * (`git@host:owner/repo`) is never mistaken for one. A range that is not a
19
+ * semantic version range is left in the text, where a later splitter either
20
+ * accepts it as part of a path or turns it down.
21
+ */
22
+ export class RangeSplitter extends BaseRefSplitter {
23
+ readonly order = 200;
24
+
25
+ protected read(state: PartialRef): PartialRef[] {
26
+ const text = state.rest;
27
+ const at = text.lastIndexOf("@");
28
+ if (at === -1 || at < text.lastIndexOf("/")) return [state];
29
+
30
+ const before = text.slice(0, at).trim();
31
+ const range = text.slice(at + 1).trim();
32
+
33
+ if (range.length === 0) {
34
+ state.problems.push("the '@' is not followed by a version range.");
35
+ return [];
36
+ }
37
+ if (before.includes("@") && !SCHEME.test(before) && !SCP.test(before)) {
38
+ state.problems.push("a ref may carry at most one '@' version range.");
39
+ return [];
40
+ }
41
+ if (semver.validRange(range) === null) {
42
+ state.problems.push(
43
+ `'${range}' is not a version range. Ranges follow npm's rules, such as '^1.2.0', ` +
44
+ "'~2.1', '>=1.0.0 <2.0.0' or '*'."
45
+ );
46
+ return [state];
47
+ }
48
+ return [{ ...state, rest: before, range }];
49
+ }
50
+ }
51
+
52
+ makeInjectable(RangeSplitter);
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The ref parser: the one place a written ref becomes the things it could mean.
3
+ *
4
+ * It is context free. It never asks where the ref was written (that is for a
5
+ * pruner) and never asks what exists (that is for a lookup); it returns EVERY
6
+ * way the text can be read, because some forms read more than one way: a bare
7
+ * `workflow` is a namespace, a recipe, a repository, a variable and an
8
+ * environment variable name, and a GitLab URL with nested groups does not say
9
+ * where the project path ends.
10
+ *
11
+ * The parser is a list of splitters, ordered by their `order`. Each reads
12
+ * whatever part of the remaining text it recognizes and returns every way to
13
+ * read it; the parser feeds every reading through every splitter with
14
+ * `flatMap`, and keeps the readings that ended with a finished ref and no text
15
+ * left over. A plugin adds a form by adding a splitter.
16
+ */
17
+
18
+ import { makeInjectable } from "../injectable.js";
19
+ import { REF_TOKENS } from "../tokens.js";
20
+ import { ConfigError } from "../../../lib/errors.js";
21
+ import type { RefSplitter, PartialRef } from "./partial-ref.js";
22
+ import { REF_KINDS, type SousRef } from "../types.js";
23
+
24
+ /** The forms a ref may take, for the reminder under a ref that does not parse. */
25
+ const SYNTAX_HELP =
26
+ "A ref is written as 'namespace', 'namespace/recipe', 'repo:namespace/recipe', " +
27
+ "'namespace/recipe/path/to/file.md', 'namespace/recipe.variable', an environment variable " +
28
+ "name, any of those with '@<range>' or '?name=value', or a location such as " +
29
+ "'github://owner/repository/namespace/recipe' or a URL copied from the browser.";
30
+
31
+ /** Parses written refs into every reading they have. */
32
+ export class RefParser {
33
+ private readonly splitters: RefSplitter[];
34
+
35
+ /**
36
+ * @param splitters - The splitters, in any order; they run by their `order`.
37
+ */
38
+ constructor(splitters: RefSplitter[]) {
39
+ this.splitters = [...splitters].sort((left, right) => left.order - right.order);
40
+ }
41
+
42
+ /**
43
+ * Every reading of a written ref.
44
+ *
45
+ * parser.parse("workflow/alpha@^1.2");
46
+ * // -> [{ kind: "recipe", name: "alpha", namespace: { kind: "namespace", name: "workflow" },
47
+ * // range: "^1.2" }]
48
+ *
49
+ * @param input - The ref exactly as it was written.
50
+ * @returns Every reading, never empty. Text that fits no form raises a ConfigError.
51
+ */
52
+ parse(input: string): SousRef[] {
53
+ if (typeof input !== "string") throw this.refError(String(input), "a ref must be a string.");
54
+
55
+ const trimmed = input.trim();
56
+ if (trimmed.length === 0) throw this.refError(input, "a ref must not be empty.");
57
+ if (trimmed.startsWith("@")) {
58
+ throw this.refError(
59
+ input,
60
+ "refs take no '@' prefix. The '@' character introduces a version range only, " +
61
+ "as in 'workflow/task-files@^1.2.0'."
62
+ );
63
+ }
64
+ if (trimmed.startsWith("~")) {
65
+ throw this.refError(
66
+ input,
67
+ "refs take no '~' prefix. The '~' sigil belongs to template include lines " +
68
+ "('@~workflow/file.md'); a ref itself is written without it."
69
+ );
70
+ }
71
+
72
+ const problems: string[] = [];
73
+ let states: PartialRef[] = [{ input: trimmed, rest: trimmed, problems }];
74
+ for (const splitter of this.splitters) {
75
+ states = states.flatMap((state) => splitter.split(state));
76
+ if (states.length === 0) break;
77
+ }
78
+
79
+ const seen = new Set<string>();
80
+ const refs: SousRef[] = [];
81
+ for (const state of states) {
82
+ if (state.ref === undefined || state.rest !== "") continue;
83
+ const key = JSON.stringify(state.ref);
84
+ if (seen.has(key)) continue;
85
+ seen.add(key);
86
+ refs.push(state.ref);
87
+ }
88
+ // Kinds in their documented order, whatever order the splitters made them in.
89
+ if (refs.length > 0) {
90
+ return refs
91
+ .map((ref, position) => ({ ref, position }))
92
+ .sort(
93
+ (left, right) =>
94
+ REF_KINDS.indexOf(left.ref.kind) - REF_KINDS.indexOf(right.ref.kind) ||
95
+ left.position - right.position
96
+ )
97
+ .map((entry) => entry.ref);
98
+ }
99
+
100
+ const reasons = [...new Set(problems)];
101
+ if (reasons.length === 0) throw this.refError(input, "it does not fit any form of a ref.");
102
+ if (reasons.length === 1) throw this.refError(input, reasons[0]!);
103
+ throw this.refError(
104
+ input,
105
+ `it fits no form of a ref:\n${reasons.map((reason) => ` ${reason}`).join("\n")}`
106
+ );
107
+ }
108
+
109
+ /** A ConfigError for text that fits no form, quoting it and showing the forms. */
110
+ private refError(input: string, problem: string): ConfigError {
111
+ return new ConfigError(`Invalid ref '${input}': ${problem}\n ${SYNTAX_HELP}`);
112
+ }
113
+ }
114
+
115
+ makeInjectable(RefParser, [{ multi: REF_TOKENS.Splitter }]);
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Reads the `repo:` qualifier at the front of a ref.
3
+ */
4
+
5
+ import { makeInjectable } from "../injectable.js";
6
+ import { isGlobName, NAME_ANY_CASE } from "../glob.js";
7
+ import { BaseRefSplitter, type PartialRef } from "./partial-ref.js";
8
+
9
+ /** A URL that names its scheme, such as `https://` or `github://`. */
10
+ const SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
11
+
12
+ /** The `scp`-style SSH form, as in `git@github.com:owner/name.git`. */
13
+ const SCP = /^[^@\s/:]+@([^@\s/:]+):(.+)$/s;
14
+
15
+ /**
16
+ * Takes `sous-recipes:` off the front of the text and records the repository
17
+ * short name it names. The name may be a glob. Text that is a location, or
18
+ * whose first `:` comes after a `/`, has no qualifier.
19
+ */
20
+ export class RepoQualifierSplitter extends BaseRefSplitter {
21
+ readonly order = 400;
22
+
23
+ protected read(state: PartialRef): PartialRef[] {
24
+ const text = state.rest;
25
+ if (state.repo !== undefined || SCHEME.test(text) || SCP.test(text)) return [state];
26
+
27
+ const match = /^([^:/\s]*):(.*)$/s.exec(text);
28
+ if (match === null) return [state];
29
+ const name = match[1]!;
30
+ const rest = match[2]!;
31
+
32
+ if (name.length === 0) {
33
+ state.problems.push("the repo qualifier before ':' is empty.");
34
+ return [];
35
+ }
36
+ if (rest.includes(":")) {
37
+ state.problems.push("a ref may carry at most one 'repo:' qualifier.");
38
+ return [];
39
+ }
40
+ if (!NAME_ANY_CASE.test(name) && !isGlobName(name)) {
41
+ state.problems.push(
42
+ `the repo qualifier '${name}' must be kebab-case: a letter, then letters, digits ` +
43
+ "or hyphens."
44
+ );
45
+ return [];
46
+ }
47
+ return [{ ...state, rest, repo: { kind: "repo", name } }];
48
+ }
49
+ }
50
+
51
+ makeInjectable(RepoQualifierSplitter);
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Reading the parents out of a nested ref, wherever a kind keeps them.
3
+ */
4
+
5
+ import type {
6
+ NamespaceRef,
7
+ RecipeRef,
8
+ RefLocation,
9
+ RepoRef,
10
+ SousRef,
11
+ } from "./types.js";
12
+
13
+ /**
14
+ * The repository qualifier a ref was written with, wherever it sits in the
15
+ * nesting. A repository ref is its own qualifier.
16
+ *
17
+ * repoOf(parse("sous-recipes:workflow/alpha")); // -> { kind: "repo", name: "sous-recipes" }
18
+ *
19
+ * @param ref - Any ref.
20
+ */
21
+ export function repoOf(ref: SousRef): RepoRef | undefined {
22
+ switch (ref.kind) {
23
+ case "repo":
24
+ return ref;
25
+ case "namespace":
26
+ return ref.repo;
27
+ case "recipe":
28
+ return ref.namespace?.repo ?? ref.repo;
29
+ case "recipeFile":
30
+ return repoOf(ref.recipe);
31
+ case "variable":
32
+ return ref.recipe === undefined ? ref.repo : (repoOf(ref.recipe) ?? ref.repo);
33
+ case "envVar":
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Where the ref says its repository lives, when it says.
40
+ *
41
+ * @param ref - Any ref.
42
+ */
43
+ export function locationOf(ref: SousRef): RefLocation | undefined {
44
+ return repoOf(ref)?.location;
45
+ }
46
+
47
+ /**
48
+ * The namespace a ref names or sits in.
49
+ *
50
+ * @param ref - Any ref.
51
+ */
52
+ export function namespaceOf(ref: SousRef): NamespaceRef | undefined {
53
+ switch (ref.kind) {
54
+ case "namespace":
55
+ return ref;
56
+ case "recipe":
57
+ return ref.namespace;
58
+ case "recipeFile":
59
+ return ref.recipe.namespace;
60
+ case "variable":
61
+ return ref.recipe?.namespace;
62
+ default:
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The recipe a ref names or sits in.
69
+ *
70
+ * @param ref - Any ref.
71
+ */
72
+ export function recipeOf(ref: SousRef): RecipeRef | undefined {
73
+ switch (ref.kind) {
74
+ case "recipe":
75
+ return ref;
76
+ case "recipeFile":
77
+ return ref.recipe;
78
+ case "variable":
79
+ return ref.recipe;
80
+ default:
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The version range a ref carries, when it carries one.
87
+ *
88
+ * @param ref - Any ref.
89
+ */
90
+ export function rangeOf(ref: SousRef): string | undefined {
91
+ return ref.kind === "recipe" || ref.kind === "recipeFile" || ref.kind === "repo"
92
+ ? ref.range
93
+ : undefined;
94
+ }
95
+
96
+ /**
97
+ * Every name and path part of a ref and its parents, outermost parent first.
98
+ *
99
+ * namesOf(parse("sous-recipes:workflow/alpha")); // -> ["sous-recipes", "workflow", "alpha"]
100
+ *
101
+ * @param ref - Any ref.
102
+ */
103
+ export function namesOf(ref: SousRef): string[] {
104
+ const names: string[] = [];
105
+ const repo = repoOf(ref);
106
+ if (repo?.name !== undefined) names.push(repo.name);
107
+ if (ref.kind === "repo") return names;
108
+ const namespace = namespaceOf(ref);
109
+ if (namespace !== undefined) names.push(namespace.name);
110
+ const recipe = recipeOf(ref);
111
+ if (recipe !== undefined) names.push(recipe.name);
112
+ if (ref.kind === "recipeFile") names.push(ref.path);
113
+ if (ref.kind === "variable" || ref.kind === "envVar") names.push(ref.name);
114
+ return names;
115
+ }