use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use oxc::syntax::{identifier::is_identifier_name, keyword::is_reserved_keyword}; use url::Url; use crate::config::{self, Error, Span, Value, ValueKind, invalid, object}; use crate::import_map::ImportMap; #[derive(Debug)] pub struct Export { pub target: Url, pub span: Span, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Source { /// A local package directory. Local(PathBuf), /// A whole `jsr:` package, optionally with a version requirement. Jsr(crate::registry::JsrRequest), /// A whole `npm:` package, optionally with a version requirement. Npm(crate::registry::npm::NpmRequest), Http(Url), } #[derive(Debug)] pub struct Dependency { pub source: Source, /// The binding, unless it is the name a local dependency declares for itself. pub name: Option, /// Where the binding name is chosen. pub span: Span, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TaskDependency { pub name: String, pub span: Span, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TaskPattern { pub pattern: String, pub span: Span, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TaskEnvFile { pub path: String, pub span: Span, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum TaskCommand { /// Run through the platform shell (`sh -c` / `cmd /C`). Shell(String), /// Spawn directly, without a shell. Argv(Vec), } impl TaskCommand { /// A one-line rendering for task listings. pub fn display(&self) -> String { match self { TaskCommand::Shell(command) => command.clone(), TaskCommand::Argv(argv) => argv.join(" "), } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Task { pub command: Option, pub description: Option, pub dependencies: Vec, /// Run `dependencies` all at once instead of sequentially. pub parallel: bool, /// A dotenv file applied to this task and, recursively, its dependencies. pub env_file: Option, /// Declaring `files` opts the task into result caching. pub files: Option>, pub output: Vec, /// Environment variable names whose values join the cache key. pub env: Vec, pub span: Span, } #[derive(Debug, Default)] pub struct TypeConfig { pub imports: ImportMap, pub globals: Vec, } #[derive(Debug)] pub struct PackageConfig { pub name: Option, pub version: Option, pub description: Option, pub exports: BTreeMap, pub dependencies: Vec, pub tasks: BTreeMap, pub import_map: ImportMap, pub types: TypeConfig, /// Each registry dependency's binding mapped to its package and that /// package's exports: `jsr:` and `npm:` alike. pub registry_map: ImportMap, /// Package-wide defaults for the supported JSX `compilerOptions`. pub compiler_options: serde_json::Map, name_span: Option, } impl PackageConfig { pub fn parse(source: &str, base: &Url) -> Result { Self::from_value(&config::parse(source)?, base) } pub(crate) fn from_value(root: &Value, base: &Url) -> Result { if !matches!(base.scheme(), "file" | "http" | "https") { return Err(invalid( root.span.clone(), "Package configs require a file or HTTP(S) URL", )); } let directory = base .join("./") .map_err(|error| invalid(root.span.clone(), error.to_string()))?; let mut package = Self { name: None, version: None, description: None, exports: BTreeMap::new(), dependencies: Vec::new(), tasks: BTreeMap::new(), import_map: ImportMap::default(), types: TypeConfig::default(), registry_map: ImportMap::default(), compiler_options: serde_json::Map::new(), name_span: None, }; let mut imports = None; let mut scopes = None; let mut type_config = None; for member in object(root)? { match member.key.as_str() { "name" => { let name = string(&member.value)?; if !valid_name(name) { return Err(invalid( member.value.span.clone(), "Expected a bare package name, optionally scoped", )); } package.name = Some(name.to_owned()); package.name_span = Some(member.value.span.clone()); } "version" => { let version = string(&member.value)?; deno_semver::Version::parse_standard(version).map_err(|error| { invalid( member.value.span.clone(), format!("Invalid package version: {error}"), ) })?; package.version = Some(version.to_owned()); } "description" => package.description = Some(string(&member.value)?.to_owned()), "exports" => { let entries: Vec<_> = match &member.value.kind { ValueKind::String(_) => { vec![(".", &member.value, member.value.span.clone())] } _ => object(&member.value)? .iter() .map(|entry| (entry.key.as_str(), &entry.value, entry.key_span.clone())) .collect(), }; for (key, value, span) in entries { if key != "." && (!key.starts_with("./") || key[2..] .split('/') .any(|part| part.is_empty() || matches!(part, "." | "..")) || key.contains(['*', '?', '#', '\\']) || key.chars().any(char::is_control)) { return Err(invalid( span, "Exports must use '.' or exact './subpath' keys", )); } let target = string(value)?; if !target.starts_with("./") { return Err(invalid( value.span.clone(), "Export targets must be package-relative files starting with './'", )); } let url = package_url(target, base, &value.span)?; if !url.as_str().starts_with(directory.as_str()) || url == directory || url.path().ends_with('/') { return Err(invalid( value.span.clone(), "Export target must be a file inside its package", )); } package.exports.insert( key.to_owned(), Export { target: url, span: value.span.clone(), }, ); } } "imports" => imports = Some(&member.value), "scopes" => scopes = Some(&member.value), "types" => type_config = Some(&member.value), "compilerOptions" => { for option in object(&member.value)? { // Every key is retained now: non-JSX values steer type // checking, so an unconvertible value errors at its own span. let value = option.value.to_value()?; check_jsx_option(&option.key, &value) .map_err(|message| invalid(option.value.span.clone(), message))?; package.compiler_options.insert(option.key.clone(), value); } } "deps" => { let ValueKind::Array(entries) = &member.value.kind else { return Err(invalid(member.value.span.clone(), "Expected an array")); }; for entry in entries { package.dependencies.push(dependency(entry, base)?); } } "tasks" => { for entry in object(&member.value)? { if !valid_task_name(&entry.key) { return Err(invalid( entry.key_span.clone(), "Task names must be non-empty and use only letters, digits, '-', '_', '.', or ':'", )); } package.tasks.insert(entry.key.clone(), task(&entry.value)?); } } _ => {} } } package.import_map = ImportMap::from_fields(imports, scopes, base)?; if let Some(type_config) = type_config { package.types = parse_type_config(type_config, base)?; } if let Some((name, span)) = package.name.as_ref().zip(package.name_span.as_ref()) && let Some(key) = package.import_map.namespace_conflict(name) { return Err(invalid( span.clone(), format!("Package name {name:?} conflicts with import key {key:?}"), )); } let mut bindings = HashMap::new(); for dependency in &package.dependencies { if let Some(name) = &dependency.name { package.check_binding(name, &dependency.span, &mut bindings)?; } } let packages: Vec<(String, String)> = package .dependencies .iter() .filter_map(|dependency| match (&dependency.name, &dependency.source) { (Some(name), Source::Jsr(request)) => { Some((name.clone(), request.specifier.clone())) } (Some(name), Source::Npm(request)) => Some((name.clone(), request.request_key())), _ => None, }) .collect(); package.registry_map = ImportMap::packages(packages); Ok(package) } /// Binds each dependency, asking `declared` for the declared name of any local /// dependency listed by path alone. Errors are reported against this config. pub async fn bindings( &self, mut declared: impl FnMut(Source) -> F, ) -> Result, Error> where F: std::future::Future, String>>, { let mut spans = HashMap::new(); let mut declarers = HashMap::new(); let mut bindings = BTreeMap::new(); for dependency in &self.dependencies { let (name, declarer) = match (&dependency.name, &dependency.source) { (Some(name), _) => (name.clone(), None), (None, Source::Jsr(_)) => unreachable!("jsr: dependencies are named when parsed"), (None, Source::Npm(_)) => unreachable!("npm: dependencies are named when parsed"), (None, source) => { let config = match source { Source::Local(directory) => { directory.join("avril.conf").display().to_string() } Source::Http(directory) => { directory.join("avril.conf").unwrap().to_string() } Source::Jsr(_) => unreachable!(), Source::Npm(_) => unreachable!(), }; let name = declared(source.clone()) .await .map_err(|error| { invalid( dependency.span.clone(), format!( "Cannot read the name of dependency {}: {error}", config ), ) })? .ok_or_else(|| { invalid( dependency.span.clone(), format!( "Dependency {} declares no name; give this entry {{from, name}}", config ), ) })?; (name, Some(config)) } }; self.check_binding(&name, &dependency.span, &mut spans) .map_err(|mut error| { // A duplicate of an earlier inferred name needs that dependency's context. if let Some(config) = declarer.as_ref().or_else(|| declarers.get(&name)) { error.message = format!( "{}; the dependency's name comes from {}", error.message, config ); } error })?; if let Some(config) = declarer { declarers.insert(name.clone(), config); } bindings.insert(name, dependency.source.clone()); } Ok(bindings) } fn check_binding( &self, name: &str, span: &Span, bindings: &mut HashMap, ) -> Result<(), Error> { if self.name.as_deref() == Some(name) { return Err(Error { kind: crate::config::ErrorKind::Syntax, message: format!( "Dependency binding {name:?} conflicts with the package's self name" ), span: span.clone(), previous: self.name_span.clone(), }); } if let Some(previous) = bindings.get(name) { return Err(Error { kind: crate::config::ErrorKind::Syntax, message: format!("Duplicate dependency binding {name:?}"), span: span.clone(), previous: Some(previous.clone()), }); } if let Some(key) = self.import_map.namespace_conflict(name) { return Err(invalid( span.clone(), format!("Dependency binding {name:?} conflicts with import key {key:?}"), )); } bindings.insert(name.to_owned(), span.clone()); Ok(()) } } fn valid_task_name(name: &str) -> bool { !name.is_empty() && name .bytes() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, b'-' | b'_' | b'.' | b':')) } fn task(value: &Value) -> Result { let span = value.span.clone(); let command = |value: &Value| -> Result { match &value.kind { ValueKind::Array(entries) => { if entries.is_empty() { return Err(invalid( value.span.clone(), "Task command arrays cannot be empty", )); } let mut argv = Vec::new(); for entry in entries { argv.push(string(entry)?.to_owned()); } if argv[0].is_empty() { return Err(invalid( entries[0].span.clone(), "The first element of a task command array cannot be empty", )); } Ok(TaskCommand::Argv(argv)) } _ => { let command = string(value).map_err(|_| { invalid( value.span.clone(), "Expected a string (shell command) or an array of strings (argv) for 'command'", ) })?; if command.trim().is_empty() { return Err(invalid(value.span.clone(), "Task commands cannot be empty")); } Ok(TaskCommand::Shell(command.to_owned())) } } }; match &value.kind { ValueKind::String(_) => Ok(Task { command: Some(command(value)?), description: None, dependencies: Vec::new(), parallel: false, env_file: None, files: None, output: Vec::new(), env: Vec::new(), span, }), ValueKind::Object(fields) => { let mut task = Task { command: None, description: None, dependencies: Vec::new(), parallel: false, env_file: None, files: None, output: Vec::new(), env: Vec::new(), span: span.clone(), }; let mut dependency_spans: HashMap = HashMap::new(); let mut parallel_span = None; for field in fields { match field.key.as_str() { "command" => task.command = Some(command(&field.value)?), "description" => task.description = Some(string(&field.value)?.to_owned()), "dependencies" => { let ValueKind::Array(entries) = &field.value.kind else { return Err(invalid( field.value.span.clone(), "Expected an array of task names", )); }; for entry in entries { let name = string(entry)?; if !valid_task_name(name) { return Err(invalid(entry.span.clone(), "Expected a task name")); } if let Some(previous) = dependency_spans.get(name) { return Err(Error { kind: crate::config::ErrorKind::Syntax, message: format!("Duplicate task dependency {name:?}"), span: entry.span.clone(), previous: Some(previous.clone()), }); } dependency_spans.insert(name.to_owned(), entry.span.clone()); task.dependencies.push(TaskDependency { name: name.to_owned(), span: entry.span.clone(), }); } } "parallel" => { let ValueKind::Bool(parallel) = field.value.kind else { return Err(invalid(field.value.span.clone(), "Expected a boolean")); }; task.parallel = parallel; parallel_span = Some(field.value.span.clone()); } "envFile" => { let path = string(&field.value)?; if path.trim().is_empty() { return Err(invalid( field.value.span.clone(), "Task env file paths cannot be empty", )); } task.env_file = Some(TaskEnvFile { path: path.to_owned(), span: field.value.span.clone(), }); } "files" | "output" => { let ValueKind::Array(entries) = &field.value.kind else { return Err(invalid( field.value.span.clone(), "Expected an array of glob patterns", )); }; let mut patterns = Vec::new(); for entry in entries { let pattern = string(entry)?; avril_task::validate_pattern(pattern) .map_err(|error| invalid(entry.span.clone(), error.to_string()))?; if field.key == "output" { avril_task::validate_output_pattern(pattern).map_err(|error| { invalid(entry.span.clone(), error.to_string()) })?; } patterns.push(TaskPattern { pattern: pattern.to_owned(), span: entry.span.clone(), }); } if field.key == "files" { task.files = Some(patterns); } else { task.output = patterns; } } "env" => { let ValueKind::Array(entries) = &field.value.kind else { return Err(invalid( field.value.span.clone(), "Expected an array of environment variable names", )); }; let mut seen: HashMap = HashMap::new(); for entry in entries { let name = string(entry)?; if !valid_env_name(name) { return Err(invalid( entry.span.clone(), "Expected an environment variable name", )); } if let Some(previous) = seen.get(name) { return Err(Error { kind: crate::config::ErrorKind::Syntax, message: format!("Duplicate task env name {name:?}"), span: entry.span.clone(), previous: Some(previous.clone()), }); } seen.insert(name.to_owned(), entry.span.clone()); task.env.push(name.to_owned()); } } _ => {} } } if task.parallel && task.dependencies.is_empty() { return Err(invalid( parallel_span.unwrap(), "Task 'parallel' requires 'dependencies'", )); } if task.files.is_none() && (!task.output.is_empty() || !task.env.is_empty()) { return Err(invalid( span.clone(), "Task 'output' and 'env' require 'files': declaring 'files' enables result caching", )); } if task.command.is_none() && task.dependencies.is_empty() { return Err(invalid( span, "Tasks must define a 'command' or 'dependencies'", )); } Ok(task) } _ => Err(invalid( span, "Expected a task command string or an object with 'command', 'description', 'dependencies', 'parallel', 'envFile', 'files', 'output', or 'env'", )), } } fn valid_env_name(name: &str) -> bool { let mut bytes = name.bytes(); bytes .next() .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == b'_') && bytes.all(|ch| ch.is_ascii_alphanumeric() || ch == b'_') } fn dependency(entry: &Value, base: &Url) -> Result { let (from, name) = match &entry.kind { ValueKind::String(_) => (entry, None), ValueKind::Object(_) => { let mut from = None; let mut name = None; for field in object(entry)? { match field.key.as_str() { "from" => from = Some(&field.value), "name" => name = Some(&field.value), _ => {} } } let (Some(from), Some(name)) = (from, name) else { return Err(invalid( entry.span.clone(), "Dependency objects require both 'from' and 'name'; a string uses the dependency's own name", )); }; (from, Some(name)) } _ => { return Err(invalid( entry.span.clone(), "Expected a dependency path, HTTP(S) directory URL, jsr: package, or {from, name}", )); } }; let binding = match name { Some(name) => { let binding = string(name)?; if !valid_name(binding) { return Err(invalid( name.span.clone(), "Expected a dependency binding name, optionally scoped", )); } Some(binding.to_owned()) } None => None, }; let target = string(from)?; let (source, implicit) = if target.starts_with("jsr:") { let span = &from.span; let request = crate::registry::parse_jsr_request(target) .map_err(|error| invalid(span.clone(), error.to_string()))?; if request.version_req.tag().is_some() { return Err(invalid( span.clone(), "JSR version tags are not supported; use a semver range", )); } if request.subpath.is_some() || target.ends_with('/') { return Err(invalid( span.clone(), "Dependencies are whole packages; map a subpath in imports instead", )); } crate::registry::validate_name(&request.name) .map_err(|error| invalid(span.clone(), error.to_string()))?; let name = request.name.clone(); (Source::Jsr(request), Some(name)) } else if target.starts_with("npm:") { let span = &from.span; let request = crate::registry::npm::parse_npm_request(target) .map_err(|error| invalid(span.clone(), error.to_string()))?; if request.subpath.is_some() || target.ends_with('/') { return Err(invalid( span.clone(), "Dependencies are whole packages; map a subpath in imports instead", )); } let name = request.name.clone(); (Source::Npm(request), Some(name)) } else if Url::parse(target).is_ok_and(|url| matches!(url.scheme(), "http" | "https")) || (matches!(base.scheme(), "http" | "https") && (target.starts_with("./") || target.starts_with("../"))) { let url = package_url(target, base, &from.span)?; if !matches!(url.scheme(), "http" | "https") || !url.username().is_empty() || url.password().is_some() || !target.ends_with('/') { return Err(invalid( from.span.clone(), "HTTP(S) dependencies must be credential-free directory URLs ending with '/'", )); } (Source::Http(url), None) } else if target.starts_with("./") || target.starts_with("../") { let path = package_url(target, base, &from.span)? .to_file_path() .map_err(|_| invalid(from.span.clone(), "Expected a local package path"))?; (Source::Local(path), None) } else { return Err(invalid( from.span.clone(), "Dependencies must be jsr: packages, HTTP(S) directory URLs, or config-relative paths starting with './' or '../'", )); }; Ok(Dependency { source, name: binding.or(implicit), span: name.unwrap_or(from).span.clone(), }) } /// Whether `name` is a bare package name, optionally scoped. pub fn valid_name(name: &str) -> bool { let valid_part = |part: &str| { !part.is_empty() && !part.starts_with('.') && part .bytes() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, b'-' | b'_' | b'.')) }; match name.strip_prefix('@') { Some(scoped) => scoped .split_once('/') .is_some_and(|(scope, name)| valid_part(scope) && valid_part(name)), None => valid_part(name), } } /// The `compilerOptions` validated for the runtime; every key is retained /// for type checking, in `avril.conf` and `tsconfig.json` alike. pub const JSX_OPTIONS: [&str; 4] = ["jsx", "jsxImportSource", "jsxFactory", "jsxFragmentFactory"]; pub fn check_jsx_option(key: &str, value: &serde_json::Value) -> Result<(), String> { if !JSX_OPTIONS.contains(&key) { return Ok(()); } let text = value .as_str() .filter(|text| !text.trim().is_empty()) .ok_or_else(|| format!("compilerOptions.{key} must be a nonempty string"))?; if key == "jsx" && !matches!( text, "react" | "react-jsx" | "react-jsxdev" | "preserve" | "react-native" ) { return Err("Unsupported compilerOptions.jsx".into()); } if matches!(key, "jsxFactory" | "jsxFragmentFactory") { let mut parts = text.split('.'); let root = parts.next().unwrap(); let valid = match root { "this" => true, "import" => parts.next() == Some("meta"), _ => is_identifier_name(root) && !is_reserved_keyword(root), }; if !valid || !parts.all(is_identifier_name) { return Err(format!( "compilerOptions.{key} must be an identifier or dotted member expression" )); } } Ok(()) } fn parse_type_config(value: &Value, base: &Url) -> Result { let members = object(value)?; let mut imports = None; let mut globals = Vec::new(); for member in members { match member.key.as_str() { "imports" => imports = Some(&member.value), "globals" => { let ValueKind::Array(entries) = &member.value.kind else { return Err(invalid(member.value.span.clone(), "Expected an array")); }; for entry in entries { let target = string(entry)?; let url = if target.starts_with("./") || target.starts_with("../") || target.starts_with('/') { base.join(target) .map_err(|error| invalid(entry.span.clone(), error.to_string()))? } else { Url::parse(target).map_err(|_| { invalid( entry.span.clone(), "Global type entries must be URLs or relative paths", ) })? }; globals.push(url.to_string()); } } _ => {} } } Ok(TypeConfig { imports: ImportMap::from_fields(imports, None, base)?, globals, }) } fn string(value: &Value) -> Result<&str, Error> { match &value.kind { ValueKind::String(value) => Ok(value), _ => Err(invalid(value.span.clone(), "Expected a string")), } } fn package_url(target: &str, base: &Url, span: &Span) -> Result { let lower = target.to_ascii_lowercase(); if target.contains('\\') || target.chars().any(char::is_control) || lower.contains("%2f") || lower.contains("%5c") { return Err(invalid(span.clone(), "Invalid package path")); } let url = base .join(target) .map_err(|error| invalid(span.clone(), error.to_string()))?; if url.query().is_some() || url.fragment().is_some() { return Err(invalid( span.clone(), "Package paths cannot contain queries or fragments", )); } let decoded = percent_encoding::percent_decode_str(url.path()) .decode_utf8() .map_err(|_| invalid(span.clone(), "Package paths must be valid UTF-8"))?; if decoded.contains('\0') { return Err(invalid( span.clone(), "Package paths must be without NUL bytes", )); } Ok(url) } #[cfg(test)] mod tests { use super::PackageConfig; use url::Url; const BASE: &str = "https://example.test/project/avril.conf"; #[test] fn type_config_normalizes_global_modules_and_keeps_imports_separate() { let config = PackageConfig::parse( r#"{ imports: { "npm:legacy-sdk": "npm:runtime-sdk" }, types: { imports: { "npm:legacy-sdk": "npm:@types/legacy-sdk@^3", "https://example.test/project/src/old": "./types/legacy.d.ts", "legacy/": null }, globals: ["./types/globals.d.ts", "npm:@types/node@^22", "https://types.test/global.d.ts"] } }"#, &Url::parse(BASE).unwrap(), ) .unwrap(); assert_eq!( config.types.globals, [ "https://example.test/project/types/globals.d.ts", "npm:@types/node@^22", "https://types.test/global.d.ts", ] ); let referrer = Url::parse("https://example.test/project/src/module.ts").unwrap(); assert_eq!( config .types .imports .resolve("npm:legacy-sdk", &referrer) .unwrap(), Some("npm:@types/legacy-sdk@^3".into()) ); assert_eq!( config .import_map .resolve("npm:legacy-sdk", &referrer) .unwrap(), Some("npm:runtime-sdk".into()) ); assert_eq!( config.types.imports.resolve("./old", &referrer).unwrap(), Some("https://example.test/project/types/legacy.d.ts".into()) ); assert!( config .types .imports .resolve("legacy/subpath", &referrer) .is_err() ); } #[test] fn type_config_defaults_empty_and_rejects_malformed_shapes() { let base = Url::parse(BASE).unwrap(); let config = PackageConfig::parse("{}", &base).unwrap(); assert!(config.types.globals.is_empty()); assert!( config .types .imports .resolve("anything", &base) .unwrap() .is_none() ); for source in [ r#"{ types: [] }"#, r#"{ types: { imports: [] } }"#, r#"{ types: { globals: "./global.d.ts" } }"#, r#"{ types: { globals: ["bare-module"] } }"#, r#"{ types: { globals: [42] } }"#, ] { assert!(PackageConfig::parse(source, &base).is_err(), "{source}"); } } }