use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorLevel {
Error,
Warning,
Info,
Suppress,
}
impl ErrorLevel {
fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"error" => Some(Self::Error),
"warning" | "warn" => Some(Self::Warning),
"info" | "notice" => Some(Self::Info),
"suppress" | "none" => Some(Self::Suppress),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub project_dirs: Vec<String>,
pub ignore_dirs: Vec<String>,
pub issue_handlers: HashMap<String, ErrorLevel>,
pub error_level: u8,
pub php_version: Option<String>,
pub find_unused_code: bool,
pub find_unused_variables: bool,
pub stub_files: Vec<String>,
pub stub_dirs: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
project_dirs: Vec::new(),
ignore_dirs: Vec::new(),
issue_handlers: HashMap::new(),
error_level: 2,
php_version: None,
find_unused_code: false,
find_unused_variables: false,
stub_files: Vec::new(),
stub_dirs: Vec::new(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("cannot read config file: {0}")]
Io(String),
#[error("XML parse error: {0}")]
Parse(String),
}
impl Config {
pub fn find(start_dir: &Path) -> Option<PathBuf> {
let mut dir = start_dir.to_path_buf();
loop {
let mir = dir.join("mir.xml");
if mir.exists() {
return Some(mir);
}
let psalm = dir.join("psalm.xml");
if psalm.exists() {
return Some(psalm);
}
if !dir.pop() {
return None;
}
}
}
pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
let xml = std::fs::read_to_string(path).map_err(|e| ConfigError::Io(e.to_string()))?;
Self::parse(&xml)
}
pub fn parse(xml: &str) -> Result<Self, ConfigError> {
parse_xml(xml)
}
}
fn parse_xml(xml: &str) -> Result<Config, ConfigError> {
use quick_xml::events::Event;
use quick_xml::Reader;
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut config = Config::default();
let mut path: Vec<String> = Vec::new();
let mut text_buf = String::new();
loop {
match reader.read_event() {
Ok(Event::Start(e)) => {
let name = bytes_to_string(e.name().as_ref());
if path.is_empty() && (name == "mir" || name == "psalm") {
for attr in e.attributes().flatten() {
if bytes_to_string(attr.key.as_ref()) == "phpVersion"
&& config.php_version.is_none()
{
let val = bytes_to_string(&attr.value);
if !val.is_empty() {
config.php_version = Some(val);
}
}
}
}
if path.last().is_some_and(|s: &String| s == "issueHandlers") {
for attr in e.attributes().flatten() {
if bytes_to_string(attr.key.as_ref()) == "errorLevel" {
if let Some(level) = ErrorLevel::from_str(&bytes_to_string(&attr.value))
{
config.issue_handlers.insert(name.clone(), level);
}
}
}
}
if name == "directory" {
collect_directory(&e, &path, &mut config);
}
if name == "file" || name == "directory" {
collect_stub_entry(&e, &path, &mut config);
}
text_buf.clear();
path.push(name);
}
Ok(Event::Empty(e)) => {
let name = bytes_to_string(e.name().as_ref());
if path.last().is_some_and(|s: &String| s == "issueHandlers") {
for attr in e.attributes().flatten() {
if bytes_to_string(attr.key.as_ref()) == "errorLevel" {
if let Some(level) = ErrorLevel::from_str(&bytes_to_string(&attr.value))
{
config.issue_handlers.insert(name.clone(), level);
}
}
}
}
if name == "directory" {
collect_directory(&e, &path, &mut config);
}
if name == "file" || name == "directory" {
collect_stub_entry(&e, &path, &mut config);
}
}
Ok(Event::Text(t)) => {
text_buf = t
.xml_content()
.map_err(|e| ConfigError::Parse(e.to_string()))?
.to_string();
}
Ok(Event::End(_)) => {
let key = path.pop().unwrap_or_default();
let parent = path.last().map_or("", |s| s.as_str());
match (key.as_str(), parent) {
("phpVersion", _) if !text_buf.is_empty() => {
config.php_version = Some(text_buf.clone());
}
("errorLevel", "mir") => {
if let Ok(n) = text_buf.parse::<u8>() {
config.error_level = n.clamp(1, 8);
}
}
("findUnusedCode", _) => {
config.find_unused_code = text_buf == "true";
}
("findUnusedVariables", _) => {
config.find_unused_variables = text_buf == "true";
}
_ => {}
}
text_buf.clear();
}
Ok(Event::Eof) => break,
Err(e) => return Err(ConfigError::Parse(e.to_string())),
_ => {}
}
}
Ok(config)
}
fn collect_directory<'a>(
e: &quick_xml::events::BytesStart<'a>,
path: &[String],
config: &mut Config,
) {
let parent = path.last().map_or("", |s| s.as_str());
for attr in e.attributes().flatten() {
if bytes_to_string(attr.key.as_ref()) == "name" {
let val = bytes_to_string(&attr.value);
match parent {
"projectFiles" => config.project_dirs.push(val),
"ignoreFiles" => config.ignore_dirs.push(val),
_ => {}
}
}
}
}
fn collect_stub_entry<'a>(
e: &quick_xml::events::BytesStart<'a>,
path: &[String],
config: &mut Config,
) {
if path.last().map_or("", |s| s.as_str()) != "stubs" {
return;
}
let elem = bytes_to_string(e.name().as_ref());
for attr in e.attributes().flatten() {
if bytes_to_string(attr.key.as_ref()) == "name" {
let val = bytes_to_string(&attr.value);
match elem.as_str() {
"file" => config.stub_files.push(val),
"directory" => config.stub_dirs.push(val),
_ => {}
}
}
}
}
fn bytes_to_string(b: &[u8]) -> String {
String::from_utf8_lossy(b).into_owned()
}
#[derive(Debug, Clone, Default)]
pub struct Baseline {
pub entries: HashMap<String, HashMap<String, Vec<String>>>,
}
impl Baseline {
pub fn from_file(path: &std::path::Path) -> Result<Self, ConfigError> {
let xml = std::fs::read_to_string(path).map_err(|e| ConfigError::Io(e.to_string()))?;
Self::parse(&xml)
}
pub fn parse(xml: &str) -> Result<Self, ConfigError> {
parse_baseline_xml(xml)
}
pub fn consume(&mut self, file: &str, issue_kind: &str, snippet: &str) -> bool {
if let Some(by_kind) = self.entries.get_mut(file) {
if let Some(snippets) = by_kind.get_mut(issue_kind) {
if let Some(pos) = snippets.iter().position(|s| s == snippet) {
snippets.remove(pos);
return true;
}
}
}
false
}
#[allow(dead_code)]
pub fn contains_kind(&self, file: &str, issue_kind: &str) -> bool {
self.entries
.get(file)
.and_then(|m| m.get(issue_kind))
.is_some_and(|v| !v.is_empty())
}
pub fn write(&self, path: &std::path::Path) -> Result<(), ConfigError> {
let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<files>\n");
let mut files: Vec<&String> = self.entries.keys().collect();
files.sort_unstable();
for file in files {
let by_kind = &self.entries[file];
let mut kinds: Vec<&String> = by_kind.keys().collect();
kinds.sort_unstable();
out.push_str(&format!(" <file src=\"{}\">\n", xml_escape_attr(file)));
for kind in kinds {
let snippets = &by_kind[kind];
out.push_str(&format!(" <{kind}>\n"));
for snippet in snippets {
out.push_str(&format!(" <code><![CDATA[{snippet}]]></code>\n"));
}
out.push_str(&format!(" </{kind}>\n"));
}
out.push_str(" </file>\n");
}
out.push_str("</files>\n");
std::fs::write(path, out).map_err(|e| ConfigError::Io(e.to_string()))
}
}
fn xml_escape_attr(s: &str) -> String {
s.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
fn parse_baseline_xml(xml: &str) -> Result<Baseline, ConfigError> {
use quick_xml::events::Event;
use quick_xml::Reader;
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut baseline = Baseline::default();
let mut path: Vec<String> = Vec::new();
let mut current_file: Option<String> = None;
let mut current_kind: Option<String> = None;
let mut text_buf = String::new();
loop {
match reader.read_event() {
Ok(Event::Start(e)) => {
let name = bytes_to_string(e.name().as_ref());
match name.as_str() {
"file" => {
for attr in e.attributes().flatten() {
if bytes_to_string(attr.key.as_ref()) == "src" {
current_file = Some(bytes_to_string(&attr.value));
}
}
current_kind = None;
}
"files" => {}
_ if path.last().is_some_and(|s: &String| s == "file") => {
current_kind = Some(name.clone());
}
_ => {}
}
text_buf.clear();
path.push(name);
}
Ok(Event::Empty(e)) => {
let name = bytes_to_string(e.name().as_ref());
if name == "file" {
for attr in e.attributes().flatten() {
if bytes_to_string(attr.key.as_ref()) == "src" {
current_file = Some(bytes_to_string(&attr.value));
}
}
}
}
Ok(Event::CData(cd)) => {
text_buf = String::from_utf8_lossy(cd.as_ref()).trim().to_string();
}
Ok(Event::Text(t)) => {
let s = t
.xml_content()
.map_err(|e| ConfigError::Parse(e.to_string()))?;
let trimmed = s.trim().to_string();
if !trimmed.is_empty() {
text_buf = trimmed;
}
}
Ok(Event::End(e)) => {
let name = bytes_to_string(e.name().as_ref());
match name.as_str() {
"code" => {
if let (Some(file), Some(kind)) = (¤t_file, ¤t_kind) {
let snippet = std::mem::take(&mut text_buf);
baseline
.entries
.entry(file.clone())
.or_default()
.entry(kind.clone())
.or_default()
.push(snippet);
}
}
"file" => {
current_file = None;
current_kind = None;
}
_ if Some(&name) == current_kind.as_ref() => {
current_kind = None;
}
_ => {}
}
path.pop();
text_buf.clear();
}
Ok(Event::Eof) => break,
Err(e) => return Err(ConfigError::Parse(e.to_string())),
_ => {}
}
}
Ok(baseline)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_php_version_child_element() {
let cfg = Config::parse(r#"<mir><phpVersion>8.1</phpVersion></mir>"#).unwrap();
assert_eq!(cfg.php_version.as_deref(), Some("8.1"));
}
#[test]
fn parses_php_version_root_attribute() {
let cfg = Config::parse(r#"<mir phpVersion="8.2"></mir>"#).unwrap();
assert_eq!(cfg.php_version.as_deref(), Some("8.2"));
}
#[test]
fn root_attribute_does_not_override_cli_override() {
let cfg = Config::parse(r#"<psalm phpVersion="7.4"></psalm>"#).unwrap();
assert_eq!(cfg.php_version.as_deref(), Some("7.4"));
}
#[test]
fn parses_stubs_file_entries() {
let cfg = Config::parse(
r#"<mir>
<stubs>
<file name="stubs/helpers.php"/>
<file name="stubs/ide.php"/>
</stubs>
</mir>"#,
)
.unwrap();
assert_eq!(cfg.stub_files, vec!["stubs/helpers.php", "stubs/ide.php"]);
assert!(cfg.stub_dirs.is_empty());
}
#[test]
fn parses_stubs_directory_entries() {
let cfg = Config::parse(
r#"<mir>
<stubs>
<directory name="stubs/doctrine"/>
</stubs>
</mir>"#,
)
.unwrap();
assert_eq!(cfg.stub_dirs, vec!["stubs/doctrine"]);
assert!(cfg.stub_files.is_empty());
}
#[test]
fn stubs_directory_does_not_pollute_project_dirs() {
let cfg = Config::parse(
r#"<mir>
<projectFiles>
<directory name="src"/>
</projectFiles>
<stubs>
<directory name="stubs/ext"/>
</stubs>
</mir>"#,
)
.unwrap();
assert_eq!(cfg.project_dirs, vec!["src"]);
assert_eq!(cfg.stub_dirs, vec!["stubs/ext"]);
}
}