diff options
Diffstat (limited to 'cli/args/config_file.rs')
-rw-r--r-- | cli/args/config_file.rs | 1102 |
1 files changed, 1102 insertions, 0 deletions
diff --git a/cli/args/config_file.rs b/cli/args/config_file.rs new file mode 100644 index 000000000..bed155d32 --- /dev/null +++ b/cli/args/config_file.rs @@ -0,0 +1,1102 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use crate::args::ConfigFlag; +use crate::args::Flags; +use crate::fs_util::canonicalize_path; +use crate::fs_util::specifier_parent; +use crate::fs_util::specifier_to_file_path; + +use deno_core::anyhow::anyhow; +use deno_core::anyhow::bail; +use deno_core::anyhow::Context; +use deno_core::error::custom_error; +use deno_core::error::AnyError; +use deno_core::normalize_path; +use deno_core::serde::Deserialize; +use deno_core::serde::Serialize; +use deno_core::serde::Serializer; +use deno_core::serde_json; +use deno_core::serde_json::json; +use deno_core::serde_json::Value; +use deno_core::ModuleSpecifier; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::collections::HashSet; +use std::fmt; +use std::path::Path; +use std::path::PathBuf; + +pub type MaybeImportsResult = + Result<Option<Vec<(ModuleSpecifier, Vec<String>)>>, AnyError>; + +/// The transpile options that are significant out of a user provided tsconfig +/// file, that we want to deserialize out of the final config for a transpile. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EmitConfigOptions { + pub check_js: bool, + pub emit_decorator_metadata: bool, + pub imports_not_used_as_values: String, + pub inline_source_map: bool, + pub inline_sources: bool, + pub source_map: bool, + pub jsx: String, + pub jsx_factory: String, + pub jsx_fragment_factory: String, + pub jsx_import_source: Option<String>, +} + +/// There are certain compiler options that can impact what modules are part of +/// a module graph, which need to be deserialized into a structure for analysis. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompilerOptions { + pub jsx: Option<String>, + pub jsx_import_source: Option<String>, + pub types: Option<Vec<String>>, +} + +/// A structure that represents a set of options that were ignored and the +/// path those options came from. +#[derive(Debug, Clone, PartialEq)] +pub struct IgnoredCompilerOptions { + pub items: Vec<String>, + pub maybe_specifier: Option<ModuleSpecifier>, +} + +impl fmt::Display for IgnoredCompilerOptions { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let mut codes = self.items.clone(); + codes.sort(); + if let Some(specifier) = &self.maybe_specifier { + write!(f, "Unsupported compiler options in \"{}\".\n The following options were ignored:\n {}", specifier, codes.join(", ")) + } else { + write!(f, "Unsupported compiler options provided.\n The following options were ignored:\n {}", codes.join(", ")) + } + } +} + +impl Serialize for IgnoredCompilerOptions { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + Serialize::serialize(&self.items, serializer) + } +} + +/// A static slice of all the compiler options that should be ignored that +/// either have no effect on the compilation or would cause the emit to not work +/// in Deno. +pub const IGNORED_COMPILER_OPTIONS: &[&str] = &[ + "allowSyntheticDefaultImports", + "allowUmdGlobalAccess", + "baseUrl", + "declaration", + "declarationMap", + "downlevelIteration", + "esModuleInterop", + "emitDeclarationOnly", + "importHelpers", + "inlineSourceMap", + "inlineSources", + "module", + "noEmitHelpers", + "noErrorTruncation", + "noLib", + "noResolve", + "outDir", + "paths", + "preserveConstEnums", + "reactNamespace", + "resolveJsonModule", + "rootDir", + "rootDirs", + "skipLibCheck", + "sourceMap", + "sourceRoot", + "target", + "useDefineForClassFields", +]; + +pub const IGNORED_RUNTIME_COMPILER_OPTIONS: &[&str] = &[ + "assumeChangesOnlyAffectDirectDependencies", + "build", + "charset", + "composite", + "diagnostics", + "disableSizeLimit", + "emitBOM", + "extendedDiagnostics", + "forceConsistentCasingInFileNames", + "generateCpuProfile", + "help", + "incremental", + "init", + "isolatedModules", + "listEmittedFiles", + "listFiles", + "mapRoot", + "maxNodeModuleJsDepth", + "moduleResolution", + "newLine", + "noEmit", + "noEmitOnError", + "out", + "outDir", + "outFile", + "preserveSymlinks", + "preserveWatchOutput", + "pretty", + "project", + "resolveJsonModule", + "showConfig", + "skipDefaultLibCheck", + "stripInternal", + "traceResolution", + "tsBuildInfoFile", + "typeRoots", + "useDefineForClassFields", + "version", + "watch", +]; + +/// Filenames that Deno will recognize when discovering config. +const CONFIG_FILE_NAMES: [&str; 2] = ["deno.json", "deno.jsonc"]; + +pub fn discover(flags: &Flags) -> Result<Option<ConfigFile>, AnyError> { + match &flags.config_flag { + ConfigFlag::Disabled => Ok(None), + ConfigFlag::Path(config_path) => Ok(Some(ConfigFile::read(config_path)?)), + ConfigFlag::Discover => { + if let Some(config_path_args) = flags.config_path_args() { + let mut checked = HashSet::new(); + for f in config_path_args { + if let Some(cf) = discover_from(&f, &mut checked)? { + return Ok(Some(cf)); + } + } + // From CWD walk up to root looking for deno.json or deno.jsonc + let cwd = std::env::current_dir()?; + discover_from(&cwd, &mut checked) + } else { + Ok(None) + } + } + } +} + +pub fn discover_from( + start: &Path, + checked: &mut HashSet<PathBuf>, +) -> Result<Option<ConfigFile>, AnyError> { + for ancestor in start.ancestors() { + if checked.insert(ancestor.to_path_buf()) { + for config_filename in CONFIG_FILE_NAMES { + let f = ancestor.join(config_filename); + match ConfigFile::read(f) { + Ok(cf) => { + return Ok(Some(cf)); + } + Err(e) => { + if let Some(ioerr) = e.downcast_ref::<std::io::Error>() { + use std::io::ErrorKind::*; + match ioerr.kind() { + InvalidInput | PermissionDenied | NotFound => { + // ok keep going + } + _ => { + return Err(e); // Unknown error. Stop. + } + } + } else { + return Err(e); // Parse error or something else. Stop. + } + } + } + } + } + } + // No config file found. + Ok(None) +} + +/// A function that works like JavaScript's `Object.assign()`. +pub fn json_merge(a: &mut Value, b: &Value) { + match (a, b) { + (&mut Value::Object(ref mut a), &Value::Object(ref b)) => { + for (k, v) in b { + json_merge(a.entry(k.clone()).or_insert(Value::Null), v); + } + } + (a, b) => { + *a = b.clone(); + } + } +} + +/// Based on an optional command line import map path and an optional +/// configuration file, return a resolved module specifier to an import map. +pub fn resolve_import_map_specifier( + maybe_import_map_path: Option<&str>, + maybe_config_file: Option<&ConfigFile>, +) -> Result<Option<ModuleSpecifier>, AnyError> { + if let Some(import_map_path) = maybe_import_map_path { + if let Some(config_file) = &maybe_config_file { + if config_file.to_import_map_path().is_some() { + log::warn!("{} the configuration file \"{}\" contains an entry for \"importMap\" that is being ignored.", crate::colors::yellow("Warning"), config_file.specifier); + } + } + let specifier = deno_core::resolve_url_or_path(import_map_path) + .context(format!("Bad URL (\"{}\") for import map.", import_map_path))?; + return Ok(Some(specifier)); + } else if let Some(config_file) = &maybe_config_file { + // when the import map is specifier in a config file, it needs to be + // resolved relative to the config file, versus the CWD like with the flag + // and with config files, we support both local and remote config files, + // so we have treat them differently. + if let Some(import_map_path) = config_file.to_import_map_path() { + let specifier = + // with local config files, it might be common to specify an import + // map like `"importMap": "import-map.json"`, which is resolvable if + // the file is resolved like a file path, so we will coerce the config + // file into a file path if possible and join the import map path to + // the file path. + if let Ok(config_file_path) = config_file.specifier.to_file_path() { + let import_map_file_path = normalize_path(config_file_path + .parent() + .ok_or_else(|| { + anyhow!("Bad config file specifier: {}", config_file.specifier) + })? + .join(&import_map_path)); + ModuleSpecifier::from_file_path(import_map_file_path).unwrap() + // otherwise if the config file is remote, we have no choice but to + // use "import resolution" with the config file as the base. + } else { + deno_core::resolve_import(&import_map_path, config_file.specifier.as_str()) + .context(format!( + "Bad URL (\"{}\") for import map.", + import_map_path + ))? + }; + return Ok(Some(specifier)); + } + } + Ok(None) +} + +fn parse_compiler_options( + compiler_options: &HashMap<String, Value>, + maybe_specifier: Option<ModuleSpecifier>, + is_runtime: bool, +) -> Result<(Value, Option<IgnoredCompilerOptions>), AnyError> { + let mut filtered: HashMap<String, Value> = HashMap::new(); + let mut items: Vec<String> = Vec::new(); + + for (key, value) in compiler_options.iter() { + let key = key.as_str(); + if (!is_runtime && IGNORED_COMPILER_OPTIONS.contains(&key)) + || IGNORED_RUNTIME_COMPILER_OPTIONS.contains(&key) + { + items.push(key.to_string()); + } else { + filtered.insert(key.to_string(), value.to_owned()); + } + } + let value = serde_json::to_value(filtered)?; + let maybe_ignored_options = if !items.is_empty() { + Some(IgnoredCompilerOptions { + items, + maybe_specifier, + }) + } else { + None + }; + + Ok((value, maybe_ignored_options)) +} + +/// A structure for managing the configuration of TypeScript +#[derive(Debug, Clone)] +pub struct TsConfig(pub Value); + +impl TsConfig { + /// Create a new `TsConfig` with the base being the `value` supplied. + pub fn new(value: Value) -> Self { + TsConfig(value) + } + + pub fn as_bytes(&self) -> Vec<u8> { + let map = self.0.as_object().unwrap(); + let ordered: BTreeMap<_, _> = map.iter().collect(); + let value = json!(ordered); + value.to_string().as_bytes().to_owned() + } + + /// Return the value of the `checkJs` compiler option, defaulting to `false` + /// if not present. + pub fn get_check_js(&self) -> bool { + if let Some(check_js) = self.0.get("checkJs") { + check_js.as_bool().unwrap_or(false) + } else { + false + } + } + + pub fn get_declaration(&self) -> bool { + if let Some(declaration) = self.0.get("declaration") { + declaration.as_bool().unwrap_or(false) + } else { + false + } + } + + /// Merge a serde_json value into the configuration. + pub fn merge(&mut self, value: &Value) { + json_merge(&mut self.0, value); + } + + /// Take an optional user provided config file + /// which was passed in via the `--config` flag and merge `compilerOptions` with + /// the configuration. Returning the result which optionally contains any + /// compiler options that were ignored. + pub fn merge_tsconfig_from_config_file( + &mut self, + maybe_config_file: Option<&ConfigFile>, + ) -> Result<Option<IgnoredCompilerOptions>, AnyError> { + if let Some(config_file) = maybe_config_file { + let (value, maybe_ignored_options) = config_file.to_compiler_options()?; + self.merge(&value); + Ok(maybe_ignored_options) + } else { + Ok(None) + } + } + + /// Take a map of compiler options, filtering out any that are ignored, then + /// merge it with the current configuration, returning any options that might + /// have been ignored. + pub fn merge_user_config( + &mut self, + user_options: &HashMap<String, Value>, + ) -> Result<Option<IgnoredCompilerOptions>, AnyError> { + let (value, maybe_ignored_options) = + parse_compiler_options(user_options, None, true)?; + self.merge(&value); + Ok(maybe_ignored_options) + } +} + +impl Serialize for TsConfig { + /// Serializes inner hash map which is ordered by the key + fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> + where + S: Serializer, + { + Serialize::serialize(&self.0, serializer) + } +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct LintRulesConfig { + pub tags: Option<Vec<String>>, + pub include: Option<Vec<String>>, + pub exclude: Option<Vec<String>>, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct SerializedFilesConfig { + pub include: Vec<String>, + pub exclude: Vec<String>, +} + +impl SerializedFilesConfig { + pub fn into_resolved( + self, + config_file_specifier: &ModuleSpecifier, + ) -> Result<FilesConfig, AnyError> { + let config_dir = specifier_parent(config_file_specifier); + Ok(FilesConfig { + include: self + .include + .into_iter() + .map(|p| config_dir.join(&p)) + .collect::<Result<Vec<ModuleSpecifier>, _>>()?, + exclude: self + .exclude + .into_iter() + .map(|p| config_dir.join(&p)) + .collect::<Result<Vec<ModuleSpecifier>, _>>()?, + }) + } +} + +#[derive(Clone, Debug, Default)] +pub struct FilesConfig { + pub include: Vec<ModuleSpecifier>, + pub exclude: Vec<ModuleSpecifier>, +} + +impl FilesConfig { + /// Gets if the provided specifier is allowed based on the includes + /// and excludes in the configuration file. + pub fn matches_specifier(&self, specifier: &ModuleSpecifier) -> bool { + // Skip files which is in the exclude list. + let specifier_text = specifier.as_str(); + if self + .exclude + .iter() + .any(|i| specifier_text.starts_with(i.as_str())) + { + return false; + } + + // Ignore files not in the include list if it's not empty. + self.include.is_empty() + || self + .include + .iter() + .any(|i| specifier_text.starts_with(i.as_str())) + } +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct SerializedLintConfig { + pub rules: LintRulesConfig, + pub files: SerializedFilesConfig, +} + +impl SerializedLintConfig { + pub fn into_resolved( + self, + config_file_specifier: &ModuleSpecifier, + ) -> Result<LintConfig, AnyError> { + Ok(LintConfig { + rules: self.rules, + files: self.files.into_resolved(config_file_specifier)?, + }) + } +} + +#[derive(Clone, Debug, Default)] +pub struct LintConfig { + pub rules: LintRulesConfig, + pub files: FilesConfig, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub enum ProseWrap { + Always, + Never, + Preserve, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +pub struct FmtOptionsConfig { + pub use_tabs: Option<bool>, + pub line_width: Option<u32>, + pub indent_width: Option<u8>, + pub single_quote: Option<bool>, + pub prose_wrap: Option<ProseWrap>, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct SerializedFmtConfig { + pub options: FmtOptionsConfig, + pub files: SerializedFilesConfig, +} + +impl SerializedFmtConfig { + pub fn into_resolved( + self, + config_file_specifier: &ModuleSpecifier, + ) -> Result<FmtConfig, AnyError> { + Ok(FmtConfig { + options: self.options, + files: self.files.into_resolved(config_file_specifier)?, + }) + } +} + +#[derive(Clone, Debug, Default)] +pub struct FmtConfig { + pub options: FmtOptionsConfig, + pub files: FilesConfig, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigFileJson { + pub compiler_options: Option<Value>, + pub import_map: Option<String>, + pub lint: Option<Value>, + pub fmt: Option<Value>, + pub tasks: Option<Value>, +} + +#[derive(Clone, Debug)] +pub struct ConfigFile { + pub specifier: ModuleSpecifier, + pub json: ConfigFileJson, +} + +impl ConfigFile { + pub fn read(path_ref: impl AsRef<Path>) -> Result<Self, AnyError> { + let path = Path::new(path_ref.as_ref()); + let config_file = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path_ref) + }; + + let config_path = canonicalize_path(&config_file).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Could not find the config file: {}", + config_file.to_string_lossy() + ), + ) + })?; + let config_specifier = ModuleSpecifier::from_file_path(&config_path) + .map_err(|_| { + anyhow!( + "Could not convert path to specifier. Path: {}", + config_path.display() + ) + })?; + Self::from_specifier(&config_specifier) + } + + pub fn from_specifier(specifier: &ModuleSpecifier) -> Result<Self, AnyError> { + let config_path = specifier_to_file_path(specifier)?; + let config_text = match std::fs::read_to_string(&config_path) { + Ok(text) => text, + Err(err) => bail!( + "Error reading config file {}: {}", + specifier, + err.to_string() + ), + }; + Self::new(&config_text, specifier) + } + + pub fn new( + text: &str, + specifier: &ModuleSpecifier, + ) -> Result<Self, AnyError> { + let jsonc = match jsonc_parser::parse_to_serde_value(text) { + Ok(None) => json!({}), + Ok(Some(value)) if value.is_object() => value, + Ok(Some(_)) => { + return Err(anyhow!( + "config file JSON {:?} should be an object", + specifier, + )) + } + Err(e) => { + return Err(anyhow!( + "Unable to parse config file JSON {:?} because of {}", + specifier, + e.to_string() + )) + } + }; + let json: ConfigFileJson = serde_json::from_value(jsonc)?; + + Ok(Self { + specifier: specifier.to_owned(), + json, + }) + } + + /// Returns true if the configuration indicates that JavaScript should be + /// type checked, otherwise false. + pub fn get_check_js(&self) -> bool { + self + .json + .compiler_options + .as_ref() + .and_then(|co| co.get("checkJs").and_then(|v| v.as_bool())) + .unwrap_or(false) + } + + /// Parse `compilerOptions` and return a serde `Value`. + /// The result also contains any options that were ignored. + pub fn to_compiler_options( + &self, + ) -> Result<(Value, Option<IgnoredCompilerOptions>), AnyError> { + if let Some(compiler_options) = self.json.compiler_options.clone() { + let options: HashMap<String, Value> = + serde_json::from_value(compiler_options) + .context("compilerOptions should be an object")?; + parse_compiler_options(&options, Some(self.specifier.to_owned()), false) + } else { + Ok((json!({}), None)) + } + } + + pub fn to_import_map_path(&self) -> Option<String> { + self.json.import_map.clone() + } + + pub fn to_lint_config(&self) -> Result<Option<LintConfig>, AnyError> { + if let Some(config) = self.json.lint.clone() { + let lint_config: SerializedLintConfig = serde_json::from_value(config) + .context("Failed to parse \"lint\" configuration")?; + Ok(Some(lint_config.into_resolved(&self.specifier)?)) + } else { + Ok(None) + } + } + + /// Return any tasks that are defined in the configuration file as a sequence + /// of JSON objects providing the name of the task and the arguments of the + /// task in a detail field. + pub fn to_lsp_tasks(&self) -> Option<Value> { + let value = self.json.tasks.clone()?; + let tasks: BTreeMap<String, String> = serde_json::from_value(value).ok()?; + Some( + tasks + .into_iter() + .map(|(key, value)| { + json!({ + "name": key, + "detail": value, + }) + }) + .collect(), + ) + } + + pub fn to_tasks_config( + &self, + ) -> Result<Option<BTreeMap<String, String>>, AnyError> { + if let Some(config) = self.json.tasks.clone() { + let tasks_config: BTreeMap<String, String> = + serde_json::from_value(config) + .context("Failed to parse \"tasks\" configuration")?; + Ok(Some(tasks_config)) + } else { + Ok(None) + } + } + + /// If the configuration file contains "extra" modules (like TypeScript + /// `"types"`) options, return them as imports to be added to a module graph. + pub fn to_maybe_imports(&self) -> MaybeImportsResult { + let mut imports = Vec::new(); + let compiler_options_value = + if let Some(value) = self.json.compiler_options.as_ref() { + value + } else { + return Ok(None); + }; + let compiler_options: CompilerOptions = + serde_json::from_value(compiler_options_value.clone())?; + if let Some(types) = compiler_options.types { + imports.extend(types); + } + if compiler_options.jsx == Some("react-jsx".to_string()) { + imports.push(format!( + "{}/jsx-runtime", + compiler_options.jsx_import_source.ok_or_else(|| custom_error("TypeError", "Compiler option 'jsx' set to 'react-jsx', but no 'jsxImportSource' defined."))? + )); + } else if compiler_options.jsx == Some("react-jsxdev".to_string()) { + imports.push(format!( + "{}/jsx-dev-runtime", + compiler_options.jsx_import_source.ok_or_else(|| custom_error("TypeError", "Compiler option 'jsx' set to 'react-jsxdev', but no 'jsxImportSource' defined."))? + )); + } + if !imports.is_empty() { + let referrer = self.specifier.clone(); + Ok(Some(vec![(referrer, imports)])) + } else { + Ok(None) + } + } + + /// Based on the compiler options in the configuration file, return the + /// implied JSX import source module. + pub fn to_maybe_jsx_import_source_module(&self) -> Option<String> { + let compiler_options_value = self.json.compiler_options.as_ref()?; + let compiler_options: CompilerOptions = + serde_json::from_value(compiler_options_value.clone()).ok()?; + match compiler_options.jsx.as_deref() { + Some("react-jsx") => Some("jsx-runtime".to_string()), + Some("react-jsxdev") => Some("jsx-dev-runtime".to_string()), + _ => None, + } + } + + pub fn to_fmt_config(&self) -> Result<Option<FmtConfig>, AnyError> { + if let Some(config) = self.json.fmt.clone() { + let fmt_config: SerializedFmtConfig = serde_json::from_value(config) + .context("Failed to parse \"fmt\" configuration")?; + Ok(Some(fmt_config.into_resolved(&self.specifier)?)) + } else { + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use deno_core::serde_json::json; + + #[test] + fn read_config_file_relative() { + let config_file = + ConfigFile::read("tests/testdata/module_graph/tsconfig.json") + .expect("Failed to load config file"); + assert!(config_file.json.compiler_options.is_some()); + } + + #[test] + fn read_config_file_absolute() { + let path = test_util::testdata_path().join("module_graph/tsconfig.json"); + let config_file = ConfigFile::read(path.to_str().unwrap()) + .expect("Failed to load config file"); + assert!(config_file.json.compiler_options.is_some()); + } + + #[test] + fn include_config_path_on_error() { + let error = ConfigFile::read("404.json").err().unwrap(); + assert!(error.to_string().contains("404.json")); + } + + #[test] + fn test_json_merge() { + let mut value_a = json!({ + "a": true, + "b": "c" + }); + let value_b = json!({ + "b": "d", + "e": false, + }); + json_merge(&mut value_a, &value_b); + assert_eq!( + value_a, + json!({ + "a": true, + "b": "d", + "e": false, + }) + ); + } + + #[test] + fn test_parse_config() { + let config_text = r#"{ + "compilerOptions": { + "build": true, + // comments are allowed + "strict": true + }, + "lint": { + "files": { + "include": ["src/"], + "exclude": ["src/testdata/"] + }, + "rules": { + "tags": ["recommended"], + "include": ["ban-untagged-todo"] + } + }, + "fmt": { + "files": { + "include": ["src/"], + "exclude": ["src/testdata/"] + }, + "options": { + "useTabs": true, + "lineWidth": 80, + "indentWidth": 4, + "singleQuote": true, + "proseWrap": "preserve" + } + }, + "tasks": { + "build": "deno run --allow-read --allow-write build.ts", + "server": "deno run --allow-net --allow-read server.ts" + } + }"#; + let config_dir = ModuleSpecifier::parse("file:///deno/").unwrap(); + let config_specifier = config_dir.join("tsconfig.json").unwrap(); + let config_file = ConfigFile::new(config_text, &config_specifier).unwrap(); + let (options_value, ignored) = + config_file.to_compiler_options().expect("error parsing"); + assert!(options_value.is_object()); + let options = options_value.as_object().unwrap(); + assert!(options.contains_key("strict")); + assert_eq!(options.len(), 1); + assert_eq!( + ignored, + Some(IgnoredCompilerOptions { + items: vec!["build".to_string()], + maybe_specifier: Some(config_specifier), + }), + ); + + let lint_config = config_file + .to_lint_config() + .expect("error parsing lint object") + .expect("lint object should be defined"); + assert_eq!( + lint_config.files.include, + vec![config_dir.join("src/").unwrap()] + ); + assert_eq!( + lint_config.files.exclude, + vec![config_dir.join("src/testdata/").unwrap()] + ); + assert_eq!( + lint_config.rules.include, + Some(vec!["ban-untagged-todo".to_string()]) + ); + assert_eq!( + lint_config.rules.tags, + Some(vec!["recommended".to_string()]) + ); + assert!(lint_config.rules.exclude.is_none()); + + let fmt_config = config_file + .to_fmt_config() + .expect("error parsing fmt object") + .expect("fmt object should be defined"); + assert_eq!( + fmt_config.files.include, + vec![config_dir.join("src/").unwrap()] + ); + assert_eq!( + fmt_config.files.exclude, + vec![config_dir.join("src/testdata/").unwrap()] + ); + assert_eq!(fmt_config.options.use_tabs, Some(true)); + assert_eq!(fmt_config.options.line_width, Some(80)); + assert_eq!(fmt_config.options.indent_width, Some(4)); + assert_eq!(fmt_config.options.single_quote, Some(true)); + + let tasks_config = config_file.to_tasks_config().unwrap().unwrap(); + assert_eq!( + tasks_config["build"], + "deno run --allow-read --allow-write build.ts", + ); + assert_eq!( + tasks_config["server"], + "deno run --allow-net --allow-read server.ts" + ); + } + + #[test] + fn test_parse_config_with_empty_file() { + let config_text = ""; + let config_specifier = + ModuleSpecifier::parse("file:///deno/tsconfig.json").unwrap(); + let config_file = ConfigFile::new(config_text, &config_specifier).unwrap(); + let (options_value, _) = + config_file.to_compiler_options().expect("error parsing"); + assert!(options_value.is_object()); + } + + #[test] + fn test_parse_config_with_commented_file() { + let config_text = r#"//{"foo":"bar"}"#; + let config_specifier = + ModuleSpecifier::parse("file:///deno/tsconfig.json").unwrap(); + let config_file = ConfigFile::new(config_text, &config_specifier).unwrap(); + let (options_value, _) = + config_file.to_compiler_options().expect("error parsing"); + assert!(options_value.is_object()); + } + + #[test] + fn test_parse_config_with_invalid_file() { + let config_text = "{foo:bar}"; + let config_specifier = + ModuleSpecifier::parse("file:///deno/tsconfig.json").unwrap(); + // Emit error: Unable to parse config file JSON "<config_path>" because of Unexpected token on line 1 column 6. + assert!(ConfigFile::new(config_text, &config_specifier).is_err()); + } + + #[test] + fn test_parse_config_with_not_object_file() { + let config_text = "[]"; + let config_specifier = + ModuleSpecifier::parse("file:///deno/tsconfig.json").unwrap(); + // Emit error: config file JSON "<config_path>" should be an object + assert!(ConfigFile::new(config_text, &config_specifier).is_err()); + } + + #[test] + fn test_tsconfig_merge_user_options() { + let mut tsconfig = TsConfig::new(json!({ + "target": "esnext", + "module": "esnext", + })); + let user_options = serde_json::from_value(json!({ + "target": "es6", + "build": true, + "strict": false, + })) + .expect("could not convert to hashmap"); + let maybe_ignored_options = tsconfig + .merge_user_config(&user_options) + .expect("could not merge options"); + assert_eq!( + tsconfig.0, + json!({ + "module": "esnext", + "target": "es6", + "strict": false, + }) + ); + assert_eq!( + maybe_ignored_options, + Some(IgnoredCompilerOptions { + items: vec!["build".to_string()], + maybe_specifier: None + }) + ); + } + + #[test] + fn test_tsconfig_as_bytes() { + let mut tsconfig1 = TsConfig::new(json!({ + "strict": true, + "target": "esnext", + })); + tsconfig1.merge(&json!({ + "target": "es5", + "module": "amd", + })); + let mut tsconfig2 = TsConfig::new(json!({ + "target": "esnext", + "strict": true, + })); + tsconfig2.merge(&json!({ + "module": "amd", + "target": "es5", + })); + assert_eq!(tsconfig1.as_bytes(), tsconfig2.as_bytes()); + } + + #[test] + fn discover_from_success() { + // testdata/fmt/deno.jsonc exists + let testdata = test_util::testdata_path(); + let c_md = testdata.join("fmt/with_config/subdir/c.md"); + let mut checked = HashSet::new(); + let config_file = discover_from(&c_md, &mut checked).unwrap().unwrap(); + assert!(checked.contains(c_md.parent().unwrap())); + assert!(!checked.contains(&testdata)); + let fmt_config = config_file.to_fmt_config().unwrap().unwrap(); + let expected_exclude = ModuleSpecifier::from_file_path( + testdata.join("fmt/with_config/subdir/b.ts"), + ) + .unwrap(); + assert_eq!(fmt_config.files.exclude, vec![expected_exclude]); + + // Now add all ancestors of testdata to checked. + for a in testdata.ancestors() { + checked.insert(a.to_path_buf()); + } + + // If we call discover_from again starting at testdata, we ought to get None. + assert!(discover_from(&testdata, &mut checked).unwrap().is_none()); + } + + #[test] + fn discover_from_malformed() { + let testdata = test_util::testdata_path(); + let d = testdata.join("malformed_config/"); + let mut checked = HashSet::new(); + let err = discover_from(&d, &mut checked).unwrap_err(); + assert!(err.to_string().contains("Unable to parse config file")); + } + + #[cfg(not(windows))] + #[test] + fn resolve_import_map_config_file() { + let config_text = r#"{ + "importMap": "import_map.json" + }"#; + let config_specifier = + ModuleSpecifier::parse("file:///deno/deno.jsonc").unwrap(); + let config_file = ConfigFile::new(config_text, &config_specifier).unwrap(); + let actual = resolve_import_map_specifier(None, Some(&config_file)); + assert!(actual.is_ok()); + let actual = actual.unwrap(); + assert_eq!( + actual, + Some(ModuleSpecifier::parse("file:///deno/import_map.json").unwrap()) + ); + } + + #[test] + fn resolve_import_map_config_file_remote() { + let config_text = r#"{ + "importMap": "./import_map.json" + }"#; + let config_specifier = + ModuleSpecifier::parse("https://example.com/deno.jsonc").unwrap(); + let config_file = ConfigFile::new(config_text, &config_specifier).unwrap(); + let actual = resolve_import_map_specifier(None, Some(&config_file)); + assert!(actual.is_ok()); + let actual = actual.unwrap(); + assert_eq!( + actual, + Some( + ModuleSpecifier::parse("https://example.com/import_map.json").unwrap() + ) + ); + } + + #[test] + fn resolve_import_map_flags_take_precedence() { + let config_text = r#"{ + "importMap": "import_map.json" + }"#; + let config_specifier = + ModuleSpecifier::parse("file:///deno/deno.jsonc").unwrap(); + let config_file = ConfigFile::new(config_text, &config_specifier).unwrap(); + let actual = + resolve_import_map_specifier(Some("import-map.json"), Some(&config_file)); + let import_map_path = + std::env::current_dir().unwrap().join("import-map.json"); + let expected_specifier = + ModuleSpecifier::from_file_path(&import_map_path).unwrap(); + assert!(actual.is_ok()); + let actual = actual.unwrap(); + assert_eq!(actual, Some(expected_specifier)); + } + + #[test] + fn resolve_import_map_none() { + let config_text = r#"{}"#; + let config_specifier = + ModuleSpecifier::parse("file:///deno/deno.jsonc").unwrap(); + let config_file = ConfigFile::new(config_text, &config_specifier).unwrap(); + let actual = resolve_import_map_specifier(None, Some(&config_file)); + assert!(actual.is_ok()); + let actual = actual.unwrap(); + assert_eq!(actual, None); + } + + #[test] + fn resolve_import_map_no_config() { + let actual = resolve_import_map_specifier(None, None); + assert!(actual.is_ok()); + let actual = actual.unwrap(); + assert_eq!(actual, None); + } +} |