use std::path::{Path, PathBuf};
use etcetera::BaseStrategy as _;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub php: PhpConfig,
pub diagnostics: DiagnosticsConfig,
pub indexing: IndexingConfig,
pub formatting: FormattingConfig,
pub phpstan: PhpStanConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct PhpConfig {
pub version: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct DiagnosticsConfig {
#[serde(rename = "unresolved-member-access")]
pub unresolved_member_access: Option<bool>,
#[serde(rename = "extra-arguments")]
pub extra_arguments: Option<bool>,
}
impl DiagnosticsConfig {
pub fn unresolved_member_access_enabled(&self) -> bool {
self.unresolved_member_access.unwrap_or(false)
}
pub fn extra_arguments_enabled(&self) -> bool {
self.extra_arguments.unwrap_or(false)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct FormattingConfig {
#[serde(rename = "php-cs-fixer")]
pub php_cs_fixer: Option<String>,
pub phpcbf: Option<String>,
pub timeout: Option<u64>,
}
impl FormattingConfig {
pub fn timeout_ms(&self) -> u64 {
self.timeout.unwrap_or(10_000)
}
pub fn is_disabled(&self) -> bool {
self.php_cs_fixer.as_deref() == Some("") && self.phpcbf.as_deref() == Some("")
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct PhpStanConfig {
pub command: Option<String>,
#[serde(rename = "memory-limit")]
pub memory_limit: Option<String>,
pub timeout: Option<u64>,
}
impl PhpStanConfig {
pub fn timeout_ms(&self) -> u64 {
self.timeout.unwrap_or(60_000)
}
pub fn is_disabled(&self) -> bool {
self.command.as_deref() == Some("")
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct IndexingConfig {
pub strategy: Option<IndexingStrategy>,
}
impl IndexingConfig {
pub fn strategy(&self) -> IndexingStrategy {
self.strategy.unwrap_or_default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IndexingStrategy {
#[default]
Composer,
SelfScan,
Full,
None,
}
impl<'de> Deserialize<'de> for IndexingStrategy {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.as_str() {
"composer" => Ok(IndexingStrategy::Composer),
"self" => Ok(IndexingStrategy::SelfScan),
"full" => Ok(IndexingStrategy::Full),
"none" => Ok(IndexingStrategy::None),
other => Err(serde::de::Error::unknown_variant(
other,
&["composer", "self", "full", "none"],
)),
}
}
}
impl std::fmt::Display for IndexingStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IndexingStrategy::Composer => write!(f, "composer"),
IndexingStrategy::SelfScan => write!(f, "self"),
IndexingStrategy::Full => write!(f, "full"),
IndexingStrategy::None => write!(f, "none"),
}
}
}
fn merge_toml(base: &mut toml::Table, overlay: toml::Table) {
for (key, overlay_val) in overlay {
match overlay_val {
toml::Value::Table(overlay_table)
if matches!(base.get(&key), Some(toml::Value::Table(_))) =>
{
if let Some(toml::Value::Table(base_table)) = base.get_mut(&key) {
merge_toml(base_table, overlay_table);
}
}
val => {
base.insert(key, val);
}
}
}
}
pub const CONFIG_FILE_NAME: &str = ".phpantom.toml";
const CONFIG_APP_DIR: &str = "phpantom_lsp";
pub const DEFAULT_CONFIG_CONTENT: &str = r#"# $schema: https://github.com/AJenbo/phpantom_lsp/raw/main/config-schema.json
"#;
pub fn global_config_path() -> Option<PathBuf> {
etcetera::choose_base_strategy()
.ok()
.map(|s| s.config_dir().join(CONFIG_APP_DIR).join(CONFIG_FILE_NAME))
}
pub fn create_default_config(workspace_root: &Path) -> Result<bool, ConfigError> {
let config_path = workspace_root.join(CONFIG_FILE_NAME);
if config_path.exists() {
return Ok(false);
}
std::fs::write(&config_path, DEFAULT_CONFIG_CONTENT).map_err(|e| ConfigError::Io {
path: config_path.display().to_string(),
source: e,
})?;
Ok(true)
}
fn load_toml_table(path: &Path) -> Result<Option<toml::Table>, ConfigError> {
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(path).map_err(|e| ConfigError::Io {
path: path.display().to_string(),
source: e,
})?;
let table: toml::Table = content.parse().map_err(|e| ConfigError::Parse {
path: path.display().to_string(),
source: e,
})?;
Ok(Some(table))
}
pub fn load_config(workspace_root: &Path) -> Result<Config, ConfigError> {
let mut table = global_config_path()
.and_then(|p| load_toml_table(&p).transpose())
.transpose()?
.unwrap_or_default();
let project_path = workspace_root.join(CONFIG_FILE_NAME);
if let Some(project) = load_toml_table(&project_path)? {
merge_toml(&mut table, project);
}
let config: Config = table.try_into().map_err(|e| ConfigError::Parse {
path: project_path.display().to_string(),
source: e,
})?;
Ok(config)
}
#[derive(Debug)]
pub enum ConfigError {
Io {
path: String,
source: std::io::Error,
},
Parse {
path: String,
source: toml::de::Error,
},
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::Io { path, source } => {
write!(f, "failed to read {}: {}", path, source)
}
ConfigError::Parse { path, source } => {
write!(f, "failed to parse {}: {}", path, source)
}
}
}
}
impl std::error::Error for ConfigError {}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn create_default_writes_file() {
let dir = tempfile::tempdir().unwrap();
let result = create_default_config(dir.path()).unwrap();
assert!(result, "should report that the file was created");
let path = dir.path().join(CONFIG_FILE_NAME);
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("$schema"));
}
#[test]
fn create_default_does_not_overwrite() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "# custom\n").unwrap();
let result = create_default_config(dir.path()).unwrap();
assert!(!result, "should report that the file already exists");
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(
content, "# custom\n",
"existing file must not be overwritten"
);
}
#[test]
fn default_content_parses_successfully() {
let config: Config = toml::from_str(DEFAULT_CONFIG_CONTENT).unwrap();
assert!(config.php.version.is_none());
assert!(!config.diagnostics.unresolved_member_access_enabled());
assert!(!config.diagnostics.extra_arguments_enabled());
assert_eq!(config.indexing.strategy(), IndexingStrategy::Composer);
assert!(config.formatting.php_cs_fixer.is_none());
assert!(config.formatting.phpcbf.is_none());
assert!(config.formatting.timeout.is_none());
assert_eq!(config.formatting.timeout_ms(), 10_000);
assert!(config.phpstan.command.is_none());
assert!(config.phpstan.memory_limit.is_none());
assert!(config.phpstan.timeout.is_none());
assert_eq!(config.phpstan.timeout_ms(), 60_000);
}
#[test]
fn missing_file_returns_defaults() {
let dir = tempfile::tempdir().unwrap();
let config = load_config(dir.path()).unwrap();
assert!(config.php.version.is_none());
assert!(!config.diagnostics.unresolved_member_access_enabled());
assert!(!config.diagnostics.extra_arguments_enabled());
assert_eq!(config.indexing.strategy(), IndexingStrategy::Composer);
assert!(config.formatting.php_cs_fixer.is_none());
assert!(config.formatting.phpcbf.is_none());
assert!(config.phpstan.command.is_none());
}
#[test]
fn empty_file_returns_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "").unwrap();
let config = load_config(dir.path()).unwrap();
assert!(config.php.version.is_none());
assert!(!config.diagnostics.unresolved_member_access_enabled());
assert!(!config.diagnostics.extra_arguments_enabled());
assert_eq!(config.indexing.strategy(), IndexingStrategy::Composer);
assert!(config.formatting.php_cs_fixer.is_none());
assert!(config.formatting.phpcbf.is_none());
assert!(config.phpstan.command.is_none());
}
#[test]
fn parses_php_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[php]\nversion = \"8.3\"\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.php.version.as_deref(), Some("8.3"));
}
#[test]
fn parses_diagnostics_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[diagnostics]\nunresolved-member-access = true\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert!(config.diagnostics.unresolved_member_access_enabled());
}
#[test]
fn unresolved_member_access_defaults_to_false() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[diagnostics]\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert!(!config.diagnostics.unresolved_member_access_enabled());
}
#[test]
fn extra_arguments_defaults_to_false() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[diagnostics]\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert!(!config.diagnostics.extra_arguments_enabled());
}
#[test]
fn parses_extra_arguments() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[diagnostics]\nextra-arguments = true\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert!(config.diagnostics.extra_arguments_enabled());
}
#[test]
fn invalid_toml_returns_parse_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[diagnostics\nbroken").unwrap();
let result = load_config(dir.path());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("failed to parse"));
}
#[test]
fn unknown_keys_are_ignored() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "[diagnostics]").unwrap();
writeln!(f, "unresolved-member-access = true").unwrap();
writeln!(f, "some-future-tool = false").unwrap();
drop(f);
let config = load_config(dir.path()).unwrap();
assert!(config.diagnostics.unresolved_member_access_enabled());
}
#[test]
fn unknown_sections_are_ignored() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(
&path,
"[php]\nversion = \"8.4\"\n\n[some-future-section]\nkey = \"value\"\n",
)
.unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.php.version.as_deref(), Some("8.4"));
}
#[test]
fn parses_phpstan_command() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[phpstan]\ncommand = \"/usr/bin/phpstan\"\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.phpstan.command.as_deref(), Some("/usr/bin/phpstan"));
}
#[test]
fn parses_phpstan_memory_limit() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[phpstan]\nmemory-limit = \"2G\"\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.phpstan.memory_limit.as_deref(), Some("2G"));
}
#[test]
fn parses_phpstan_timeout() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[phpstan]\ntimeout = 30000\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.phpstan.timeout_ms(), 30_000);
}
#[test]
fn phpstan_empty_string_disables() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[phpstan]\ncommand = \"\"\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.phpstan.command.as_deref(), Some(""));
assert!(config.phpstan.is_disabled());
}
#[test]
fn phpstan_defaults() {
let config = Config::default();
assert!(config.phpstan.command.is_none());
assert!(config.phpstan.memory_limit.is_none());
assert!(config.phpstan.timeout.is_none());
assert_eq!(config.phpstan.timeout_ms(), 60_000);
assert!(!config.phpstan.is_disabled());
}
#[test]
fn full_example_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(
&path,
r#"
[php]
version = "8.2"
[diagnostics]
unresolved-member-access = true
extra-arguments = true
[indexing]
strategy = "self"
[formatting]
php-cs-fixer = ""
phpcbf = "/usr/local/bin/phpcbf"
timeout = 5000
[phpstan]
command = "/usr/local/bin/phpstan"
memory-limit = "2G"
timeout = 30000
"#,
)
.unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.php.version.as_deref(), Some("8.2"));
assert!(config.diagnostics.unresolved_member_access_enabled());
assert!(config.diagnostics.extra_arguments_enabled());
assert_eq!(config.indexing.strategy, Some(IndexingStrategy::SelfScan));
assert_eq!(config.formatting.php_cs_fixer.as_deref(), Some(""));
assert_eq!(
config.formatting.phpcbf.as_deref(),
Some("/usr/local/bin/phpcbf")
);
assert_eq!(config.formatting.timeout_ms(), 5000);
assert_eq!(
config.phpstan.command.as_deref(),
Some("/usr/local/bin/phpstan")
);
assert_eq!(config.phpstan.memory_limit.as_deref(), Some("2G"));
assert_eq!(config.phpstan.timeout_ms(), 30_000);
}
#[test]
fn parses_indexing_strategy_composer() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[indexing]\nstrategy = \"composer\"\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.indexing.strategy, Some(IndexingStrategy::Composer));
}
#[test]
fn parses_indexing_strategy_self() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[indexing]\nstrategy = \"self\"\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.indexing.strategy, Some(IndexingStrategy::SelfScan));
}
#[test]
fn parses_indexing_strategy_full() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[indexing]\nstrategy = \"full\"\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.indexing.strategy, Some(IndexingStrategy::Full));
}
#[test]
fn parses_indexing_strategy_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[indexing]\nstrategy = \"none\"\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.indexing.strategy, Some(IndexingStrategy::None));
}
#[test]
fn invalid_indexing_strategy_returns_parse_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[indexing]\nstrategy = \"bogus\"\n").unwrap();
let result = load_config(dir.path());
assert!(result.is_err());
}
#[test]
fn indexing_strategy_defaults_to_composer() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[indexing]\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.indexing.strategy(), IndexingStrategy::Composer);
}
#[test]
fn indexing_strategy_display() {
assert_eq!(IndexingStrategy::Composer.to_string(), "composer");
assert_eq!(IndexingStrategy::SelfScan.to_string(), "self");
assert_eq!(IndexingStrategy::Full.to_string(), "full");
assert_eq!(IndexingStrategy::None.to_string(), "none");
}
#[test]
fn parses_formatting_php_cs_fixer_command() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(
&path,
"[formatting]\nphp-cs-fixer = \"/usr/bin/php-cs-fixer\"\n",
)
.unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(
config.formatting.php_cs_fixer.as_deref(),
Some("/usr/bin/php-cs-fixer")
);
assert!(config.formatting.phpcbf.is_none());
}
#[test]
fn parses_formatting_phpcbf_command() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[formatting]\nphpcbf = \"vendor/bin/phpcbf\"\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(
config.formatting.phpcbf.as_deref(),
Some("vendor/bin/phpcbf")
);
assert!(config.formatting.php_cs_fixer.is_none());
}
#[test]
fn parses_formatting_timeout() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[formatting]\ntimeout = 3000\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.formatting.timeout_ms(), 3000);
}
#[test]
fn formatting_empty_string_disables_tool() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, "[formatting]\nphp-cs-fixer = \"\"\nphpcbf = \"\"\n").unwrap();
let config = load_config(dir.path()).unwrap();
assert_eq!(config.formatting.php_cs_fixer.as_deref(), Some(""));
assert_eq!(config.formatting.phpcbf.as_deref(), Some(""));
assert!(config.formatting.is_disabled());
}
#[test]
fn formatting_defaults() {
let config = Config::default();
assert!(config.formatting.php_cs_fixer.is_none());
assert!(config.formatting.phpcbf.is_none());
assert!(config.formatting.timeout.is_none());
assert_eq!(config.formatting.timeout_ms(), 10_000);
assert!(!config.formatting.is_disabled());
}
#[test]
fn merge_toml_overlay_wins() {
let mut base: toml::Table = toml::from_str("[php]\nversion = \"8.2\"\n").unwrap();
let overlay: toml::Table = toml::from_str("[php]\nversion = \"8.4\"\n").unwrap();
merge_toml(&mut base, overlay);
let config: Config = base.try_into().unwrap();
assert_eq!(config.php.version.as_deref(), Some("8.4"));
}
#[test]
fn merge_toml_base_preserved_when_overlay_missing() {
let mut base: toml::Table =
toml::from_str("[php]\nversion = \"8.2\"\n\n[phpstan]\ntimeout = 30000\n").unwrap();
let overlay: toml::Table = toml::from_str("[phpstan]\ncommand = \"phpstan\"\n").unwrap();
merge_toml(&mut base, overlay);
let config: Config = base.try_into().unwrap();
assert_eq!(config.php.version.as_deref(), Some("8.2"));
assert_eq!(config.phpstan.command.as_deref(), Some("phpstan"));
assert_eq!(config.phpstan.timeout_ms(), 30_000);
}
#[test]
fn merge_toml_deep_merge_within_section() {
let mut base: toml::Table =
toml::from_str("[formatting]\ntimeout = 5000\nphpcbf = \"/usr/bin/phpcbf\"\n").unwrap();
let overlay: toml::Table =
toml::from_str("[formatting]\nphp-cs-fixer = \"vendor/bin/php-cs-fixer\"\n").unwrap();
merge_toml(&mut base, overlay);
let config: Config = base.try_into().unwrap();
assert_eq!(
config.formatting.php_cs_fixer.as_deref(),
Some("vendor/bin/php-cs-fixer")
);
assert_eq!(config.formatting.phpcbf.as_deref(), Some("/usr/bin/phpcbf"));
assert_eq!(config.formatting.timeout_ms(), 5000);
}
#[test]
fn merge_toml_empty_overlay() {
let mut base: toml::Table = toml::from_str("[php]\nversion = \"8.3\"\n").unwrap();
let overlay: toml::Table = toml::Table::new();
merge_toml(&mut base, overlay);
let config: Config = base.try_into().unwrap();
assert_eq!(config.php.version.as_deref(), Some("8.3"));
}
#[test]
fn merge_toml_empty_base() {
let mut base = toml::Table::new();
let overlay: toml::Table =
toml::from_str("[diagnostics]\nextra-arguments = true\n").unwrap();
merge_toml(&mut base, overlay);
let config: Config = base.try_into().unwrap();
assert!(config.diagnostics.extra_arguments_enabled());
}
#[test]
fn merge_toml_overlay_replaces_non_table_with_value() {
let mut base: toml::Table =
toml::from_str("[indexing]\nstrategy = \"composer\"\n").unwrap();
let overlay: toml::Table = toml::from_str("[indexing]\nstrategy = \"self\"\n").unwrap();
merge_toml(&mut base, overlay);
let config: Config = base.try_into().unwrap();
assert_eq!(config.indexing.strategy, Some(IndexingStrategy::SelfScan));
}
}