prismtty 0.2.4

Fast terminal output highlighter focused on network devices and Unix systems
Documentation
//! Configuration and profile-file parsing.
//!
//! PrismTTY accepts ChromaTerm-style YAML rule files and native profile files
//! that add profile metadata such as inheritance and detection hints.

use crate::profiles::{ProfileRuntimeMeta, ProfileStore};
use crate::style::{Style, parse_palette};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;

/// Error message returned when user profile files include reserved runtime metadata.
pub const RESERVED_PROFILE_RUNTIME_MESSAGE: &str =
    "the profile.runtime field is reserved for built-in profiles in this PrismTTY version";

/// Errors returned while loading, parsing, or resolving PrismTTY configuration.
#[derive(Debug, Error)]
pub enum ConfigError {
    /// A configuration or profile file could not be read.
    #[error("failed to read {path}: {source}")]
    Read {
        /// Path that failed to load.
        path: PathBuf,
        /// Underlying filesystem error.
        source: std::io::Error,
    },
    /// YAML decoding failed.
    #[error("failed to parse YAML: {0}")]
    Yaml(#[from] serde_norway::Error),
    /// A requested profile name was not registered.
    #[error("unknown profile '{0}'")]
    UnknownProfile(String),
    /// Profile inheritance loops back to a profile already being resolved.
    #[error("cyclic profile inheritance: {0}")]
    CyclicProfileInheritance(String),
    /// A native profile file omitted `profile.name`.
    #[error("profile files must include profile.name")]
    MissingProfileName,
    /// A bundled built-in profile omitted its private runtime metadata.
    #[error("bundled profile files must include profile.runtime")]
    MissingProfileRuntime,
    /// A user profile attempted to set reserved runtime metadata.
    #[error("{0}")]
    ReservedProfileRuntime(&'static str),
    /// A rule style string or capture style mapping is invalid.
    #[error("rule '{description}' has invalid style: {message}")]
    InvalidStyle {
        /// Human-readable rule description.
        description: String,
        /// Style parser error text.
        message: String,
    },
    /// The palette section contains an invalid color name or value.
    #[error("palette has invalid color: {0}")]
    InvalidPalette(String),
    /// A capture style key was neither a group index nor a group name.
    #[error("rule '{description}' has invalid capture key: {key}")]
    InvalidCaptureKey {
        /// Human-readable rule description.
        description: String,
        /// Invalid capture key as it appeared in YAML.
        key: String,
    },
}

/// Fully resolved highlighting configuration.
#[derive(Clone, Debug, Default)]
pub struct PrismConfig {
    /// Rule list in application order.
    pub rules: Vec<RuleSpec>,
    /// Profiles that contributed rules to this configuration.
    pub enabled_profiles: Vec<String>,
}

/// One highlight rule before PCRE2 compilation.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuleSpec {
    /// Human-readable rule name used in errors and benchmark reports.
    pub description: String,
    /// PCRE2 regular expression matched against visible terminal text.
    pub regex: String,
    /// Style applied to the whole match or selected capture groups.
    pub style: RuleStyle,
    /// Whether this rule prevents later rules from changing the same span.
    pub exclusive: bool,
}

/// Capture group reference used by capture-specific styles.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum CaptureRef {
    /// Numeric capture group index, including `0` for the whole match.
    Index(usize),
    /// Named capture group.
    Name(String),
}

/// Style target for a highlight rule.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuleStyle {
    /// Apply one style to the whole regex match.
    Whole(Style),
    /// Apply individual styles to capture groups.
    Captures(BTreeMap<CaptureRef, Style>),
}

#[derive(Debug, Deserialize)]
struct RulesDoc {
    #[serde(default)]
    profile: Option<ProfileMetaDoc>,
    #[serde(default)]
    palette: BTreeMap<String, String>,
    #[serde(default)]
    rules: Vec<RuleDoc>,
}

/// Metadata declared in a native profile YAML file.
#[derive(Clone, Debug, Deserialize)]
pub struct ProfileMetaDoc {
    /// Profile name used on the command line and in inheritance lists.
    pub name: String,
    /// Parent profiles loaded before this profile.
    #[serde(default)]
    pub inherits: Vec<String>,
    /// Startup detection hints used for auto-detection.
    #[serde(default)]
    pub detection: Vec<String>,
    #[serde(default)]
    pub(crate) runtime: Option<ProfileRuntimeMeta>,
}

