@sweetener/cli 0.1.0-alpha.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/bin/sweetener.mjs +29 -0
  4. package/dist/src/bin.d.ts +3 -0
  5. package/dist/src/bin.d.ts.map +1 -0
  6. package/dist/src/bin.js +45 -0
  7. package/dist/src/bin.js.map +1 -0
  8. package/dist/src/command-line.d.ts +48 -0
  9. package/dist/src/command-line.d.ts.map +1 -0
  10. package/dist/src/command-line.js +449 -0
  11. package/dist/src/command-line.js.map +1 -0
  12. package/dist/src/configuration.d.ts +2 -0
  13. package/dist/src/configuration.d.ts.map +1 -0
  14. package/dist/src/configuration.js +2 -0
  15. package/dist/src/configuration.js.map +1 -0
  16. package/dist/src/default-expansion-provider.d.ts +2 -0
  17. package/dist/src/default-expansion-provider.d.ts.map +1 -0
  18. package/dist/src/default-expansion-provider.js +2 -0
  19. package/dist/src/default-expansion-provider.js.map +1 -0
  20. package/dist/src/expansion-tools.d.ts +2 -0
  21. package/dist/src/expansion-tools.d.ts.map +1 -0
  22. package/dist/src/expansion-tools.js +2 -0
  23. package/dist/src/expansion-tools.js.map +1 -0
  24. package/dist/src/incremental-equivalence.d.ts +26 -0
  25. package/dist/src/incremental-equivalence.d.ts.map +1 -0
  26. package/dist/src/incremental-equivalence.js +115 -0
  27. package/dist/src/incremental-equivalence.js.map +1 -0
  28. package/dist/src/index.d.ts +12 -0
  29. package/dist/src/index.d.ts.map +1 -0
  30. package/dist/src/index.js +12 -0
  31. package/dist/src/index.js.map +1 -0
  32. package/dist/src/project-command.d.ts +2 -0
  33. package/dist/src/project-command.d.ts.map +1 -0
  34. package/dist/src/project-command.js +2 -0
  35. package/dist/src/project-command.js.map +1 -0
  36. package/dist/src/project-runner.d.ts +39 -0
  37. package/dist/src/project-runner.d.ts.map +1 -0
  38. package/dist/src/project-runner.js +150 -0
  39. package/dist/src/project-runner.js.map +1 -0
  40. package/dist/src/scaffold.d.ts +56 -0
  41. package/dist/src/scaffold.d.ts.map +1 -0
  42. package/dist/src/scaffold.js +410 -0
  43. package/dist/src/scaffold.js.map +1 -0
  44. package/dist/src/source-kind.d.ts +2 -0
  45. package/dist/src/source-kind.d.ts.map +1 -0
  46. package/dist/src/source-kind.js +2 -0
  47. package/dist/src/source-kind.js.map +1 -0
  48. package/dist/src/standalone-emit.d.ts +26 -0
  49. package/dist/src/standalone-emit.d.ts.map +1 -0
  50. package/dist/src/standalone-emit.js +56 -0
  51. package/dist/src/standalone-emit.js.map +1 -0
  52. package/package.json +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jimmy Miller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @sweetener/cli
