use std::env::home_dir;
use std::fmt::Debug;
use std::path::Path;
use std::path::PathBuf;
use config::Case;
use config::Config;
use config::Environment;
use config::File;
use config::FileFormat;
use config::FileStoredFormat;
use config::Value;
use config::ValueKind;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use mago_php_version::PHPVersion;
use crate::config::analyzer::AnalyzerConfiguration;
use crate::config::formatter::FormatterConfiguration;
use crate::config::guard::GuardConfiguration;
use crate::config::linter::LinterConfiguration;
use crate::config::parser::ParserConfiguration;
use crate::config::source::SourceConfiguration;
use crate::consts::*;
use crate::error::Error;
pub mod analyzer;
pub mod formatter;
pub mod guard;
pub mod linter;
pub mod parser;
pub mod source;
fn default_threads() -> usize {
*LOGICAL_CPUS
}
fn default_stack_size() -> usize {
DEFAULT_STACK_SIZE
}
fn default_php_version() -> PHPVersion {
DEFAULT_PHP_VERSION
}
fn default_source_configuration() -> SourceConfiguration {
SourceConfiguration::from_workspace(CURRENT_DIR.clone())
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct Configuration {
#[serde(default)]
pub version: Option<String>,
#[serde(default = "default_threads")]
pub threads: usize,
#[serde(default = "default_stack_size")]
pub stack_size: usize,
#[serde(default = "default_php_version")]
pub php_version: PHPVersion,
#[serde(default)]
pub allow_unsupported_php_version: bool,
#[serde(default)]
pub no_version_check: bool,
#[serde(default = "default_source_configuration")]
pub source: SourceConfiguration,
#[serde(default)]
pub linter: LinterConfiguration,
#[serde(default)]
pub parser: ParserConfiguration,
#[serde(default)]
pub formatter: FormatterConfiguration,
#[serde(default)]
pub analyzer: AnalyzerConfiguration,
#[serde(default)]
pub guard: GuardConfiguration,
#[serde(default, skip_serializing)]
#[schemars(skip)]
#[allow(dead_code)]
log: Value,
#[serde(default)]
pub editor_url: Option<String>,
#[serde(default, skip_serializing)]
#[schemars(skip)]
pub config_file: Option<PathBuf>,
#[serde(default, skip_serializing)]
#[schemars(skip)]
pub config_file_is_explicit: bool,
}
impl Configuration {
pub fn load(
workspace: Option<PathBuf>,
file: Option<&Path>,
php_version: Option<PHPVersion>,
threads: Option<usize>,
allow_unsupported_php_version: bool,
no_version_check: bool,
) -> Result<Configuration, Error> {
let workspace_dir = workspace.clone().unwrap_or_else(|| CURRENT_DIR.to_path_buf());
let mut builder = Config::builder();
let resolved_config_file;
let config_file_is_explicit;
if let Some(file) = file {
tracing::debug!("Sourcing configuration from {}.", file.display());
resolved_config_file = Some(file.to_path_buf());
config_file_is_explicit = true;
builder = builder.add_source(File::from(file).required(true));
} else {
let formats = [FileFormat::Toml, FileFormat::Yaml, FileFormat::Json];
let fallback_roots = [
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from),
home_dir().map(|h| h.join(".config")),
home_dir(),
];
if let Some((config_file, format)) = Self::find_config_files(&workspace_dir, &fallback_roots, &formats) {
tracing::debug!("Sourcing configuration from {}.", config_file.display());
resolved_config_file = Some(config_file.clone());
builder = builder.add_source(File::from(config_file).format(format).required(false));
} else {
tracing::debug!("No configuration file found, using defaults and environment variables.");
resolved_config_file = None;
}
config_file_is_explicit = false;
}
let mut configuration: Configuration = builder
.add_source(Environment::with_prefix(ENVIRONMENT_PREFIX).convert_case(Case::Kebab))
.build()?
.try_deserialize::<Configuration>()?;
configuration.config_file = resolved_config_file;
configuration.config_file_is_explicit = config_file_is_explicit;
if allow_unsupported_php_version && !configuration.allow_unsupported_php_version {
tracing::warn!("Allowing unsupported PHP versions.");
configuration.allow_unsupported_php_version = true;
}
if no_version_check && !configuration.no_version_check {
tracing::info!("Silencing project version drift warning.");
configuration.no_version_check = true;
}
if let Some(php_version) = php_version {
tracing::info!("Overriding PHP version with {}.", php_version);
configuration.php_version = php_version;
}
if let Some(threads) = threads {
tracing::info!("Overriding thread count with {}.", threads);
configuration.threads = threads;
}
if let Some(workspace) = workspace {
tracing::info!("Overriding workspace directory with {}.", workspace.display());
configuration.source.workspace = workspace;
}
if configuration.editor_url.is_none() {
configuration.editor_url = detect_editor_url();
}
configuration.normalize()?;
Ok(configuration)
}
fn find_config_files(
root_dir: &Path,
fallback_roots: &[Option<PathBuf>],
file_formats: &[FileFormat],
) -> Option<(PathBuf, FileFormat)> {
let config_files = [CONFIGURATION_FILE_NAME, CONFIGURATION_DIST_FILE_NAME];
for name in config_files.iter() {
let base = root_dir.join(name);
for format in file_formats.iter() {
if let Some(ext) = format.file_extensions().iter().find(|ext| base.with_added_extension(ext).exists()) {
return Some((base.with_added_extension(ext), *format));
}
}
}
for root in fallback_roots.iter().flatten() {
let base = root.join(CONFIGURATION_FILE_NAME);
for format in file_formats.iter() {
if let Some(ext) = format.file_extensions().iter().find(|ext| base.with_added_extension(ext).exists()) {
return Some((base.with_added_extension(ext), *format));
}
}
}
None
}
pub fn from_workspace(workspace: PathBuf) -> Self {
Self {
version: None,
threads: *LOGICAL_CPUS,
stack_size: DEFAULT_STACK_SIZE,
php_version: DEFAULT_PHP_VERSION,
allow_unsupported_php_version: false,
no_version_check: false,
source: SourceConfiguration::from_workspace(workspace),
linter: LinterConfiguration::default(),
parser: ParserConfiguration::default(),
formatter: FormatterConfiguration::default(),
analyzer: AnalyzerConfiguration::default(),
guard: GuardConfiguration::default(),
log: Value::new(None, ValueKind::Nil),
editor_url: None,
config_file: None,
config_file_is_explicit: false,
}
}
}
impl Configuration {
#[must_use]
pub fn to_filtered_value(&self) -> serde_json::Value {
serde_json::json!({
"version": self.version,
"threads": self.threads,
"stack-size": self.stack_size,
"php-version": self.php_version,
"allow-unsupported-php-version": self.allow_unsupported_php_version,
"no-version-check": self.no_version_check,
"source": self.source,
"linter": self.linter.to_filtered_value(self.php_version),
"parser": self.parser,
"formatter": self.formatter.to_value(),
"analyzer": self.analyzer,
"guard": self.guard,
})
}
fn normalize(&mut self) -> Result<(), Error> {
match self.threads {
0 => {
tracing::info!("Thread configuration is zero, using the number of logical CPUs: {}.", *LOGICAL_CPUS);
self.threads = *LOGICAL_CPUS;
}
_ => {
tracing::debug!("Configuration specifies {} threads.", self.threads);
}
}
match self.stack_size {
0 => {
tracing::info!(
"Stack size configuration is zero, using the maximum size of {} bytes.",
MAXIMUM_STACK_SIZE
);
self.stack_size = MAXIMUM_STACK_SIZE;
}
s if s > MAXIMUM_STACK_SIZE => {
tracing::warn!(
"Stack size configuration is too large, reducing to maximum size of {} bytes.",
MAXIMUM_STACK_SIZE
);
self.stack_size = MAXIMUM_STACK_SIZE;
}
s if s < MINIMUM_STACK_SIZE => {
tracing::warn!(
"Stack size configuration is too small, increasing to minimum size of {} bytes.",
MINIMUM_STACK_SIZE
);
self.stack_size = MINIMUM_STACK_SIZE;
}
_ => {
tracing::debug!("Configuration specifies a stack size of {} bytes.", self.stack_size);
}
}
self.source.normalize()?;
if let Some(b) = self.analyzer.baseline.take() {
self.analyzer.baseline = Some(if b.is_relative() { self.source.workspace.join(&b) } else { b });
}
if let Some(b) = self.linter.baseline.take() {
self.linter.baseline = Some(if b.is_relative() { self.source.workspace.join(&b) } else { b });
}
if let Some(b) = self.guard.baseline.take() {
self.guard.baseline = Some(if b.is_relative() { self.source.workspace.join(&b) } else { b });
}
Ok(())
}
}
#[cfg(all(test, not(target_os = "windows")))]
mod tests {
use core::str;
use std::fs;
use pretty_assertions::assert_eq;
use tempfile::env::temp_dir;
use super::*;
#[test]
fn test_take_defaults() {
let workspace_path = temp_dir().join("workspace-0");
std::fs::create_dir_all(&workspace_path).unwrap();
let config = temp_env::with_vars(
[
("HOME", temp_dir().to_str()),
("MAGO_THREADS", None),
("MAGO_PHP_VERSION", None),
("MAGO_ALLOW_UNSUPPORTED_PHP_VERSION", None),
],
|| Configuration::load(Some(workspace_path), None, None, None, false, false).unwrap(),
);
assert_eq!(config.threads, *LOGICAL_CPUS)
}
#[test]
fn test_toml_has_precedence_when_multiple_configs_present() {
let workspace_path = temp_dir().join("workspace-with-multiple-configs");
std::fs::create_dir_all(&workspace_path).unwrap();
create_tmp_file("threads = 3", &workspace_path, "toml");
create_tmp_file("threads: 2\nphp-version: \"7.4.0\"", &workspace_path, "yaml");
create_tmp_file("{\"threads\": 1,\"php-version\":\"8.1.0\"}", &workspace_path, "json");
let config = Configuration::load(Some(workspace_path), None, None, None, false, false).unwrap();
assert_eq!(config.threads, 3);
assert_eq!(config.php_version.to_string(), DEFAULT_PHP_VERSION.to_string())
}
#[test]
fn test_env_config_override_all_others() {
let workspace_path = temp_dir().join("workspace-1");
let config_path = temp_dir().join("config-1");
std::fs::create_dir_all(&workspace_path).unwrap();
std::fs::create_dir_all(&config_path).unwrap();
let config_file_path = create_tmp_file("threads = 1", &config_path, "toml");
create_tmp_file("threads = 2", &workspace_path, "toml");
let config = temp_env::with_vars(
[
("HOME", None),
("MAGO_THREADS", Some("3")),
("MAGO_PHP_VERSION", None),
("MAGO_ALLOW_UNSUPPORTED_PHP_VERSION", None),
],
|| Configuration::load(Some(workspace_path), Some(&config_file_path), None, None, false, false).unwrap(),
);
assert_eq!(config.threads, 3);
}
#[test]
fn test_config_cancel_workspace() {
let workspace_path = temp_dir().join("workspace-2");
let config_path = temp_dir().join("config-2");
std::fs::create_dir_all(&workspace_path).unwrap();
std::fs::create_dir_all(&config_path).unwrap();
create_tmp_file("threads = 2\nphp-version = \"7.4.0\"", &workspace_path, "toml");
let config_file_path = create_tmp_file("threads = 1", &config_path, "toml");
let config = temp_env::with_vars(
[
("HOME", None::<&str>),
("MAGO_THREADS", None),
("MAGO_PHP_VERSION", None),
("MAGO_ALLOW_UNSUPPORTED_PHP_VERSION", None),
],
|| Configuration::load(Some(workspace_path), Some(&config_file_path), None, None, false, false).unwrap(),
);
assert_eq!(config.threads, 1);
assert_eq!(config.php_version.to_string(), DEFAULT_PHP_VERSION.to_string());
}
#[test]
fn test_workspace_has_precedence_over_global() {
let home_path = temp_dir().join("home-3");
let xdg_config_home_path = temp_dir().join("xdg-config-home-3");
let workspace_path = temp_dir().join("workspace-3");
let _ = std::fs::remove_dir_all(&home_path);
let _ = std::fs::remove_dir_all(&xdg_config_home_path);
let _ = std::fs::remove_dir_all(&workspace_path);
std::fs::create_dir_all(&home_path).unwrap();
std::fs::create_dir_all(&xdg_config_home_path).unwrap();
std::fs::create_dir_all(&workspace_path).unwrap();
create_tmp_file("threads: 2\nphp-version: \"8.1.0\"", &workspace_path.to_owned(), "yaml");
create_tmp_file("threads = 3\nphp-version = \"7.4.0\"", &home_path, "toml");
create_tmp_file("source.excludes = [\"yes\"]", &xdg_config_home_path, "toml");
let config = temp_env::with_vars(
[
("HOME", Some(home_path)),
("XDG_CONFIG_HOME", Some(xdg_config_home_path)),
("MAGO_THREADS", None),
("MAGO_PHP_VERSION", None),
("MAGO_ALLOW_UNSUPPORTED_PHP_VERSION", None),
],
|| Configuration::load(Some(workspace_path.clone()), None, None, None, false, false).unwrap(),
);
assert_eq!(config.threads, 2);
assert_eq!(config.php_version.to_string(), "8.1.0".to_string());
assert_eq!(config.source.excludes, Vec::<String>::new());
}
fn create_tmp_file(config_content: &str, folder: &PathBuf, extension: &str) -> PathBuf {
fs::create_dir_all(folder).unwrap();
let config_path = folder.join(CONFIGURATION_FILE_NAME).with_extension(extension);
fs::write(&config_path, config_content).unwrap();
config_path
}
}
fn detect_editor_url() -> Option<String> {
if let Ok(bundle_id) = std::env::var("__CFBundleIdentifier") {
let url = match bundle_id.as_str() {
"com.jetbrains.PhpStorm" | "com.jetbrains.PhpStorm-EAP" => {
"phpstorm://open?file=%file%&line=%line%&column=%column%"
}
"com.jetbrains.intellij" | "com.jetbrains.intellij.ce" => {
"idea://open?file=%file%&line=%line%&column=%column%"
}
"com.jetbrains.WebStorm" | "com.jetbrains.WebStorm-EAP" => {
"webstorm://open?file=%file%&line=%line%&column=%column%"
}
"dev.zed.Zed" | "dev.zed.Zed-Preview" => "zed://file/%file%:%line%:%column%",
"com.microsoft.VSCode" => "vscode://file/%file%:%line%:%column%",
"com.microsoft.VSCodeInsiders" => "vscode-insiders://file/%file%:%line%:%column%",
"com.sublimetext.4" | "com.sublimetext.3" => "subl://open?url=file://%file%&line=%line%&column=%column%",
_ => return None,
};
tracing::debug!("Auto-detected editor URL from __CFBundleIdentifier={bundle_id}");
return Some(url.to_string());
}
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
let url = match term_program.as_str() {
"vscode" => "vscode://file/%file%:%line%:%column%",
"zed" => "zed://file/%file%:%line%:%column%",
_ => return None,
};
tracing::debug!("Auto-detected editor URL from TERM_PROGRAM={term_program}");
return Some(url.to_string());
}
None
}