#[derive(Debug, Deserialize)]
struct RuleDoc {
    #[serde(default)]
    description: String,
    regex: String,
    color: serde_norway::Value,
    #[serde(default)]
    exclusive: bool,
}

/// Parsed native profile file, including metadata and rules.
#[derive(Clone, Debug)]
pub struct LoadedProfileFile {
    /// Public profile metadata from the `profile` YAML section.
    pub meta: ProfileMetaDoc,
    /// Runtime metadata for bundled profiles, or `None` for user profiles.
    pub runtime: Option<ProfileRuntimeMeta>,
    /// Parsed highlighting rules from the file.
    pub rules: Vec<RuleSpec>,
}

impl PrismConfig {
    /// Parses a ChromaTerm-style YAML document into highlighting rules.
    pub fn from_chromaterm_yaml(input: &str) -> Result<Self, ConfigError> {
        let doc: RulesDoc = serde_norway::from_str(input)?;
        let palette = parse_palette(&doc.palette).map_err(ConfigError::InvalidPalette)?;
        Ok(Self {
            rules: parse_rule_docs(doc.rules, &palette)?,
            enabled_profiles: Vec::new(),
        })
    }

    /// Reads and parses a ChromaTerm-style YAML file.
    pub fn from_chromaterm_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
        let path = path.as_ref();
        let input = fs::read_to_string(path).map_err(|source| ConfigError::Read {
            path: path.to_path_buf(),
            source,
        })?;
        Self::from_chromaterm_yaml(&input)
    }

    /// Builds a configuration from registered profiles and their inherited rules.
    pub fn from_profiles(
        store: &ProfileStore,
        profile_names: &[&str],
    ) -> Result<Self, ConfigError> {
        let mut rules = Vec::new();
        let mut loaded = BTreeSet::new();

        for profile_name in profile_names {
            store.append_profile_rules(profile_name, &mut loaded, &mut rules)?;
        }

        Ok(Self {
            rules,
            enabled_profiles: loaded.into_iter().collect(),
        })
    }

    /// Appends another configuration, preserving unique enabled-profile names.
    pub fn merge(mut self, mut other: Self) -> Self {
        self.rules.append(&mut other.rules);
        for profile in other.enabled_profiles {
            if !self.enabled_profiles.contains(&profile) {
                self.enabled_profiles.push(profile);
            }
        }
        self
    }
}

/// Reads and parses a native PrismTTY profile YAML file.
pub fn load_profile_file(path: impl AsRef<Path>) -> Result<LoadedProfileFile, ConfigError> {
    let path = path.as_ref();
    let input = fs::read_to_string(path).map_err(|source| ConfigError::Read {
        path: path.to_path_buf(),
        source,
    })?;
    parse_profile_yaml(&input)
}

/// Parses native PrismTTY profile YAML from a string.
pub fn parse_profile_yaml(input: &str) -> Result<LoadedProfileFile, ConfigError> {
    parse_profile_yaml_with_mode(input, ProfileYamlMode::User)
}

pub(crate) fn parse_builtin_profile_yaml(input: &str) -> Result<LoadedProfileFile, ConfigError> {
    parse_profile_yaml_with_mode(input, ProfileYamlMode::Bundled)
}

#[derive(Clone, Copy)]
enum ProfileYamlMode {
    User,
    Bundled,
}

fn parse_profile_yaml_with_mode(
    input: &str,
    mode: ProfileYamlMode,
) -> Result<LoadedProfileFile, ConfigError> {
    let doc: RulesDoc = serde_norway::from_str(input)?;
    let mut meta = doc.profile.ok_or(ConfigError::MissingProfileName)?;
    let runtime = meta.runtime.take();
    match mode {
        ProfileYamlMode::User if runtime.is_some() => {
            return Err(ConfigError::ReservedProfileRuntime(
                RESERVED_PROFILE_RUNTIME_MESSAGE,
            ));
        }
        ProfileYamlMode::Bundled if runtime.is_none() => {
            return Err(ConfigError::MissingProfileRuntime);
        }
        _ => {}
    }
    let palette = parse_palette(&doc.palette).map_err(ConfigError::InvalidPalette)?;
    Ok(LoadedProfileFile {
        meta,
        runtime,
        rules: parse_rule_docs(doc.rules, &palette)?,
    })
}

