use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Criterion {
Title(String),
Class(String),
RegexpTitle(String),
RegexpClass(String),
X(i32),
Y(i32),
W(i32),
H(i32),
Instance(u32),
Last,
Active,
All,
Handle(u64),
Pid(u32),
}
impl Criterion {
const fn key(&self) -> &'static str {
match self {
Self::Title(_) => "TITLE",
Self::Class(_) => "CLASS",
Self::RegexpTitle(_) => "REGEXPTITLE",
Self::RegexpClass(_) => "REGEXPCLASS",
Self::X(_) => "X",
Self::Y(_) => "Y",
Self::W(_) => "W",
Self::H(_) => "H",
Self::Instance(_) => "INSTANCE",
Self::Last => "LAST",
Self::Active => "ACTIVE",
Self::All => "ALL",
Self::Handle(_) => "HANDLE",
Self::Pid(_) => "PID",
}
}
}
impl fmt::Display for Criterion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let key = self.key();
match self {
Self::Last | Self::Active | Self::All => f.write_str(key),
Self::Title(v) | Self::Class(v) | Self::RegexpTitle(v) | Self::RegexpClass(v) => {
write!(f, "{key}:{v}")
}
Self::X(v) | Self::Y(v) | Self::W(v) | Self::H(v) => write!(f, "{key}:{v}"),
Self::Instance(v) => write!(f, "{key}:{v}"),
Self::Pid(v) => write!(f, "{key}:{v}"),
Self::Handle(v) => write!(f, "{key}:{v:x}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Kind {
BareTitle(String),
Advanced(Vec<Criterion>),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Selector(Kind);
impl Selector {
#[must_use]
pub fn title(t: impl Into<String>) -> Self {
Self(Kind::BareTitle(t.into()))
}
#[must_use]
pub fn active() -> Self {
Self(Kind::Advanced(vec![Criterion::Active]))
}
#[must_use]
pub fn handle(h: u64) -> Self {
Self(Kind::Advanced(vec![Criterion::Handle(h)]))
}
#[must_use]
pub fn builder() -> SelectorBuilder {
SelectorBuilder::default()
}
#[must_use]
pub fn criteria(&self) -> Option<&[Criterion]> {
match &self.0 {
Kind::Advanced(c) => Some(c),
Kind::BareTitle(_) => None,
}
}
#[must_use]
pub fn bare_title(&self) -> Option<&str> {
match &self.0 {
Kind::BareTitle(t) => Some(t),
Kind::Advanced(_) => None,
}
}
#[must_use]
pub fn is_active(&self) -> bool {
matches!(&self.0, Kind::Advanced(c) if c.contains(&Criterion::Active))
}
}
impl fmt::Display for Selector {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.0 {
Kind::BareTitle(t) => f.write_str(t),
Kind::Advanced(criteria) => {
f.write_str("[")?;
for (i, c) in criteria.iter().enumerate() {
if i > 0 {
f.write_str(";")?;
}
write!(f, "{c}")?;
}
f.write_str("]")
}
}
}
}
impl From<&str> for Selector {
fn from(s: &str) -> Self {
s.parse().unwrap_or_else(|_| Self::title(s))
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SelectorError {
#[error("unknown selector property {name:?}")]
UnknownProperty {
name: String,
},
#[error("property {name} {problem}")]
BadValue {
name: String,
problem: String,
},
#[error(
"selector value {value:?} contains {ch:?}, which AutoIt cannot escape \
inside a selector — match on a different property instead"
)]
UnescapableChar {
value: String,
ch: char,
},
#[error("selector has no criteria")]
Empty,
#[error("not advanced selector syntax: expected a leading '[' and trailing ']'")]
NotAdvanced,
}
fn check_value(value: &str) -> Result<(), SelectorError> {
for ch in [';', ']'] {
if value.contains(ch) {
return Err(SelectorError::UnescapableChar {
value: value.to_owned(),
ch,
});
}
}
Ok(())
}
impl FromStr for Selector {
type Err = SelectorError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let inner = s
.strip_prefix('[')
.and_then(|r| r.strip_suffix(']'))
.ok_or(SelectorError::NotAdvanced)?;
if inner.trim().is_empty() {
return Err(SelectorError::Empty);
}
let mut criteria = Vec::new();
for part in inner.split(';') {
let part = part.trim();
if part.is_empty() {
continue;
}
let (name, value) = match part.split_once(':') {
Some((n, v)) => (n.trim(), Some(v)),
None => (part, None),
};
criteria.push(parse_criterion(name, value)?);
}
if criteria.is_empty() {
return Err(SelectorError::Empty);
}
Ok(Self(Kind::Advanced(criteria)))
}
}
fn parse_criterion(name: &str, value: Option<&str>) -> Result<Criterion, SelectorError> {
let upper = name.to_ascii_uppercase();
let need_value = || -> Result<&str, SelectorError> {
value.ok_or_else(|| SelectorError::BadValue {
name: upper.clone(),
problem: "requires a value".to_owned(),
})
};
let need_int = |v: &str| -> Result<i32, SelectorError> {
v.trim()
.parse::<i32>()
.map_err(|_| SelectorError::BadValue {
name: upper.clone(),
problem: format!("expected an integer, got {v:?}"),
})
};
Ok(match upper.as_str() {
"TITLE" => Criterion::Title(need_value()?.to_owned()),
"CLASS" => Criterion::Class(need_value()?.to_owned()),
"REGEXPTITLE" => Criterion::RegexpTitle(need_value()?.to_owned()),
"REGEXPCLASS" => Criterion::RegexpClass(need_value()?.to_owned()),
"X" => Criterion::X(need_int(need_value()?)?),
"Y" => Criterion::Y(need_int(need_value()?)?),
"W" => Criterion::W(need_int(need_value()?)?),
"H" => Criterion::H(need_int(need_value()?)?),
"INSTANCE" => {
let v = need_value()?;
Criterion::Instance(v.trim().parse().map_err(|_| SelectorError::BadValue {
name: upper.clone(),
problem: format!("expected a positive integer, got {v:?}"),
})?)
}
"PID" => {
let v = need_value()?;
Criterion::Pid(v.trim().parse().map_err(|_| SelectorError::BadValue {
name: upper.clone(),
problem: format!("expected a process id, got {v:?}"),
})?)
}
"HANDLE" => {
let v = need_value()?.trim();
let hex = v
.strip_prefix("0x")
.or_else(|| v.strip_prefix("0X"))
.unwrap_or(v);
Criterion::Handle(u64::from_str_radix(hex, 16).map_err(|_| {
SelectorError::BadValue {
name: upper.clone(),
problem: format!("expected a hex handle, got {v:?}"),
}
})?)
}
"LAST" => Criterion::Last,
"ACTIVE" => Criterion::Active,
"ALL" => Criterion::All,
_ => {
return Err(SelectorError::UnknownProperty {
name: name.to_owned(),
});
}
})
}
#[derive(Debug, Default, Clone)]
pub struct SelectorBuilder {
criteria: Vec<Criterion>,
error: Option<SelectorError>,
}
macro_rules! string_criterion {
($(#[$m:meta])* $method:ident => $variant:ident) => {
$(#[$m])*
#[must_use]
pub fn $method(mut self, v: impl Into<String>) -> Self {
let v = v.into();
match check_value(&v) {
Ok(()) => self.criteria.push(Criterion::$variant(v)),
Err(e) => {
self.error.get_or_insert(e);
}
}
self
}
};
}
impl SelectorBuilder {
string_criterion!(
title => Title
);
string_criterion!(
class => Class
);
string_criterion!(
regexp_title => RegexpTitle
);
string_criterion!(
regexp_class => RegexpClass
);
#[must_use]
pub fn instance(mut self, n: u32) -> Self {
self.criteria.push(Criterion::Instance(n));
self
}
#[must_use]
pub fn pid(mut self, pid: u32) -> Self {
self.criteria.push(Criterion::Pid(pid));
self
}
#[must_use]
pub fn criterion(mut self, c: Criterion) -> Self {
self.criteria.push(c);
self
}
pub fn build(self) -> Result<Selector, SelectorError> {
if let Some(e) = self.error {
return Err(e);
}
if self.criteria.is_empty() {
return Err(SelectorError::Empty);
}
Ok(Selector(Kind::Advanced(self.criteria)))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectorSet {
windows: Selector,
macos: Selector,
}
impl SelectorSet {
#[must_use]
pub const fn new(windows: Selector, macos: Selector) -> Self {
Self { windows, macos }
}
#[must_use]
pub const fn current(&self) -> &Selector {
#[cfg(windows)]
{
&self.windows
}
#[cfg(not(windows))]
{
&self.macos
}
}
#[must_use]
pub const fn windows(&self) -> &Selector {
&self.windows
}
#[must_use]
pub const fn macos(&self) -> &Selector {
&self.macos
}
}
#[cfg(test)]
mod tests {
use super::*;
const PRODUCTION_SHAPES: &[&str] = &[
"[CLASS:Chrome_WidgetWin_1]",
"[CLASS:Chrome_WidgetWin_1;TITLE:Acme ERP]",
"[CLASS:Chrome_WidgetWin_1;TITLE:Acme Invoices]",
"[TITLE:Acme ERP;CLASS:ui60Modal_W32]",
"[REGEXPTITLE:Acme - (.*)Invoice(.*)]",
"[REGEXPTITLE:Acme - (.*)Picking(.*)]",
"[REGEXPTITLE:Acme - NORTHWIND(.*)Receiving(.*)]",
"[REGEXPTITLE:Cancel Invoiced Items(.*)]",
"[REGEXPTITLE:DevTools - (.*)]",
"[ACTIVE]",
];
#[test]
fn every_real_selector_parses_and_round_trips() {
for s in PRODUCTION_SHAPES {
let sel: Selector = s.parse().unwrap_or_else(|e| panic!("{s:?}: {e}"));
assert_eq!(&sel.to_string(), s, "round trip changed {s:?}");
assert_eq!(
sel.to_string().parse::<Selector>().unwrap(),
sel,
"reparse differs for {s:?}"
);
}
}
#[test]
fn property_names_are_case_insensitive() {
let upper: Selector = "[ACTIVE]".parse().unwrap();
let lower: Selector = "[active]".parse().unwrap();
assert_eq!(upper, lower);
assert!(lower.is_active());
let mixed: Selector = "[Class:Foo;title:Bar]".parse().unwrap();
assert_eq!(mixed.to_string(), "[CLASS:Foo;TITLE:Bar]");
}
#[test]
fn a_bare_title_stays_a_bare_title() {
let s = Selector::from("Order Entry");
assert_eq!(s.bare_title(), Some("Order Entry"));
assert_eq!(s.criteria(), None);
assert_eq!(s.to_string(), "Order Entry");
}
#[test]
fn criterion_order_is_preserved() {
let a: Selector = "[CLASS:X;TITLE:Y]".parse().unwrap();
let b: Selector = "[TITLE:Y;CLASS:X]".parse().unwrap();
assert_ne!(a.to_string(), b.to_string());
assert_eq!(a.to_string(), "[CLASS:X;TITLE:Y]");
}
#[test]
fn unescapable_characters_are_rejected_rather_than_emitted() {
let err = Selector::builder().title("A;B").build().unwrap_err();
assert!(matches!(
err,
SelectorError::UnescapableChar { ch: ';', .. }
));
let err = Selector::builder().class("A]B").build().unwrap_err();
assert!(matches!(
err,
SelectorError::UnescapableChar { ch: ']', .. }
));
}
#[test]
fn malformed_advanced_syntax_is_rejected() {
for s in ["[NOPE:1]", "[]", "[TITLE]", "[X:abc]", "[INSTANCE:-1]"] {
assert!(s.parse::<Selector>().is_err(), "should not parse: {s:?}");
}
}
#[test]
fn from_str_falls_back_to_a_title_for_non_bracket_strings() {
assert_eq!(Selector::from("Forms").bare_title(), Some("Forms"));
assert_eq!(Selector::from("[NOPE:1]").bare_title(), Some("[NOPE:1]"));
assert!("[NOPE:1]".parse::<Selector>().is_err());
}
#[test]
fn handles_render_as_hex_without_a_prefix() {
let s = Selector::handle(0x0004_0B1E);
assert_eq!(s.to_string(), "[HANDLE:40b1e]");
assert_eq!(s.to_string().parse::<Selector>().unwrap(), s);
assert_eq!("[HANDLE:0x40b1e]".parse::<Selector>().unwrap(), s);
}
#[test]
fn builder_produces_the_same_string_as_the_parser() {
let built = Selector::builder()
.class("Chrome_WidgetWin_1")
.title("Acme Invoices")
.build()
.unwrap();
let parsed: Selector = "[CLASS:Chrome_WidgetWin_1;TITLE:Acme Invoices]"
.parse()
.unwrap();
assert_eq!(built, parsed);
}
#[test]
fn selector_set_picks_by_platform() {
let set = SelectorSet::new(
Selector::from("[CLASS:Chrome_WidgetWin_1]"),
Selector::from("[CLASS:com.google.Chrome]"),
);
if cfg!(windows) {
assert_eq!(set.current(), set.windows());
} else {
assert_eq!(set.current(), set.macos());
}
}
}