char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somexperiment: automatic types for npm packages51266f9

main
16.9 KiB527 linesraw
1use std::path::{Path, PathBuf};
2
3use avril_runtime::caps::CapabilitiesError;
4
5/// `(syntax, help)`: the flag names are read off `syntax`.
6type Opt = (&'static str, &'static str);
7
8type Group = (&'static str, &'static [Opt]);
9
10/// The flag names an option's syntax spells, expanding `--allow-{a,b}=VALUE`.
11fn names(syntax: &str) -> impl Iterator<Item = String> + '_ {
12    syntax.split(", ").flat_map(|spelling| {
13        let flag = spelling.split([' ', '=']).next().unwrap();
14        match flag.split_once('{') {
15            Some((prefix, rest)) => rest
16                .trim_end_matches('}')
17                .split(',')
18                .map(|name| format!("{prefix}{name}"))
19                .collect(),
20            None => vec![flag.to_owned()],
21        }
22    })
23}
24
25const OFFLINE: Opt = ("--offline", "Never contact the registry");
26const NO_TYPES_PACKAGES: Opt = (
27    "--no-types-packages",
28    "Disable automatic DefinitelyTyped package lookup",
29);
30const JSR_REGISTRY: Opt = (
31    "--jsr-registry=URL",
32    "JSR registry base (default https://jsr.io/)",
33);
34const NPM_REGISTRY: Opt = (
35    "--npm-registry=URL",
36    "npm registry base (default https://registry.npmjs.org/)",
37);
38const CACHE_DIR: Opt = (
39    "--cache-dir=PATH",
40    "Package cache (default $AVRIL_CACHE_DIR or user cache)",
41);
42const LOCK: Opt = (
43    "--lock=PATH",
44    "Root graph lockfile (default package-root/avril.lock)",
45);
46const VENDOR: Opt = (
47    "--vendor=PATH",
48    "Read acquired artifacts from a vendor directory",
49);
50
51const ALLOW: Opt = (
52    "--allow-{net,read,write,js-exec,env,sys,run,ffi}=VALUE",
53    "Grant a capability",
54);
55const DENY: Opt = (
56    "--deny-{net,read,write,js-exec,env,sys,run,ffi}=VALUE",
57    "Deny a capability",
58);
59
60const PERMISSIONS: Group = (
61    "Permissions",
62    &[
63        (
64            "-0",
65            "Start with no capabilities except running the entry module",
66        ),
67        ALLOW,
68        DENY,
69    ],
70);
71const TEST_PERMISSIONS: Group = (
72    "Permissions",
73    &[
74        (
75            "-0",
76            "Start with no capabilities except running the test files",
77        ),
78        ALLOW,
79        DENY,
80    ],
81);
82const REPL_PERMISSIONS: Group = (
83    "Permissions",
84    &[("-0", "Start with no capabilities"), ALLOW, DENY],
85);
86const PACKAGES: Group = (
87    "Packages",
88    &[
89        OFFLINE,
90        NO_TYPES_PACKAGES,
91        ("--frozen", "Require existing lock selections and integrity"),
92        JSR_REGISTRY,
93        NPM_REGISTRY,
94        CACHE_DIR,
95        LOCK,
96        VENDOR,
97    ],
98);
99const INCLUDES: Group = (
100    "Includes",
101    &[
102        (
103            "--include=SPECIFIER",
104            "Also prepare a dynamic dependency; repeatable",
105        ),
106        (
107            "--include-from=REFERRER",
108            "Resolve following includes from this source ('.' resets)",
109        ),
110    ],
111);
112const ENVIRONMENT: Group = (
113    "Environment",
114    &[(
115        "--env-file=PATH",
116        "Load environment defaults from a file; repeatable",
117    )],
118);
119const TYPESCRIPT: Opt = (
120    "--typescript=PATH",
121    "Stock native TypeScript executable (pinned version required)",
122);
123const LISTENER: Group = (
124    "Listener",
125    &[
126        ("--hostname=HOST", "TCP bind hostname (default 127.0.0.1)"),
127        (
128            "--port=PORT",
129            "TCP bind port (default 8000; 0 selects an available port)",
130        ),
131        ("--unix=PATH", "Unix socket path, instead of TCP"),
132    ],
133);
134const TASK: Group = (
135    "Task",
136    &[(
137        "--watch",
138        "Rerun the task when its declared input files change",
139    )],
140);
141const TEST: Group = (
142    "Test",
143    &[
144        (
145            "--filter=PATTERN",
146            "Only run tests whose name contains PATTERN",
147        ),
148        (
149            "--no-run",
150            "Collect tests without running them (file top levels still evaluate)",
151        ),
152    ],
153);
154const ADD: Group = (
155    "Add",
156    &[
157        (
158            "--config=PATH",
159            "Edit this config instead of the nearest one",
160        ),
161        ("--pre", "Let an unversioned target select a prerelease"),
162    ],
163);
164const BUNDLE: Group = (
165    "Bundle",
166    &[
167        (
168            "-o, --out PATH",
169            "Entry output file; chunks and maps go beside it",
170        ),
171        (
172            "--outdir PATH",
173            "Output directory; entries use their basenames with .js extensions",
174        ),
175        ("--minify", "Minify the output"),
176        ("--sourcemap", "Write a source map beside each output file"),
177        (
178            "--no-splitting",
179            "Inline dynamic imports instead of emitting lazy chunks",
180        ),
181        (
182            "--target TARGET",
183            "Output syntax level, es2015 to es2025 or esnext (default)",
184        ),
185        (
186            "--define KEY=VALUE",
187            "Replace a global or dotted path with a JS expression; repeatable",
188        ),
189    ],
190);
191const LANGUAGE_SERVER: Group = (
192    "Language server",
193    &[
194        TYPESCRIPT,
195        (
196            "--project=DIR",
197            "Workspace directory (default current directory); open documents are graph roots",
198        ),
199    ],
200);
201
202pub struct Command {
203    name: &'static str,
204    summary: &'static str,
205    usage: &'static [&'static str],
206    groups: &'static [Group],
207}
208
209const COMMAND_GROUPS: &[(&str, &[Command])] = &[
210    (
211        "Main commands",
212        &[
213            Command {
214                name: "repl",
215                summary: "Start an interactive session (the default without a command)",
216                usage: &["repl [OPTIONS] [-- args...]", "[OPTIONS] [-- args...]"],
217                groups: &[REPL_PERMISSIONS, PACKAGES],
218            },
219            Command {
220                name: "run",
221                summary: "Run a module",
222                usage: &["run [OPTIONS] <module> [args...]"],
223                groups: &[PERMISSIONS, PACKAGES, INCLUDES, ENVIRONMENT],
224            },
225            Command {
226                name: "serve",
227                summary: "Serve a module's default fetch handler",
228                usage: &["serve [OPTIONS] <module> [args...]"],
229                groups: &[PERMISSIONS, PACKAGES, INCLUDES, LISTENER, ENVIRONMENT],
230            },
231            Command {
232                name: "task",
233                summary: "Run a task from avril.conf (without a name, list tasks)",
234                usage: &["task [--watch] [<name> [args...]]"],
235                groups: &[TASK],
236            },
237            Command {
238                name: "test",
239                summary: "Run tests",
240                usage: &["test [OPTIONS] [files...] [-- args...]"],
241                groups: &[TEST_PERMISSIONS, PACKAGES, INCLUDES, ENVIRONMENT, TEST],
242            },
243        ],
244    ),
245    (
246        "Additional commands",
247        &[
248            Command {
249                name: "add",
250                summary: "Add a jsr: or npm: package to avril.conf",
251                usage: &["add [OPTIONS] [<alias>] <jsr:|npm:specifier>"],
252                groups: &[
253                    ADD,
254                    (
255                        "Packages",
256                        &[
257                            OFFLINE,
258                            NO_TYPES_PACKAGES,
259                            JSR_REGISTRY,
260                            NPM_REGISTRY,
261                            CACHE_DIR,
262                        ],
263                    ),
264                ],
265            },
266            Command {
267                name: "bundle",
268                summary: "Bundle a module for the browser",
269                usage: &[
270                    "bundle [OPTIONS] <module> -o <output>",
271                    "bundle [OPTIONS] <module>... --outdir <directory>",
272                ],
273                groups: &[PERMISSIONS, PACKAGES, BUNDLE],
274            },
275            Command {
276                name: "cache",
277                summary: "Download and lock a module's dependencies",
278                usage: &["cache [OPTIONS] <module>"],
279                groups: &[PERMISSIONS, PACKAGES, INCLUDES],
280            },
281            Command {
282                name: "check",
283                summary: "Type-check a module",
284                usage: &["check --typescript=PATH [OPTIONS] <module>"],
285                groups: &[PERMISSIONS, PACKAGES, ("Type checking", &[TYPESCRIPT])],
286            },
287            Command {
288                name: "info",
289                summary: "Print a module's resolved graph as JSON",
290                usage: &["info [OPTIONS] <module>"],
291                groups: &[PERMISSIONS, PACKAGES, INCLUDES],
292            },
293            Command {
294                name: "lsp",
295                summary: "Start the native TypeScript-backed language server",
296                usage: &["lsp --typescript=PATH [--project=DIR] [OPTIONS]"],
297                groups: &[REPL_PERMISSIONS, PACKAGES, LANGUAGE_SERVER],
298            },
299            Command {
300                name: "update",
301                summary: "Select newer dependency versions and update the lockfile",
302                usage: &["update [OPTIONS] <module>"],
303                groups: &[
304                    PERMISSIONS,
305                    (
306                        "Packages",
307                        &[
308                            OFFLINE,
309                            NO_TYPES_PACKAGES,
310                            JSR_REGISTRY,
311                            NPM_REGISTRY,
312                            CACHE_DIR,
313                            LOCK,
314                            VENDOR,
315                        ],
316                    ),
317                    INCLUDES,
318                    (
319                        "Update",
320                        &[("--prune", "Drop pins outside the prepared graph")],
321                    ),
322                ],
323            },
324            Command {
325                name: "vendor",
326                summary: "Copy a module's dependencies into a directory",
327                usage: &["vendor [OPTIONS] <module>"],
328                groups: &[
329                    PERMISSIONS,
330                    PACKAGES,
331                    INCLUDES,
332                    (
333                        "Output",
334                        &[(
335                            "--out=PATH",
336                            "Vendor directory (default package-root/vendor)",
337                        )],
338                    ),
339                ],
340            },
341        ],
342    ),
343];
344
345fn commands() -> impl Iterator<Item = &'static Command> {
346    COMMAND_GROUPS.iter().flat_map(|(_, commands)| *commands)
347}
348
349/// Argument parsing failed: the shared vocabulary for generic shape errors.
350/// Each command's own enum carries its specific failures and converts from
351/// this via `#[from]`.
352#[derive(Debug, thiserror::Error)]
353pub enum ParseError {
354    #[error("{0}")]
355    Usage(String),
356    #[error("{0}")]
357    Check(String),
358    #[error("--{flag} does not take a value")]
359    UnexpectedValue { flag: String },
360    #[error("Expected a value for --{flag}{equals}")]
361    MissingValue { flag: String, equals: &'static str },
362    #[error("Expected an option with '=VALUE': {arg}")]
363    ExpectedEquals { arg: String },
364    #[error("Expected a value for {flag}")]
365    EmptyValue { flag: String },
366    #[error("Expected a path for --env-file (use --env-file=PATH for names starting with '-')")]
367    EnvFilePath,
368    #[error("Expected a pattern for --filter (use --filter=PATTERN for names starting with '-')")]
369    FilterPattern,
370    #[error("Expected a value for {flag} (use {flag}=VALUE for values starting with '-')")]
371    SpacedValue { flag: String },
372    #[error("Module paths must be valid UTF-8")]
373    ModulePath,
374    #[error("--{flag} requires a path")]
375    MissingPath { flag: String },
376    #[error("Duplicate option: --{flag}")]
377    Duplicate { flag: String },
378    #[error(transparent)]
379    Caps(#[from] CapabilitiesError),
380    #[error(transparent)]
381    Url(#[from] url::ParseError),
382    #[error(transparent)]
383    Io(#[from] std::io::Error),
384    #[error(transparent)]
385    Env(#[from] crate::env_file::Error),
386}
387
388/// Looks up a command by name for a hardcoded caller (task, add, lsp, and
389/// dispatch); a typo surfaces as a usage error instead of a panic.
390pub fn command(name: &str) -> Option<&'static Command> {
391    commands().find(|command| command.name == name)
392}
393
394// Help and capability pre-scans must respect the same guest-argument boundary.
395pub(crate) fn script_args_start(args: &[String]) -> usize {
396    let command = args.first().map(String::as_str);
397    if matches!(command, Some("run" | "serve")) {
398        let mut args_iter = args.iter().enumerate().skip(1);
399        while let Some((index, arg)) = args_iter.next() {
400            match arg.as_str() {
401                "--" => return (index + 2).min(args.len()),
402                "--env-file" => {
403                    args_iter.next();
404                }
405                _ if !arg.starts_with('-') => return index + 1,
406                _ => {}
407            }
408        }
409    } else if let Some(command) = command
410        && (matches!(command, "repl" | "test") || command.starts_with('-'))
411        && let Some(index) = args.iter().position(|arg| arg == "--")
412    {
413        return index + 1;
414    }
415    args.len()
416}
417
418/// Help for `avril --help`, `avril help [<command>]`, or `avril <command> --help`, if requested.
419pub fn help(args: &[String]) -> Result<Option<String>, String> {
420    let args = &args[..script_args_start(args)];
421    let requested = |args: &[String]| {
422        args.iter()
423            .take_while(|arg| *arg != "--")
424            .any(|arg| arg == "--help" || arg == "-h")
425    };
426    match args.first().map(String::as_str) {
427        Some("help") => match &args[1..] {
428            [] => Ok(Some(usage())),
429            [name] => command(name)
430                .map(|command| Some(command.help()))
431                .ok_or_else(|| format!("Unknown command: {name}\n\n{}", usage())),
432            _ => Err("Usage: avril help [<command>]".into()),
433        },
434        Some(name) if requested(&args[1..]) && command(name).is_some() => {
435            Ok(command(name).map(Command::help))
436        }
437        _ if requested(args) => Ok(Some(usage())),
438        _ => Ok(None),
439    }
440}
441
442/// General usage: the command overview. This is the fallback wherever a
443/// hardcoded command lookup fails, so a typo shows usage instead of panicking.
444pub fn usage() -> String {
445    let width = commands().map(|command| command.name.len()).max().unwrap() + 3;
446    let mut text = String::from(
447        "Usage: avril <command> [OPTIONS] [ARGS]\n       avril [OPTIONS] [-- args...]  (starts the REPL)\n",
448    );
449    for (title, commands) in COMMAND_GROUPS {
450        text.push_str(&format!("\n{title}:\n"));
451        for command in *commands {
452            text.push_str(&format!("  {:width$}{}\n", command.name, command.summary));
453        }
454    }
455    text.push_str("\nRun `avril help <command>` or `avril <command> --help` for its options.");
456    text
457}
458
459impl Command {
460    pub fn usage(&self) -> String {
461        let mut text = String::new();
462        for (index, line) in self.usage.iter().enumerate() {
463            let prefix = if index == 0 { "Usage:" } else { "      " };
464            text.push_str(&format!("{prefix} avril {line}\n"));
465        }
466        text.pop();
467        text
468    }
469
470    fn help(&self) -> String {
471        let width = self
472            .groups
473            .iter()
474            .flat_map(|(_, options)| options.iter())
475            .map(|option| option.0.len())
476            .max()
477            .unwrap_or_default()
478            + 3;
479        let mut text = format!("{}\n\n{}\n", self.summary, self.usage());
480        for (title, options) in self.groups {
481            text.push_str(&format!("\n{title}:\n"));
482            for option in *options {
483                text.push_str(&format!("  {:width$}{}\n", option.0, option.1));
484            }
485        }
486        text.pop();
487        if matches!(self.name, "run" | "serve") {
488            text.push_str("\n\nArguments after <module> are passed verbatim as process.args. Put runtime options before <module>.");
489        } else if matches!(self.name, "repl" | "test") {
490            text.push_str("\n\nArguments after -- are passed verbatim as process.args; the separating -- is omitted.");
491        }
492        text
493    }
494
495    /// Rejects an option this command does not take, `--name=VALUE` or a bare flag.
496    pub fn check(&self, arg: &str) -> Result<(), String> {
497        let flag = arg.split_once('=').map_or(arg, |(flag, _)| flag);
498        let takes = |command: &Command| {
499            command
500                .groups
501                .iter()
502                .flat_map(|(_, options)| options.iter())
503                .any(|option| names(option.0).any(|name| name == flag))
504        };
505        if takes(self) {
506            Ok(())
507        } else if commands().any(takes) {
508            Err(format!("{flag} is not an option of `avril {}`", self.name))
509        } else {
510            Err(format!("Unknown option: {flag}"))
511        }
512    }
513}
514
515pub fn default_cache(cwd: &Path) -> PathBuf {
516    cwd.join(
517        std::env::var_os("AVRIL_CACHE_DIR")
518            .map(PathBuf::from)
519            .or_else(|| {
520                std::env::var_os("XDG_CACHE_HOME").map(|root| PathBuf::from(root).join("avril"))
521            })
522            .or_else(|| {
523                std::env::var_os("HOME").map(|root| PathBuf::from(root).join(".cache/avril"))
524            })
525            .unwrap_or_else(|| cwd.join(".avril-cache")),
526    )
527}