fn parse_rule_docs(
    rule_docs: Vec<RuleDoc>,
    palette: &BTreeMap<String, crate::style::Rgb>,
) -> Result<Vec<RuleSpec>, ConfigError> {
    rule_docs
        .into_iter()
        .enumerate()
        .map(|(idx, rule)| {
            let description = if rule.description.trim().is_empty() {
                format!("rule {}", idx + 1)
            } else {
                rule.description
            };
            let style = parse_color_doc(&description, rule.color, palette)?;
            Ok(RuleSpec {
                description,
                regex: rule.regex,
                style,
                exclusive: rule.exclusive,
            })
        })
        .collect()
}

fn parse_color_doc(
    description: &str,
    color: serde_norway::Value,
    palette: &BTreeMap<String, crate::style::Rgb>,
) -> Result<RuleStyle, ConfigError> {
    match color {
        serde_norway::Value::String(spec) => {
            Ok(RuleStyle::Whole(parse_style(description, &spec, palette)?))
        }
        serde_norway::Value::Mapping(captures) => {
            let mut parsed = BTreeMap::new();
            for (group, spec) in captures {
                let group = parse_capture_ref(description, group)?;
                let spec = spec.as_str().ok_or_else(|| ConfigError::InvalidStyle {
                    description: description.to_string(),
                    message: "capture color must be a string".to_string(),
                })?;
                parsed.insert(group, parse_style(description, spec, palette)?);
            }
            Ok(RuleStyle::Captures(parsed))
        }
        _ => Err(ConfigError::InvalidStyle {
            description: description.to_string(),
            message: "color must be a string or capture-group mapping".to_string(),
        }),
    }
}

fn parse_capture_ref(
    description: &str,
    value: serde_norway::Value,
) -> Result<CaptureRef, ConfigError> {
    match value {
        serde_norway::Value::Number(number) => {
            let Some(group) = number.as_u64() else {
                return Err(ConfigError::InvalidCaptureKey {
                    description: description.to_string(),
                    key: number.to_string(),
                });
            };
            Ok(CaptureRef::Index(group as usize))
        }
        serde_norway::Value::String(name) if name.bytes().all(|byte| byte.is_ascii_digit()) => name
            .parse::<usize>()
            .map(CaptureRef::Index)
            .map_err(|_| ConfigError::InvalidCaptureKey {
                description: description.to_string(),
                key: name,
            }),
        serde_norway::Value::String(name) if !name.trim().is_empty() => {
            Ok(CaptureRef::Name(name.to_string()))
        }
        other => Err(ConfigError::InvalidCaptureKey {
            description: description.to_string(),
            key: format!("{other:?}"),
        }),
    }
}

fn parse_style(
    description: &str,
    spec: &str,
    palette: &BTreeMap<String, crate::style::Rgb>,
) -> Result<Style, ConfigError> {
    let palette = (!palette.is_empty()).then_some(palette);
    Style::parse_with_palette(spec, palette).map_err(|message| ConfigError::InvalidStyle {
        description: description.to_string(),
        message,
    })
}

#[cfg(test)]
mod tests {
    use super::{RESERVED_PROFILE_RUNTIME_MESSAGE, parse_builtin_profile_yaml, parse_profile_yaml};

    #[test]
    fn user_profile_runtime_is_reserved() {
        let yaml = r#"
profile:
  name: custom-router
  runtime:
    priority: 5
    startup_prompt: cisco_host_marker
    runtime_prompt: cisco_host_marker
    strong_signals: []
rules: []
"#;

        let err = parse_profile_yaml(yaml).expect_err("user profile.runtime must be rejected");

        assert_eq!(err.to_string(), RESERVED_PROFILE_RUNTIME_MESSAGE);
    }

    #[test]
    fn bundled_profile_runtime_rejects_unknown_prompt_matcher() {
        let yaml = r#"
profile:
  name: broken-builtin
  runtime:
    priority: 1
    startup_prompt: mystery_prompt
    runtime_prompt: none
    strong_signals: []
rules: []
"#;

        let err = parse_builtin_profile_yaml(yaml).expect_err("unknown prompt matcher should fail");

        assert!(err.to_string().contains("mystery_prompt"));
    }
}