use std::path::{Path, PathBuf}; use avril_runtime::caps::CapabilitiesError; /// `(syntax, help)`: the flag names are read off `syntax`. type Opt = (&'static str, &'static str); type Group = (&'static str, &'static [Opt]); /// The flag names an option's syntax spells, expanding `--allow-{a,b}=VALUE`. fn names(syntax: &str) -> impl Iterator + '_ { syntax.split(", ").flat_map(|spelling| { let flag = spelling.split([' ', '=']).next().unwrap(); match flag.split_once('{') { Some((prefix, rest)) => rest .trim_end_matches('}') .split(',') .map(|name| format!("{prefix}{name}")) .collect(), None => vec![flag.to_owned()], } }) } const OFFLINE: Opt = ("--offline", "Never contact the registry"); const NO_TYPES_PACKAGES: Opt = ( "--no-types-packages", "Disable automatic DefinitelyTyped package lookup", ); const JSR_REGISTRY: Opt = ( "--jsr-registry=URL", "JSR registry base (default https://jsr.io/)", ); const NPM_REGISTRY: Opt = ( "--npm-registry=URL", "npm registry base (default https://registry.npmjs.org/)", ); const CACHE_DIR: Opt = ( "--cache-dir=PATH", "Package cache (default $AVRIL_CACHE_DIR or user cache)", ); const LOCK: Opt = ( "--lock=PATH", "Root graph lockfile (default package-root/avril.lock)", ); const VENDOR: Opt = ( "--vendor=PATH", "Read acquired artifacts from a vendor directory", ); const ALLOW: Opt = ( "--allow-{net,read,write,js-exec,env,sys,run,ffi}=VALUE", "Grant a capability", ); const DENY: Opt = ( "--deny-{net,read,write,js-exec,env,sys,run,ffi}=VALUE", "Deny a capability", ); const PERMISSIONS: Group = ( "Permissions", &[ ( "-0", "Start with no capabilities except running the entry module", ), ALLOW, DENY, ], ); const TEST_PERMISSIONS: Group = ( "Permissions", &[ ( "-0", "Start with no capabilities except running the test files", ), ALLOW, DENY, ], ); const REPL_PERMISSIONS: Group = ( "Permissions", &[("-0", "Start with no capabilities"), ALLOW, DENY], ); const PACKAGES: Group = ( "Packages", &[ OFFLINE, NO_TYPES_PACKAGES, ("--frozen", "Require existing lock selections and integrity"), JSR_REGISTRY, NPM_REGISTRY, CACHE_DIR, LOCK, VENDOR, ], ); const INCLUDES: Group = ( "Includes", &[ ( "--include=SPECIFIER", "Also prepare a dynamic dependency; repeatable", ), ( "--include-from=REFERRER", "Resolve following includes from this source ('.' resets)", ), ], ); const ENVIRONMENT: Group = ( "Environment", &[( "--env-file=PATH", "Load environment defaults from a file; repeatable", )], ); const TYPESCRIPT: Opt = ( "--typescript=PATH", "Stock native TypeScript executable (pinned version required)", ); const LISTENER: Group = ( "Listener", &[ ("--hostname=HOST", "TCP bind hostname (default 127.0.0.1)"), ( "--port=PORT", "TCP bind port (default 8000; 0 selects an available port)", ), ("--unix=PATH", "Unix socket path, instead of TCP"), ], ); const TASK: Group = ( "Task", &[( "--watch", "Rerun the task when its declared input files change", )], ); const TEST: Group = ( "Test", &[ ( "--filter=PATTERN", "Only run tests whose name contains PATTERN", ), ( "--no-run", "Collect tests without running them (file top levels still evaluate)", ), ], ); const ADD: Group = ( "Add", &[ ( "--config=PATH", "Edit this config instead of the nearest one", ), ("--pre", "Let an unversioned target select a prerelease"), ], ); const BUNDLE: Group = ( "Bundle", &[ ( "-o, --out PATH", "Entry output file; chunks and maps go beside it", ), ( "--outdir PATH", "Output directory; entries use their basenames with .js extensions", ), ("--minify", "Minify the output"), ("--sourcemap", "Write a source map beside each output file"), ( "--no-splitting", "Inline dynamic imports instead of emitting lazy chunks", ), ( "--target TARGET", "Output syntax level, es2015 to es2025 or esnext (default)", ), ( "--define KEY=VALUE", "Replace a global or dotted path with a JS expression; repeatable", ), ], ); const LANGUAGE_SERVER: Group = ( "Language server", &[ TYPESCRIPT, ( "--project=DIR", "Workspace directory (default current directory); open documents are graph roots", ), ], ); pub struct Command { name: &'static str, summary: &'static str, usage: &'static [&'static str], groups: &'static [Group], } const COMMAND_GROUPS: &[(&str, &[Command])] = &[ ( "Main commands", &[ Command { name: "repl", summary: "Start an interactive session (the default without a command)", usage: &["repl [OPTIONS] [-- args...]", "[OPTIONS] [-- args...]"], groups: &[REPL_PERMISSIONS, PACKAGES], }, Command { name: "run", summary: "Run a module", usage: &["run [OPTIONS] [args...]"], groups: &[PERMISSIONS, PACKAGES, INCLUDES, ENVIRONMENT], }, Command { name: "serve", summary: "Serve a module's default fetch handler", usage: &["serve [OPTIONS] [args...]"], groups: &[PERMISSIONS, PACKAGES, INCLUDES, LISTENER, ENVIRONMENT], }, Command { name: "task", summary: "Run a task from avril.conf (without a name, list tasks)", usage: &["task [--watch] [ [args...]]"], groups: &[TASK], }, Command { name: "test", summary: "Run tests", usage: &["test [OPTIONS] [files...] [-- args...]"], groups: &[TEST_PERMISSIONS, PACKAGES, INCLUDES, ENVIRONMENT, TEST], }, ], ), ( "Additional commands", &[ Command { name: "add", summary: "Add a jsr: or npm: package to avril.conf", usage: &["add [OPTIONS] [] "], groups: &[ ADD, ( "Packages", &[ OFFLINE, NO_TYPES_PACKAGES, JSR_REGISTRY, NPM_REGISTRY, CACHE_DIR, ], ), ], }, Command { name: "bundle", summary: "Bundle a module for the browser", usage: &[ "bundle [OPTIONS] -o ", "bundle [OPTIONS] ... --outdir ", ], groups: &[PERMISSIONS, PACKAGES, BUNDLE], }, Command { name: "cache", summary: "Download and lock a module's dependencies", usage: &["cache [OPTIONS] "], groups: &[PERMISSIONS, PACKAGES, INCLUDES], }, Command { name: "check", summary: "Type-check a module", usage: &["check --typescript=PATH [OPTIONS] "], groups: &[PERMISSIONS, PACKAGES, ("Type checking", &[TYPESCRIPT])], }, Command { name: "info", summary: "Print a module's resolved graph as JSON", usage: &["info [OPTIONS] "], groups: &[PERMISSIONS, PACKAGES, INCLUDES], }, Command { name: "lsp", summary: "Start the native TypeScript-backed language server", usage: &["lsp --typescript=PATH [--project=DIR] [OPTIONS]"], groups: &[REPL_PERMISSIONS, PACKAGES, LANGUAGE_SERVER], }, Command { name: "update", summary: "Select newer dependency versions and update the lockfile", usage: &["update [OPTIONS] "], groups: &[ PERMISSIONS, ( "Packages", &[ OFFLINE, NO_TYPES_PACKAGES, JSR_REGISTRY, NPM_REGISTRY, CACHE_DIR, LOCK, VENDOR, ], ), INCLUDES, ( "Update", &[("--prune", "Drop pins outside the prepared graph")], ), ], }, Command { name: "vendor", summary: "Copy a module's dependencies into a directory", usage: &["vendor [OPTIONS] "], groups: &[ PERMISSIONS, PACKAGES, INCLUDES, ( "Output", &[( "--out=PATH", "Vendor directory (default package-root/vendor)", )], ), ], }, ], ), ]; fn commands() -> impl Iterator { COMMAND_GROUPS.iter().flat_map(|(_, commands)| *commands) } /// Argument parsing failed: the shared vocabulary for generic shape errors. /// Each command's own enum carries its specific failures and converts from /// this via `#[from]`. #[derive(Debug, thiserror::Error)] pub enum ParseError { #[error("{0}")] Usage(String), #[error("{0}")] Check(String), #[error("--{flag} does not take a value")] UnexpectedValue { flag: String }, #[error("Expected a value for --{flag}{equals}")] MissingValue { flag: String, equals: &'static str }, #[error("Expected an option with '=VALUE': {arg}")] ExpectedEquals { arg: String }, #[error("Expected a value for {flag}")] EmptyValue { flag: String }, #[error("Expected a path for --env-file (use --env-file=PATH for names starting with '-')")] EnvFilePath, #[error("Expected a pattern for --filter (use --filter=PATTERN for names starting with '-')")] FilterPattern, #[error("Expected a value for {flag} (use {flag}=VALUE for values starting with '-')")] SpacedValue { flag: String }, #[error("Module paths must be valid UTF-8")] ModulePath, #[error("--{flag} requires a path")] MissingPath { flag: String }, #[error("Duplicate option: --{flag}")] Duplicate { flag: String }, #[error(transparent)] Caps(#[from] CapabilitiesError), #[error(transparent)] Url(#[from] url::ParseError), #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Env(#[from] crate::env_file::Error), } /// Looks up a command by name for a hardcoded caller (task, add, lsp, and /// dispatch); a typo surfaces as a usage error instead of a panic. pub fn command(name: &str) -> Option<&'static Command> { commands().find(|command| command.name == name) } // Help and capability pre-scans must respect the same guest-argument boundary. pub(crate) fn script_args_start(args: &[String]) -> usize { let command = args.first().map(String::as_str); if matches!(command, Some("run" | "serve")) { let mut args_iter = args.iter().enumerate().skip(1); while let Some((index, arg)) = args_iter.next() { match arg.as_str() { "--" => return (index + 2).min(args.len()), "--env-file" => { args_iter.next(); } _ if !arg.starts_with('-') => return index + 1, _ => {} } } } else if let Some(command) = command && (matches!(command, "repl" | "test") || command.starts_with('-')) && let Some(index) = args.iter().position(|arg| arg == "--") { return index + 1; } args.len() } /// Help for `avril --help`, `avril help []`, or `avril --help`, if requested. pub fn help(args: &[String]) -> Result, String> { let args = &args[..script_args_start(args)]; let requested = |args: &[String]| { args.iter() .take_while(|arg| *arg != "--") .any(|arg| arg == "--help" || arg == "-h") }; match args.first().map(String::as_str) { Some("help") => match &args[1..] { [] => Ok(Some(usage())), [name] => command(name) .map(|command| Some(command.help())) .ok_or_else(|| format!("Unknown command: {name}\n\n{}", usage())), _ => Err("Usage: avril help []".into()), }, Some(name) if requested(&args[1..]) && command(name).is_some() => { Ok(command(name).map(Command::help)) } _ if requested(args) => Ok(Some(usage())), _ => Ok(None), } } /// General usage: the command overview. This is the fallback wherever a /// hardcoded command lookup fails, so a typo shows usage instead of panicking. pub fn usage() -> String { let width = commands().map(|command| command.name.len()).max().unwrap() + 3; let mut text = String::from( "Usage: avril [OPTIONS] [ARGS]\n avril [OPTIONS] [-- args...] (starts the REPL)\n", ); for (title, commands) in COMMAND_GROUPS { text.push_str(&format!("\n{title}:\n")); for command in *commands { text.push_str(&format!(" {:width$}{}\n", command.name, command.summary)); } } text.push_str("\nRun `avril help ` or `avril --help` for its options."); text } impl Command { pub fn usage(&self) -> String { let mut text = String::new(); for (index, line) in self.usage.iter().enumerate() { let prefix = if index == 0 { "Usage:" } else { " " }; text.push_str(&format!("{prefix} avril {line}\n")); } text.pop(); text } fn help(&self) -> String { let width = self .groups .iter() .flat_map(|(_, options)| options.iter()) .map(|option| option.0.len()) .max() .unwrap_or_default() + 3; let mut text = format!("{}\n\n{}\n", self.summary, self.usage()); for (title, options) in self.groups { text.push_str(&format!("\n{title}:\n")); for option in *options { text.push_str(&format!(" {:width$}{}\n", option.0, option.1)); } } text.pop(); if matches!(self.name, "run" | "serve") { text.push_str("\n\nArguments after are passed verbatim as process.args. Put runtime options before ."); } else if matches!(self.name, "repl" | "test") { text.push_str("\n\nArguments after -- are passed verbatim as process.args; the separating -- is omitted."); } text } /// Rejects an option this command does not take, `--name=VALUE` or a bare flag. pub fn check(&self, arg: &str) -> Result<(), String> { let flag = arg.split_once('=').map_or(arg, |(flag, _)| flag); let takes = |command: &Command| { command .groups .iter() .flat_map(|(_, options)| options.iter()) .any(|option| names(option.0).any(|name| name == flag)) }; if takes(self) { Ok(()) } else if commands().any(takes) { Err(format!("{flag} is not an option of `avril {}`", self.name)) } else { Err(format!("Unknown option: {flag}")) } } } pub fn default_cache(cwd: &Path) -> PathBuf { cwd.join( std::env::var_os("AVRIL_CACHE_DIR") .map(PathBuf::from) .or_else(|| { std::env::var_os("XDG_CACHE_HOME").map(|root| PathBuf::from(root).join("avril")) }) .or_else(|| { std::env::var_os("HOME").map(|root| PathBuf::from(root).join(".cache/avril")) }) .unwrap_or_else(|| cwd.join(".avril-cache")), ) }