2
+
3
+ Check, build, and watch a Sweetener project, and scaffold one into an existing repository.
4
+
5
+ Part of [Sweetener](https://github.com/sweetener-ts/sweetener), hygienic
6
+ declarative macros for TypeScript. Alpha: the language version is 1 and the
7
+ package interfaces may still change.
8
+
9
+ ## Usage
10
+
11
+ ```sh
12
+ npx sweetener init # scaffold, after showing what it would write
13
+ npx sweetener check # type-check through the official compiler
14
+ npx sweetener build # expand and emit
15
+ npx sweetener watch
16
+ ```
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // The `sweetener` command, kept in the repository rather than generated.
3
+ //
4
+ // A package manager creates the link for a command while it installs, and in
5
+ // this workspace nothing is built at that point, so a link pointing straight
6
+ // into `dist/` could not be created and every example that runs `sweetener`
7
+ // was left with no such command. This file always exists, so the link always
8
+ // can be, and running it before a build says so plainly.
9
+ import { existsSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+
13
+ const entry = join(
14
+ dirname(fileURLToPath(import.meta.url)),
15
+ "..",
16
+ "dist",
17
+ "src",
18
+ "bin.js",
19
+ );
20
+
21
+ if (!existsSync(entry)) {
22
+ process.stderr.write(
23
+ `@sweetener/cli has not been built: ${entry} does not exist.\n` +
24
+ `Run \`pnpm build\` in the repository root, then try again.\n`,
25
+ );
26
+ process.exit(1);
27
+ }
28
+
29
+ await import(pathToFileURL(entry).href);
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=bin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../../src/bin.ts"],"names":[],"mappings":""}
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import { readSync } from "node:fs";
3
+ import { runCli } from "./command-line.js";
4
+ /**
5
+ * Reads one line of an answer from the terminal.
6
+ *
7
+ * Synchronous because the commands are, and reading a byte at a time because
8
+ * anything buffered would swallow input the shell still needs afterwards.
9
+ */
10
+ function askOnTerminal(question) {
11
+ if (!process.stdin.isTTY)
12
+ return false;
13
+ process.stdout.write(question);
14
+ const byte = Buffer.alloc(1);
15
+ let answer = "";
16
+ for (;;) {
17
+ let read;
18
+ try {
19
+ read = readSync(0, byte, 0, 1, null);
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ if (read === 0)
25
+ break;
26
+ const character = byte.toString("utf8");
27
+ if (character === "\n" || character === "\r")
28
+ break;
29
+ answer += character;
30
+ }
31
+ process.stdout.write("\n");
32
+ return /^y(?:es)?$/iu.test(answer.trim());
33
+ }
34
+ const result = runCli({
35
+ argv: process.argv.slice(2),
36
+ io: {
37
+ stdout: (text) => process.stdout.write(text),
38
+ stderr: (text) => process.stderr.write(text),
39
+ // Only where there is a terminal to answer. Everywhere else a command that
40
+ // would write says so and stops, rather than taking silence for consent.
41
+ ...(process.stdin.isTTY ? { confirm: askOnTerminal } : {}),
42
+ },
43
+ });
44
+ process.exitCode = result.exitCode;
45
+ //# sourceMappingURL=bin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.js","sourceRoot":"","sources":["../../src/bin.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAE3C;;;;;GAKG;AACH,SAAS,aAAa,CAAC,QAAgB;IACrC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK;QAAE,OAAO,KAAK,CAAC;IACvC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAC/B,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC7B,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,SAAS,CAAC;QACR,IAAI,IAAY,CAAC;QACjB,IAAI,CAAC;YACH,IAAI,GAAG,QAAQ,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC;QACvC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAC;QACf,CAAC;QACD,IAAI,IAAI,KAAK,CAAC;YAAE,MAAM;QACtB,MAAM,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QACxC,IAAI,SAAS,KAAK,IAAI,IAAI,SAAS,KAAK,IAAI;YAAE,MAAM;QACpD,MAAM,IAAI,SAAS,CAAC;IACtB,CAAC;IACD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC3B,OAAO,cAAc,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;AAC5C,CAAC;AAED,MAAM,MAAM,GAAG,MAAM,CAAC;IACpB,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;IAC3B,EAAE,EAAE;QACF,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC;QAC5C,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC;QAC5C,2EAA2E;QAC3E,yEAAyE;QACzE,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC3D;CACF,CAAC,CAAC;AAEH,OAAO,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC"}
@@ -0,0 +1,48 @@
1
+ import type { System } from "typescript";
2
+ import { type ProjectExpansionProvider, type WatchProject } from "./project-command.js";
3
+ import { type ExpansionInspectionProvider } from "./expansion-tools.js";
4
+ export interface CliIo {
5
+ readonly stdout: (text: string) => void;
6
+ readonly stderr: (text: string) => void;
7
+ /**
8
+ * Asks before anything is written. Absent when there is nobody to ask, in
9
+ * which case a command that would write refuses instead of assuming.
10
+ */
11
+ readonly confirm?: ((question: string) => boolean) | undefined;
12
+ }
13
+ export type CliInvocation = {
14
+ readonly command: "check" | "build" | "watch";
15
+ readonly configPath: string;
16
+ readonly debug: boolean;
17
+ } | {
18
+ readonly command: "init";
19
+ readonly directory: string;
20
+ readonly assumeYes: boolean;
21
+ } | {
22
+ readonly command: "expand";
23
+ readonly fileName: string;
24
+ readonly configPath?: string | undefined;
25
+ } | {
26
+ readonly command: "explain";
27
+ readonly position: string;
28
+ readonly configPath?: string | undefined;
29
+ readonly json?: boolean | undefined;
30
+ } | {
31
+ readonly command: "help";
32
+ } | {
33
+ readonly command: "emit";
34
+ readonly fileNames: readonly string[];
35
+ readonly outDir: string;
36
+ };
37
+ export declare function parseCliInvocation(argv: readonly string[]): CliInvocation;
38
+ export declare function runCli(options: {
39
+ readonly argv: readonly string[];
40
+ readonly expansionProvider?: ProjectExpansionProvider | undefined;
41
+ readonly inspectionProvider?: ExpansionInspectionProvider | undefined;
42
+ readonly io: CliIo;
43
+ readonly system?: System;
44
+ }): {
45
+ readonly exitCode: 0 | 1;
46
+ readonly watch?: WatchProject;
47
+ };
48
+ //# sourceMappingURL=command-line.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command-line.d.ts","sourceRoot":"","sources":["../../src/command-line.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEzC,OAAO,EAGL,KAAK,wBAAwB,EAC7B,KAAK,YAAY,EAClB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAKL,KAAK,2BAA2B,EACjC,MAAM,sBAAsB,CAAC;AAK9B,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,SAAS,CAAC;CAChE;AAED,MAAM,MAAM,aAAa,GACrB;IACE,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC;IAC9C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB,GACD;IACE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;CAC7B,GACD;IACE,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC1C,GACD;IACE,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACrC,GACD;IAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC5B;IACE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB,CAAC;AA6BN,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,aAAa,CA6FzE;AAoJD,wBAAgB,MAAM,CAAC,OAAO,EAAE;IAC9B,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,wBAAwB,GAAG,SAAS,CAAC;IAClE,QAAQ,CAAC,kBAAkB,CAAC,EAAE,2BAA2B,GAAG,SAAS,CAAC;IACtE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B,GAAG;IAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC,GAAG,CAAC,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAA;CAAE,CA0P9D"}
@@ -0,0 +1,449 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { scaffoldIntoProject, scaffoldProject, writeScaffold, } from "./scaffold.js";
4
+ import * as ts from "typescript";
5
+ import { runConfiguredProjectCommand, watchConfiguredProject, } from "./project-command.js";
6
+ import { expansionView, explainOriginalPosition, parseSourcePosition, sourceOffset, } from "./expansion-tools.js";
7
+ import { createDefaultProjectExpansionProvider } from "./default-expansion-provider.js";
8
+ import { loadSweetProject } from "./configuration.js";
9
+ import { emitStandalone } from "./standalone-emit.js";
10
+ /**
11
+ * Pull `-p`/`--project` out of an argument list.
12
+ *
13
+ * `expand` and `explain` used to reject it, and only ever discovered a
14
+ * `tsconfig.json`. `init` writes `sweetener.json`, so in a scaffolded project
15
+ * two of the commands could not read the config the other four were using.
16
+ */
17
+ function splitProjectOption(argv) {
18
+ const positional = [];
19
+ let configPath;
20
+ for (let index = 0; index < argv.length; index += 1) {
21
+ const argument = argv[index];
22
+ if (argument === "-p" || argument === "--project") {
23
+ const value = argv[++index];
24
+ if (value === undefined)
25
+ throw new TypeError(`${argument} requires a path`);
26
+ configPath = value;
27
+ }
28
+ else if (argument.startsWith("-"))
29
+ throw new TypeError(`Unknown argument ${argument}`);
30
+ else
31
+ positional.push(argument);
32
+ }
33
+ return { positional: Object.freeze(positional), configPath };
34
+ }
35
+ export function parseCliInvocation(argv) {
36
+ const command = argv[0];
37
+ if (command === "init") {
38
+ const rest = argv.slice(1);
39
+ const assumeYes = rest.some((argument) => argument === "--yes" || argument === "-y");
40
+ const directories = rest.filter((argument) => argument !== "--yes" && argument !== "-y");
41
+ if (directories.length > 1)
42
+ throw new TypeError("init takes at most one directory");
43
+ return Object.freeze({
44
+ command,
45
+ directory: directories[0] ?? ".",
46
+ assumeYes,
47
+ });
48
+ }
49
+ if (command === undefined ||
50
+ command === "--help" ||
51
+ command === "-h" ||
52
+ command === "help")
53
+ return Object.freeze({ command: "help" });
54
+ if (command === "expand") {
55
+ const { positional, configPath } = splitProjectOption(argv.slice(1));
56
+ if (positional.length !== 1)
57
+ throw new TypeError("expand requires one source file");
58
+ return Object.freeze({
59
+ command,
60
+ fileName: positional[0],
61
+ ...(configPath === undefined ? {} : { configPath }),
62
+ });
63
+ }
64
+ if (command === "emit") {
65
+ const fileNames = [];
66
+ let outDir;
67
+ for (let index = 1; index < argv.length; index += 1) {
68
+ const argument = argv[index];
69
+ if (argument === "--out-dir") {
70
+ const value = argv[++index];
71
+ if (value === undefined)
72
+ throw new TypeError("--out-dir requires a directory");
73
+ outDir = value;
74
+ }
75
+ else if (argument.startsWith("-"))
76
+ throw new TypeError(`Unknown argument ${argument}`);
77
+ else
78
+ fileNames.push(argument);
79
+ }
80
+ if (fileNames.length === 0)
81
+ throw new TypeError("emit requires at least one source file");
82
+ // Required rather than defaulted to the source directory: a file that
83
+ // opted in with a directive keeps its own name, so emitting alongside it
84
+ // would overwrite the input.
85
+ if (outDir === undefined)
86
+ throw new TypeError("emit requires --out-dir");
87
+ return Object.freeze({
88
+ command,
89
+ fileNames: Object.freeze(fileNames),
90
+ outDir,
91
+ });
92
+ }
93
+ if (command === "explain") {
94
+ const json = argv.includes("--json");
95
+ const { positional, configPath } = splitProjectOption(argv.slice(1).filter((argument) => argument !== "--json"));
96
+ if (positional.length !== 1)
97
+ throw new TypeError("explain requires one file:line:column position");
98
+ parseSourcePosition(positional[0]);
99
+ return Object.freeze({
100
+ command,
101
+ position: positional[0],
102
+ ...(configPath === undefined ? {} : { configPath }),
103
+ ...(json ? { json } : {}),
104
+ });
105
+ }
106
+ if (command !== "check" && command !== "build" && command !== "watch")
107
+ throw new TypeError("Expected init, check, build, watch, expand, explain, or emit command");
108
+ let configPath = "tsconfig.json";
109
+ let debug = false;
110
+ for (let index = 1; index < argv.length; index++) {
111
+ const argument = argv[index];
112
+ if (argument === "--debug")
113
+ debug = true;
114
+ else if (argument === "-p" || argument === "--project") {
115
+ const value = argv[++index];
116
+ if (value === undefined)
117
+ throw new TypeError(`${argument} requires a path`);
118
+ configPath = value;
119
+ }
120
+ else
121
+ throw new TypeError(`Unknown argument ${argument}`);
122
+ }
123
+ return Object.freeze({ command, configPath, debug });
124
+ }
125
+ function at(file, start) {
126
+ const position = file.getLineAndCharacterOfPosition(start);
127
+ return `${file.fileName}:${String(position.line + 1)}:${String(position.character + 1)}`;
128
+ }
129
+ function renderDiagnostic(diagnostic) {
130
+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
131
+ const head = diagnostic.file === undefined || diagnostic.start === undefined
132
+ ? `TS${String(diagnostic.code)}: ${message}`
133
+ : `${at(diagnostic.file, diagnostic.start)} TS${String(diagnostic.code)}: ${message}`;
134
+ // A diagnostic that points somewhere else as well — the rule that wanted
135
+ // different syntax, the binding already holding a name — is most of the
136
+ // answer, and printing only the first line threw that away.
137
+ const related = (diagnostic.relatedInformation ?? []).map((entry) => {
138
+ const text = ts.flattenDiagnosticMessageText(entry.messageText, "\n");
139
+ return entry.file === undefined || entry.start === undefined
140
+ ? ` ${text}`
141
+ : ` ${at(entry.file, entry.start)} ${text}`;
142
+ });
143
+ return [head, ...related].join("\n");
144
+ }
145
+ const usage = `sweetener — hygienic declarative macros for TypeScript
146
+
147
+ Usage: sweetener <command> [options]
148
+
149
+ Commands:
150
+ init [directory] Scaffold a project. Shows what it would write; pass
151
+ --yes to write it.
152
+ check Type-check the project through the official compiler.
153
+ build Check, then expand and emit.
154
+ watch Rebuild as sources and macros change.
155
+ expand <file> Print the expanded TypeScript for one source.
156
+ explain <file:line:col> Report where a position came from, and through which
157
+ macros.
158
+ emit <files...> Expand named files into a directory, without checking.
159
+
160
+ Options:
161
+ -p, --project <path> Project config to use. Defaults to the nearest
162
+ tsconfig.json; \`init\` writes sweetener.json, so pass
163
+ it here.
164
+ --yes, -y For init: write the files rather than listing them.
165
+ --out-dir <dir> For emit: where to write. Required.
166
+ --json For explain: print the raw origin records instead of
167
+ a description.
168
+ --debug Print the expansion's internal state after the run.
169
+ -h, --help Show this.
170
+ `;
171
+ /** One-based line and column for a byte offset, the way an editor counts. */
172
+ function lineColumn(text, offset) {
173
+ const before = text.slice(0, Math.max(0, offset));
174
+ const line = before.split("\n").length;
175
+ const column = offset - (before.lastIndexOf("\n") + 1) + 1;
176
+ return `${String(line)}:${String(column)}`;
177
+ }
178
+ /** The source line with a caret under the span, the way a compiler shows one. */
179
+ function excerpt(text, start, end) {
180
+ const lineStart = text.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
181
+ const lineEnd = text.indexOf("\n", start);
182
+ const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd);
183
+ const width = Math.max(1, Math.min(end, lineEnd < 0 ? text.length : lineEnd) - start);
184
+ return [
185
+ ` ${line}`,
186
+ ` ${" ".repeat(Math.max(0, start - lineStart))}${"^".repeat(width)}`,
187
+ ];
188
+ }
189
+ /**
190
+ * What `explain` says to a person.
191
+ *
192
+ * It used to print the raw origin records: interned numeric ids, byte offsets,
193
+ * and a `sourceId` in place of a file name. That is the shape a tool wants,
194
+ * and it is still available behind `--json`, but a command called `explain`
195
+ * should answer in the terms the question was asked in.
196
+ */
197
+ function describeExplanation(options) {
198
+ const { explanation, fileName, sourceText, generatedText } = options;
199
+ const lines = [
200
+ `${fileName}:${lineColumn(sourceText, explanation.offset)}`,
201
+ ...excerpt(sourceText, explanation.offset, explanation.offset + 1),
202
+ ];
203
+ if (explanation.regions.length === 0) {
204
+ lines.push("", "This position produced no generated TypeScript.");
205
+ return `${lines.join("\n")}\n`;
206
+ }
207
+ const kinds = new Set(explanation.regions.map(({ kind }) => kind));
208
+ const stack = explanation.regions.flatMap(({ expansionStack }) => expansionStack);
209
+ lines.push("");
210
+ if (stack.length === 0) {
211
+ lines.push("Copied through expansion untouched.");
212
+ }
213
+ else {
214
+ const seen = new Set();
215
+ lines.push("Expanded by:");
216
+ for (const entry of stack) {
217
+ if (seen.has(entry.invocationId))
218
+ continue;
219
+ seen.add(entry.invocationId);
220
+ lines.push(` ${entry.macroName}, invoked at ${fileName}:${lineColumn(sourceText, entry.origin.start)}`);
221
+ }
222
+ }
223
+ lines.push("", "Generated at:");
224
+ // Layout regions are where the whitespace around a token went. They answer a
225
+ // question nobody asked here, and there are more of them than of anything
226
+ // else, so the interesting regions do not get lost among them.
227
+ const regions = [...explanation.regions]
228
+ .filter(({ generatedStart, generatedEnd }) => generatedText.slice(generatedStart, generatedEnd).trim().length > 0)
229
+ .sort((left, right) => left.generatedStart - right.generatedStart);
230
+ for (const region of regions) {
231
+ const at = lineColumn(generatedText, region.generatedStart);
232
+ const text = generatedText
233
+ .slice(region.generatedStart, region.generatedEnd)
234
+ .replaceAll("\n", "\\n");
235
+ lines.push(` ${at} ${JSON.stringify(text)}${region.kind === "source" ? "" : ` (${region.kind})`}`);
236
+ }
237
+ if (kinds.has("introduced"))
238
+ lines.push("", "`introduced` marks syntax the macro wrote rather than syntax it was given.");
239
+ return `${lines.join("\n")}\n`;
240
+ }
241
+ export function runCli(options) {
242
+ const expansionProvider = options.expansionProvider ?? createDefaultProjectExpansionProvider();
243
+ const inspectionProvider = options.inspectionProvider ??
244
+ ("inspectSource" in expansionProvider
245
+ ? expansionProvider
246
+ : undefined);
247
+ let invocation;
248
+ try {
249
+ invocation = parseCliInvocation(options.argv);
250
+ }
251
+ catch (error) {
252
+ options.io.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
253
+ return Object.freeze({ exitCode: 1 });
254
+ }
255
+ if (invocation.command === "help") {
256
+ options.io.stdout(usage);
257
+ return Object.freeze({ exitCode: 0 });
258
+ }
259
+ const report = (result) => {
260
+ for (const diagnostic of result.diagnostics)
261
+ options.io.stderr(`${renderDiagnostic(diagnostic)}\n`);
262
+ if ("debug" in invocation && invocation.debug)
263
+ options.io.stdout(`${JSON.stringify(result.debugState, null, 2)}\n`);
264
+ options.io.stdout(`${result.command}: ${result.exitCode === 0 ? "success" : "failed"}\n`);
265
+ };
266
+ if (invocation.command === "emit") {
267
+ const result = emitStandalone({
268
+ fileNames: invocation.fileNames,
269
+ outDir: invocation.outDir,
270
+ expansionProvider,
271
+ });
272
+ for (const diagnostic of result.diagnostics)
273
+ options.io.stderr(`${renderDiagnostic(diagnostic)}\n`);
274
+ if (result.diagnostics.length > 0) {
275
+ options.io.stdout("emit: failed\n");
276
+ return Object.freeze({ exitCode: 1 });
277
+ }
278
+ for (const fileName of result.outputs.keys())
279
+ options.io.stdout(`${fileName}\n`);
280
+ options.io.stdout("emit: success\n");
281
+ return Object.freeze({ exitCode: 0 });
282
+ }
283
+ if (invocation.command === "init") {
284
+ try {
285
+ // A project that already builds itself gets what it is missing, not a
286
+ // refusal: adding macros to something that exists is the ordinary case,
287
+ // and starting from nothing is the rare one.
288
+ const manifestPath = resolve(invocation.directory, "package.json");
289
+ const existing = existsSync(manifestPath)
290
+ ? JSON.parse(readFileSync(manifestPath, "utf8"))
291
+ : undefined;
292
+ // A project built for a runtime that needs no package.json is still a
293
+ // project, and writing one into it would be the wrong thing entirely.
294
+ const settled = existing !== undefined ||
295
+ ["deno.json", "deno.jsonc", "bunfig.toml", "tsconfig.json"].some((name) => existsSync(resolve(invocation.directory, name)));
296
+ const project = settled
297
+ ? scaffoldIntoProject({
298
+ directory: invocation.directory,
299
+ manifest: existing,
300
+ })
301
+ : scaffoldProject({ directory: invocation.directory });
302
+ // Said in full before anything happens, because this writes into a
303
+ // directory someone else owns.
304
+ options.io.stdout(`${[
305
+ `In ${resolve(invocation.directory)} this will create:`,
306
+ ...project.files.map(({ path }) => ` ${path}`),
307
+ "",
308
+ "It will not modify or delete anything already there.",
309
+ "",
310
+ ].join("\n")}`);
311
+ if (!invocation.assumeYes) {
312
+ if (options.io.confirm === undefined) {
313
+ options.io.stderr("Nothing here can ask for confirmation. Re-run with --yes to write these files.\n");
314
+ return Object.freeze({ exitCode: 1 });
315
+ }
316
+ if (!options.io.confirm("Create them? [y/N] ")) {
317
+ options.io.stdout("Nothing was written.\n");
318
+ return Object.freeze({ exitCode: 0 });
319
+ }
320
+ }
321
+ const written = writeScaffold(project, invocation.directory);
322
+ options.io.stdout(`${[
323
+ "Created:",
324
+ ...written.map((path) => ` ${path}`),
325
+ "",
326
+ // A note may carry a block to paste into a config file. Only its
327
+ // first line is a bullet; the rest is printed as it should appear.
328
+ ...project.notes.map((note) => note
329
+ .split("\n")
330
+ .map((line, index) => (index === 0 ? `- ${line}` : ` ${line}`))
331
+ .join("\n")),
332
+ "",
333
+ ].join("\n")}`);
334
+ return Object.freeze({ exitCode: 0 });
335
+ }
336
+ catch (error) {
337
+ options.io.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
338
+ return Object.freeze({ exitCode: 1 });
339
+ }
340
+ }
341
+ if (invocation.command === "expand" || invocation.command === "explain") {
342
+ if (inspectionProvider === undefined) {
343
+ options.io.stderr("Expansion inspection is unavailable\n");
344
+ return Object.freeze({ exitCode: 1 });
345
+ }
346
+ // Expanding the named project first is what makes `-p` mean anything: the
347
+ // inspection provider answers about files it has already expanded, and on
348
+ // its own it only ever discovers a tsconfig.json.
349
+ if (invocation.configPath !== undefined) {
350
+ if (!("expandProject" in inspectionProvider)) {
351
+ options.io.stderr("This expansion provider cannot load a project\n");
352
+ return Object.freeze({ exitCode: 1 });
353
+ }
354
+ try {
355
+ inspectionProvider.expandProject(loadSweetProject(resolve(invocation.configPath)));
356
+ }
357
+ catch (error) {
358
+ options.io.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
359
+ return Object.freeze({ exitCode: 1 });
360
+ }
361
+ }
362
+ const position = invocation.command === "explain"
363
+ ? parseSourcePosition(invocation.position)
364
+ : undefined;
365
+ const fileName = invocation.command === "expand"
366
+ ? invocation.fileName
367
+ : position.fileName;
368
+ const inspected = inspectionProvider.inspectSource(fileName) ??
369
+ ("prepareSource" in inspectionProvider &&
370
+ typeof inspectionProvider.prepareSource === "function"
371
+ ? inspectionProvider.prepareSource(fileName)
372
+ : undefined);
373
+ if (inspected === undefined) {
374
+ options.io.stderr(`No expansion available for ${fileName}\n`);
375
+ return Object.freeze({ exitCode: 1 });
376
+ }
377
+ // Printing the source, or an account of where it came from, and reporting
378
+ // success would say the macros ran. `explain` used to report the origins of
379
+ // an expansion that never happened, which reads as an expansion in which
380
+ // every token came from the source -- exactly what an unexpanded file looks
381
+ // like.
382
+ if (inspected.diagnostics.length > 0) {
383
+ for (const diagnostic of inspected.diagnostics)
384
+ options.io.stderr(`${renderDiagnostic(diagnostic)}\n`);
385
+ return Object.freeze({ exitCode: 1 });
386
+ }
387
+ if (invocation.command === "expand") {
388
+ options.io.stdout(expansionView(inspected.generated));
389
+ }
390
+ else {
391
+ // A position past the end of the file is something a person types, not
392
+ // an internal fault: it used to escape as a raw stack trace naming dist
393
+ // paths.
394
+ let offset;
395
+ try {
396
+ offset = sourceOffset(inspected.sourceText, position.line, position.column);
397
+ }
398
+ catch {
399
+ const lines = inspected.sourceText.split("\n").length;
400
+ options.io.stderr(`${fileName} has ${String(lines)} line${lines === 1 ? "" : "s"}; ` +
401
+ `${String(position.line)}:${String(position.column)} is outside it\n`);
402
+ return Object.freeze({ exitCode: 1 });
403
+ }
404
+ const explanation = explainOriginalPosition({
405
+ sourceId: inspected.sourceId,
406
+ offset,
407
+ index: inspected.index,
408
+ trace: inspected.trace,
409
+ generatedNames: inspected.generatedNames,
410
+ });
411
+ options.io.stdout(invocation.json === true
412
+ ? `${JSON.stringify(explanation, null, 2)}\n`
413
+ : describeExplanation({
414
+ explanation,
415
+ fileName,
416
+ sourceText: inspected.sourceText,
417
+ generatedText: inspected.generated.text,
418
+ }));
419
+ }
420
+ return Object.freeze({ exitCode: 0 });
421
+ }
422
+ if (invocation.command === "watch") {
423
+ // `build: success` on its own said nothing about when it happened or
424
+ // whether the run was still watching, so a rebuild was indistinguishable
425
+ // from the first build scrolling past.
426
+ const time = () => new Date().toLocaleTimeString(undefined, { hour12: false });
427
+ let first = true;
428
+ const watch = watchConfiguredProject({
429
+ configPath: invocation.configPath,
430
+ expansionProvider,
431
+ onResult: (result) => {
432
+ options.io.stdout(`[${time()}] ${first ? "Building" : "Rebuilding"} ${invocation.configPath}\n`);
433
+ first = false;
434
+ report(result);
435
+ options.io.stdout(`[${time()}] Watching for changes. Press Ctrl+C to stop.\n`);
436
+ },
437
+ ...(options.system === undefined ? {} : { system: options.system }),
438
+ });
439
+ return Object.freeze({ exitCode: watch.result.exitCode, watch });
440
+ }
441
+ const result = runConfiguredProjectCommand({
442
+ command: invocation.command,
443
+ configPath: invocation.configPath,
444
+ expansionProvider,
445
+ });
446
+ report(result);
447
+ return Object.freeze({ exitCode: result.exitCode });
448
+ }
449
+ //# sourceMappingURL=command-line.js.map