use std::cell::RefCell; use std::collections::{BTreeSet, HashMap, HashSet}; use std::io::Read; use std::path::{Component, Path, PathBuf}; use crate::authority::SourceAuthority; use crate::caps::PathTarget; use crate::package_config::check_jsx_option; use avril_core::Error; use oxc::transformer::{JsxOptions, JsxRuntime, TransformOptions}; use serde_json::{Map, Value}; use crate::local_packages::trusted; #[derive(Clone, Default)] pub(crate) struct JsxConfig(Map); impl JsxConfig { pub fn compiler_options(&self) -> Map { let mut options = self.0.clone(); options.entry("jsx").or_insert_with(|| "react-jsx".into()); options } pub fn preserves_jsx(&self) -> bool { matches!( self.0.get("jsx").and_then(Value::as_str), Some("preserve" | "react-native") ) } pub fn transform_options(&self) -> Result { if self.preserves_jsx() { return Err(Error::Module("avril cannot execute preserved JSX; set compilerOptions.jsx to react-jsx, react-jsxdev, or react".into())); } let string = |key| self.0.get(key).and_then(Value::as_str).map(str::to_owned); let mut options = TransformOptions { jsx: JsxOptions { runtime: if string("jsx").as_deref() == Some("react") { JsxRuntime::Classic } else { JsxRuntime::Automatic }, development: string("jsx").as_deref() == Some("react-jsxdev"), import_source: string("jsxImportSource"), pragma: string("jsxFactory"), pragma_frag: string("jsxFragmentFactory"), ..JsxOptions::default() }, ..TransformOptions::default() }; // TypeScript import elision must retain factories used only by JSX. if let Some(factory) = &options.jsx.pragma { options.typescript.jsx_pragma = factory.clone().into(); } if let Some(fragment) = &options.jsx.pragma_frag { options.typescript.jsx_pragma_frag = fragment.clone().into(); } Ok(options) } } /// The nearest applicable `tsconfig.json` overrides `defaults`, the owning /// package's `avril.conf` options. pub(crate) fn jsx_config( authority: &SourceAuthority, module: &Path, boundary: Option<&Path>, defaults: &Map, inputs: &RefCell>, cache: &RefCell>, ) -> Result { let mut directory = module.parent(); while let Some(path) = directory { let target = PathTarget::resolve(path).map_err(|error| Error::Module(error.to_string()))?; if target.as_path() != path || authority.check_local(&target).is_err() { break; } let handle = crate::sources::open_directory(path) .map_err(|error| Error::Module(error.to_string()))?; if !trusted( &handle .dir_metadata() .map_err(|error| Error::Module(error.to_string()))?, ) { break; } let package = PathTarget::for_entry(path.join("avril.conf")) .map_err(|error| Error::Module(error.to_string()))?; if authority.check_local(&package).is_err() { break; } let config = path.join("tsconfig.json"); let target = PathTarget::for_entry(&config).map_err(|error| Error::Module(error.to_string()))?; // Do not probe the existence of configs outside source authority. if authority.check_local(&target).is_err() { break; } inputs.borrow_mut().insert(config.clone()); match handle.symlink_metadata("tsconfig.json") { Ok(_) => { let found = ConfigLoader { authority, inputs, active: HashSet::new(), cache, } .load(&config, false)?; let mut options = defaults.clone(); options.extend(found.0); return Ok(JsxConfig(options)); } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(Error::Module(format!("{}: {error}", config.display()))), } if Some(path) == boundary { break; } directory = path.parent(); } Ok(JsxConfig(defaults.clone())) } struct ConfigLoader<'a> { authority: &'a SourceAuthority, inputs: &'a RefCell>, active: HashSet, cache: &'a RefCell>, } impl ConfigLoader<'_> { fn load(&mut self, path: &Path, append_json: bool) -> Result { let target = PathTarget::for_entry(path).map_err(|error| Error::Module(error.to_string()))?; self.authority .check_local(&target) .map_err(|error| Error::Module(error.to_string()))?; let path = target.as_path(); self.inputs.borrow_mut().insert(path.to_owned()); let error = |message: &str| Error::Module(format!("{}: {message}", path.display())); if self.active.len() >= 64 || self.cache.borrow().len() >= 512 { return Err(error("tsconfig extends graph is too large")); } if !self.active.insert(path.to_owned()) { return Err(error("Cyclic tsconfig extends")); } if let Some(config) = self.cache.borrow().get(path).cloned() { self.active.remove(path); return Ok(config); } let directory = path .parent() .ok_or_else(|| error("Expected a config directory"))?; let handle = crate::sources::open_directory(directory) .map_err(|_| error("Cannot open config directory"))?; if !trusted( &handle .dir_metadata() .map_err(|_| error("Cannot inspect config directory"))?, ) { return Err(error("Untrusted config directory")); } let name = path .file_name() .ok_or_else(|| error("Expected a config filename"))?; let metadata = match handle.symlink_metadata(name) { Ok(metadata) => metadata, Err(err) if append_json && path.extension().is_none_or(|ext| ext != "json") && err.kind() == std::io::ErrorKind::NotFound => { let mut name = name.to_os_string(); name.push(".json"); self.active.remove(path); return self.load(&path.with_file_name(name), false); } Err(_) => return Err(error("Cannot find tsconfig")), }; if !metadata.is_file() || metadata.is_symlink() { return Err(error("Config must be a regular non-symlink file")); } if !trusted(&metadata) { return Err(error("Untrusted config")); } let file = crate::sources::open_regular(&handle, name).map_err(|_| error("Cannot read config"))?; if !trusted( &file .metadata() .map_err(|_| error("Cannot inspect config"))?, ) { return Err(error("Untrusted config")); } let mut source = String::new(); file.take(32 * 1024 * 1024 + 1) .read_to_string(&mut source) .map_err(|_| error("Cannot read config"))?; if source.len() > 32 * 1024 * 1024 { return Err(error("Config is too large")); } self.authority .check_local(&target) .map_err(|error| Error::Module(error.to_string()))?; let value: Value = jsonc_parser::parse_to_serde_value( source.trim_start_matches('\u{feff}'), &jsonc_parser::ParseOptions { allow_comments: true, allow_trailing_commas: true, allow_loose_object_property_names: false, allow_missing_commas: false, allow_single_quoted_strings: false, allow_hexadecimal_numbers: false, allow_unary_plus_numbers: false, allow_bare_decimal_point_numbers: false, allow_non_finite_numbers: false, allow_extended_string_escapes: false, }, ) .map_err(|err| error(&err.to_string()))?; let object = value .as_object() .ok_or_else(|| error("tsconfig must be an object"))?; let mut config = JsxConfig::default(); if let Some(extends) = object.get("extends") { let bases = match extends { Value::String(_) => std::slice::from_ref(extends), Value::Array(bases) => bases.as_slice(), _ => return Err(error("extends must be a path or an array of paths")), }; for base in bases { let base = base .as_str() .ok_or_else(|| error("extends entries must be paths"))?; if !Path::new(base).is_absolute() && !matches!( Path::new(base).components().next(), Some(Component::CurDir | Component::ParentDir) ) { return Err(error( "extends must use relative or absolute file paths; package and URL configs are not supported", )); } config.0.extend(self.load(&directory.join(base), true)?.0); } } if let Some(options) = object.get("compilerOptions") { let options = options .as_object() .ok_or_else(|| error("compilerOptions must be an object"))?; for (key, value) in options { check_jsx_option(key, value).map_err(|message| error(&message))?; config.0.insert(key.to_owned(), value.clone()); } } self.active.remove(path); self.cache .borrow_mut() .insert(path.to_owned(), config.clone()); Ok(config) } }