#![forbid(unsafe_code)]
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
pub mod confidence;
use marque_ism::{IsmAttributes, Span};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::SystemTime;
pub use confidence::{Confidence, FeatureContribution, FeatureId};
pub use marque_ism::{DocumentPosition, MarkingType, Zone};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RuleId(&'static str);
impl RuleId {
#[inline]
pub const fn new(id: &'static str) -> Self {
Self(id)
}
#[inline]
pub const fn as_str(&self) -> &'static str {
self.0
}
}
impl std::fmt::Display for RuleId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Severity {
Off,
Suggest,
Info,
Warn,
Error,
Fix,
}
impl Severity {
pub fn parse_config(s: &str) -> Option<Self> {
match s {
"off" => Some(Self::Off),
"suggest" => Some(Self::Suggest),
"info" => Some(Self::Info),
"warn" => Some(Self::Warn),
"error" => Some(Self::Error),
"fix" => Some(Self::Fix),
_ => None,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Suggest => "suggest",
Self::Info => "info",
Self::Warn => "warn",
Self::Error => "error",
Self::Fix => "fix",
}
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct RuleContext {
pub marking_type: MarkingType,
pub zone: Option<Zone>,
pub position: Option<DocumentPosition>,
pub page_context: Option<std::sync::Arc<marque_ism::PageContext>>,
pub corrections: Option<Arc<HashMap<String, String>>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FixSource {
BuiltinRule,
CorrectionsMap,
MigrationTable,
DecoderPosterior,
DecoderClassificationHeuristic,
}
pub const CORRECTIONS_MAP_CITATION: &str = "CONFIG:[corrections]";
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct FixProposal {
pub rule: RuleId,
pub source: FixSource,
pub span: Span,
pub original: Box<str>,
pub replacement: Box<str>,
pub confidence: Confidence,
pub migration_ref: Option<&'static str>,
}
impl FixProposal {
pub fn new(
rule: RuleId,
source: FixSource,
span: Span,
original: impl Into<Box<str>>,
replacement: impl Into<Box<str>>,
confidence: Confidence,
migration_ref: Option<&'static str>,
) -> Self {
if let Err(msg) = confidence.validate() {
panic!("FixProposal invalid confidence: {msg}");
}
Self {
rule,
source,
span,
original: original.into(),
replacement: replacement.into(),
confidence,
migration_ref,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct AppliedFix {
pub proposal: FixProposal,
pub confidence: Confidence,
pub source: FixSource,
pub timestamp: SystemTime,
pub classifier_id: Option<Arc<str>>,
pub dry_run: bool,
pub input: Option<Arc<str>>,
}
impl AppliedFix {
#[doc(hidden)]
pub fn __engine_promote(
proposal: FixProposal,
timestamp: SystemTime,
classifier_id: Option<Arc<str>>,
dry_run: bool,
input: Option<Arc<str>>,
_token: EnginePromotionToken,
) -> Self {
let confidence = proposal.confidence.clone();
let source = proposal.source;
Self {
proposal,
confidence,
source,
timestamp,
classifier_id,
dry_run,
input,
}
}
}
#[derive(Debug)]
pub struct EnginePromotionToken {
_seal: (),
}
impl EnginePromotionToken {
#[doc(hidden)]
#[inline]
pub const fn __engine_construct() -> Self {
Self { _seal: () }
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub rule: RuleId,
pub severity: Severity,
pub span: Span,
pub message: Box<str>,
pub citation: &'static str,
pub fix: Option<FixProposal>,
}
impl Diagnostic {
pub fn new(
rule: RuleId,
severity: Severity,
span: Span,
message: impl Into<Box<str>>,
citation: &'static str,
fix: Option<FixProposal>,
) -> Self {
Self {
rule,
severity,
span,
message: message.into(),
citation,
fix,
}
}
}
pub trait Rule: Send + Sync {
fn id(&self) -> RuleId;
fn name(&self) -> &'static str;
fn default_severity(&self) -> Severity;
fn check(&self, attrs: &IsmAttributes, ctx: &RuleContext) -> Vec<Diagnostic>;
}
pub trait RuleSet: Send + Sync {
fn rules(&self) -> &[Box<dyn Rule>];
fn schema_version(&self) -> &'static str;
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[test]
fn rule_id_round_trip() {
let r = RuleId::new("E001");
assert_eq!(r.as_str(), "E001");
assert_eq!(r.to_string(), "E001");
}
#[test]
fn severity_parse_config_accepts_known_values() {
assert_eq!(Severity::parse_config("off"), Some(Severity::Off));
assert_eq!(Severity::parse_config("suggest"), Some(Severity::Suggest));
assert_eq!(Severity::parse_config("info"), Some(Severity::Info));
assert_eq!(Severity::parse_config("warn"), Some(Severity::Warn));
assert_eq!(Severity::parse_config("error"), Some(Severity::Error));
assert_eq!(Severity::parse_config("fix"), Some(Severity::Fix));
}
#[test]
fn severity_parse_config_is_case_sensitive() {
assert_eq!(Severity::parse_config("OFF"), None);
assert_eq!(Severity::parse_config("Warn"), None);
}
#[test]
fn severity_parse_config_rejects_unknown_strings() {
assert_eq!(Severity::parse_config("err"), None);
assert_eq!(Severity::parse_config("disable"), None);
assert_eq!(Severity::parse_config(""), None);
}
#[test]
fn severity_display_round_trips() {
for s in [
Severity::Off,
Severity::Suggest,
Severity::Info,
Severity::Warn,
Severity::Error,
Severity::Fix,
] {
assert_eq!(Severity::parse_config(s.as_str()), Some(s));
assert_eq!(s.to_string(), s.as_str());
}
}
#[test]
fn severity_ord_off_is_lowest() {
assert!(Severity::Off < Severity::Suggest);
assert!(Severity::Suggest < Severity::Info);
assert!(Severity::Info < Severity::Warn);
assert!(Severity::Warn < Severity::Error);
assert!(Severity::Error < Severity::Fix);
}
#[test]
fn severity_suggest_round_trips_through_config_string() {
assert_eq!(Severity::parse_config("suggest"), Some(Severity::Suggest));
assert_eq!(Severity::Suggest.as_str(), "suggest");
assert_eq!(Severity::Suggest.to_string(), "suggest");
}
#[test]
fn severity_suggest_is_strictly_below_info_in_ord() {
assert!(Severity::Suggest < Severity::Info);
assert!(Severity::Off < Severity::Suggest);
}
#[test]
fn fix_proposal_new_accepts_boundary_confidence() {
let _zero = FixProposal::new(
RuleId::new("E001"),
FixSource::BuiltinRule,
Span::new(0, 0),
"x",
"y",
Confidence::strict(0.0),
None,
);
let _one = FixProposal::new(
RuleId::new("E001"),
FixSource::BuiltinRule,
Span::new(0, 0),
"x",
"y",
Confidence::strict(1.0),
None,
);
}
#[test]
#[should_panic(expected = "Confidence::strict rule confidence")]
fn fix_proposal_new_panics_on_negative_confidence() {
let _ = FixProposal::new(
RuleId::new("E001"),
FixSource::BuiltinRule,
Span::new(0, 0),
"x",
"y",
Confidence::strict(-0.1),
None,
);
}
#[test]
#[should_panic(expected = "Confidence::strict rule confidence")]
fn fix_proposal_new_panics_on_above_one_confidence() {
let _ = FixProposal::new(
RuleId::new("E001"),
FixSource::BuiltinRule,
Span::new(0, 0),
"x",
"y",
Confidence::strict(1.5),
None,
);
}
#[test]
#[should_panic(expected = "Confidence::strict rule confidence")]
fn fix_proposal_new_panics_on_nan_confidence() {
let _ = FixProposal::new(
RuleId::new("E001"),
FixSource::BuiltinRule,
Span::new(0, 0),
"x",
"y",
Confidence::strict(f32::NAN),
None,
);
}
#[test]
fn fix_proposal_new_panics_when_axis_is_nan() {
let bad = Confidence {
recognition: f32::NAN,
rule: 1.0,
region: None,
runner_up_ratio: None,
features: Vec::new(),
};
let caught = std::panic::catch_unwind(|| {
FixProposal::new(
RuleId::new("E001"),
FixSource::BuiltinRule,
Span::new(0, 0),
"x",
"y",
bad,
None,
);
});
assert!(
caught.is_err(),
"expected FixProposal::new to panic on NaN recognition axis"
);
}
#[test]
fn fix_proposal_new_panics_when_axis_out_of_range() {
let bad = Confidence {
recognition: 2.0,
rule: 0.4,
region: None,
runner_up_ratio: None,
features: Vec::new(),
};
assert!((0.0..=1.0).contains(&bad.combined()));
let caught = std::panic::catch_unwind(|| {
FixProposal::new(
RuleId::new("E001"),
FixSource::BuiltinRule,
Span::new(0, 0),
"x",
"y",
bad,
None,
);
});
assert!(
caught.is_err(),
"expected FixProposal::new to panic on out-of-range recognition axis"
);
}
#[test]
fn fix_proposal_new_panics_when_feature_delta_is_nan() {
let bad = Confidence {
recognition: 0.9,
rule: 0.9,
region: None,
runner_up_ratio: None,
features: vec![FeatureContribution {
id: FeatureId::EditDistance1,
delta: f32::NAN,
}],
};
let caught = std::panic::catch_unwind(|| {
FixProposal::new(
RuleId::new("E001"),
FixSource::BuiltinRule,
Span::new(0, 0),
"x",
"y",
bad,
None,
);
});
assert!(
caught.is_err(),
"expected FixProposal::new to panic on NaN feature delta"
);
}
#[test]
fn fix_proposal_new_accepts_runner_up_ratio_above_one() {
let ok = Confidence {
recognition: 0.9,
rule: 0.9,
region: None,
runner_up_ratio: Some(3.5),
features: Vec::new(),
};
let _ = FixProposal::new(
RuleId::new("E001"),
FixSource::BuiltinRule,
Span::new(0, 0),
"x",
"y",
ok,
None,
);
}
}