use std::cmp::Ordering;
use std::fmt;
use std::str;
use crate::Error;
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
enum SuffixKind {
Next,
ReleaseCandidate(u16),
Final,
Patch(u8),
}
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ReleaseSuffix(SuffixKind);
impl ReleaseSuffix {
pub const RELEASE_CANDIDATE: Self = Self(SuffixKind::ReleaseCandidate(1));
pub const FINAL: Self = Self(SuffixKind::Final);
pub(crate) const NEXT: Self = Self(SuffixKind::Next);
pub(crate) const RELEASE_CANDIDATE_2: Self = Self(SuffixKind::ReleaseCandidate(2));
pub(crate) const PATCH_A: Self = Self(SuffixKind::Patch(b'a'));
#[must_use]
pub const fn release_candidate(number: u16) -> Option<Self> {
if number == 0 {
None
} else {
Some(Self(SuffixKind::ReleaseCandidate(number)))
}
}
#[must_use]
pub fn patch(letter: char) -> Option<Self> {
if !letter.is_ascii_lowercase() {
return None;
}
u8::try_from(letter as u32)
.ok()
.map(|letter| Self(SuffixKind::Patch(letter)))
}
#[must_use]
pub fn patch_letter(self) -> Option<char> {
match self.0 {
SuffixKind::Patch(letter) => Some(char::from(letter)),
SuffixKind::Next | SuffixKind::ReleaseCandidate(_) | SuffixKind::Final => None,
}
}
#[must_use]
pub const fn release_candidate_number(self) -> Option<u16> {
match self.0 {
SuffixKind::ReleaseCandidate(number) => Some(number),
SuffixKind::Next | SuffixKind::Final | SuffixKind::Patch(_) => None,
}
}
#[must_use]
pub const fn is_final(self) -> bool {
matches!(self.0, SuffixKind::Final)
}
#[must_use]
pub const fn is_release_candidate(self) -> bool {
matches!(self.0, SuffixKind::ReleaseCandidate(_))
}
}
impl fmt::Debug for ReleaseSuffix {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
SuffixKind::ReleaseCandidate(number) => formatter
.debug_tuple("ReleaseCandidate")
.field(&number)
.finish(),
SuffixKind::Next => formatter.write_str("Next"),
SuffixKind::Final => formatter.write_str("Final"),
SuffixKind::Patch(letter) => formatter
.debug_tuple("Patch")
.field(&char::from(letter))
.finish(),
}
}
}
impl fmt::Display for ReleaseSuffix {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
SuffixKind::Next => formatter.write_str("-next"),
SuffixKind::ReleaseCandidate(1) => formatter.write_str("-rc"),
SuffixKind::ReleaseCandidate(number) => write!(formatter, "-rc{number}"),
SuffixKind::Final => Ok(()),
SuffixKind::Patch(letter) => write!(formatter, "{}", char::from(letter)),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ReleaseVersion {
major: u16,
minor: u16,
suffix: ReleaseSuffix,
}
impl ReleaseVersion {
#[must_use]
pub const fn new(major: u16, minor: u16, suffix: ReleaseSuffix) -> Self {
Self {
major,
minor,
suffix,
}
}
#[must_use]
pub const fn major(self) -> u16 {
self.major
}
#[must_use]
pub const fn minor(self) -> u16 {
self.minor
}
#[must_use]
pub const fn suffix(self) -> ReleaseSuffix {
self.suffix
}
}
impl fmt::Display for ReleaseVersion {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}.{}{}", self.major, self.minor, self.suffix)
}
}
#[derive(Clone, Debug)]
pub struct TmuxVersion {
raw: Box<str>,
release: Option<ReleaseVersion>,
}
const MAX_REPORTED_TOKEN: usize = 64;
impl TmuxVersion {
pub const MIN_SUPPORTED: ReleaseVersion = ReleaseVersion::new(3, 3, ReleaseSuffix::PATCH_A);
pub fn parse_output(output: &[u8]) -> Result<Self, Error> {
let raw = parse_output_frame(output)
.ok_or_else(|| Error::from_invalid_version_output(output.len()))?;
let release = match raw.as_bytes().first() {
Some(first) if first.is_ascii_digit() => Some(
parse_release(raw)
.ok_or_else(|| Error::from_invalid_version_output(output.len()))?,
),
Some(_) if is_development_identifier(raw) => None,
_ => return Err(Error::from_invalid_version_output(output.len())),
};
Ok(Self {
raw: raw.into(),
release,
})
}
pub(crate) fn parse_report(output: &[u8], reported_len: usize) -> Result<Self, Error> {
match Self::parse_output(output) {
Err(Error::InvalidVersionOutput { .. }) => {
match parse_output_frame(output).filter(|token| {
token.len() <= MAX_REPORTED_TOKEN && token.bytes().all(|b| b.is_ascii_graphic())
}) {
Some(token) => Err(Error::unrecognized_tmux_version(Self {
raw: token.into(),
release: None,
})),
None => Err(Error::from_invalid_version_output(reported_len)),
}
}
other => other.map_err(|error| match error {
Error::InvalidVersionOutput { .. } => {
Error::from_invalid_version_output(reported_len)
}
other => other,
}),
}
}
#[must_use]
pub fn raw(&self) -> &str {
&self.raw
}
#[must_use]
pub const fn release(&self) -> Option<&ReleaseVersion> {
self.release.as_ref()
}
#[must_use]
pub const fn is_development(&self) -> bool {
self.release.is_none()
}
pub(crate) fn behavior_release(&self) -> Option<ReleaseVersion> {
self.release.or_else(|| parse_next_release(&self.raw))
}
#[must_use]
pub fn meets(&self, required: &ReleaseVersion) -> bool {
match self.release {
Some(release) => release >= *required,
None if *required > Self::MIN_SUPPORTED => false,
None if self.raw.as_ref() == "master" => true,
None => parse_next_release(&self.raw).is_some_and(|release| release >= *required),
}
}
fn behavior_satisfies(&self, needs: ReleaseVersion) -> bool {
self.behavior_release()
.is_none_or(|release| release >= needs)
}
#[must_use]
pub fn has_behavior(&self, needs: &ReleaseVersion) -> bool {
self.behavior_satisfies(*needs)
}
pub(crate) fn require(
&self,
capability: &'static str,
needs: ReleaseVersion,
) -> Result<(), Error> {
if !self.behavior_satisfies(needs) {
return Err(Error::UnsupportedCapability {
capability,
needs,
found: self.clone(),
});
}
Ok(())
}
pub fn ensure_supported(&self) -> Result<(), Error> {
match self.release.or_else(|| parse_next_release(&self.raw)) {
Some(release) if release >= Self::MIN_SUPPORTED => Ok(()),
Some(_) => Err(Error::unsupported_tmux_version(
self.clone(),
Self::MIN_SUPPORTED,
)),
None => Err(Error::unrecognized_tmux_version(self.clone())),
}
}
pub(crate) fn older_than_probe() -> Self {
Self {
raw: "older than 3.2".into(),
release: None,
}
}
}
impl fmt::Display for TmuxVersion {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.raw)
}
}
impl PartialEq for TmuxVersion {
fn eq(&self, other: &Self) -> bool {
match (self.release, other.release) {
(Some(left), Some(right)) => left == right,
(None, None) => self.raw == other.raw,
(Some(_), None) | (None, Some(_)) => false,
}
}
}
impl Eq for TmuxVersion {}
impl PartialOrd for TmuxVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
match (self.release, other.release) {
(Some(left), Some(right)) => Some(left.cmp(&right)),
(None, None) if self.raw == other.raw => Some(Ordering::Equal),
_ => None,
}
}
}
fn parse_release(raw: &str) -> Option<ReleaseVersion> {
let (major, remainder) = raw.split_once('.')?;
let minor_end = remainder
.find(|character: char| !character.is_ascii_digit())
.unwrap_or(remainder.len());
let (minor, suffix) = remainder.split_at(minor_end);
let major = parse_u16_component(major)?;
let minor = parse_u16_component(minor)?;
let suffix = parse_release_suffix(suffix)?;
Some(ReleaseVersion::new(major, minor, suffix))
}
fn is_development_identifier(raw: &str) -> bool {
raw == "master" || parse_next_release(raw).is_some()
}
fn parse_next_release(raw: &str) -> Option<ReleaseVersion> {
let (major, minor) = raw.strip_prefix("next-")?.split_once('.')?;
Some(ReleaseVersion::new(
parse_u16_component(major)?,
parse_u16_component(minor)?,
ReleaseSuffix::NEXT,
))
}
fn parse_release_suffix(suffix: &str) -> Option<ReleaseSuffix> {
if suffix.is_empty() {
return Some(ReleaseSuffix::FINAL);
}
if suffix == "-rc" {
return Some(ReleaseSuffix::RELEASE_CANDIDATE);
}
if let Some(number) = suffix.strip_prefix("-rc") {
return ReleaseSuffix::release_candidate(parse_u16_component(number)?);
}
match suffix.as_bytes() {
[letter] if letter.is_ascii_lowercase() => Some(ReleaseSuffix(SuffixKind::Patch(*letter))),
_ => None,
}
}
fn parse_u16_component(component: &str) -> Option<u16> {
if component.is_empty()
|| (component.len() > 1 && component.starts_with('0'))
|| !component.bytes().all(|byte| byte.is_ascii_digit())
{
return None;
}
component.parse().ok()
}
fn parse_output_frame(output: &[u8]) -> Option<&str> {
let line = output.strip_suffix(b"\n")?;
let separator = line.iter().position(|byte| *byte == b' ')?;
let program = line.get(..separator)?;
let version = line.get(separator + 1..)?;
if program.is_empty()
|| version.is_empty()
|| program
.iter()
.any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control())
|| version
.iter()
.any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control())
{
return None;
}
str::from_utf8(version).ok()
}
pub mod since {
use super::{ReleaseSuffix, ReleaseVersion};
pub const PANE_BORDER_STYLE_PER_PANE: ReleaseVersion =
ReleaseVersion::new(3, 7, ReleaseSuffix::FINAL);
pub const CAPTURE_LINE_FLAGS: ReleaseVersion = ReleaseVersion::new(3, 7, ReleaseSuffix::FINAL);
pub const LIST_KEYS_FORMAT: ReleaseVersion = ReleaseVersion::new(3, 7, ReleaseSuffix::FINAL);
pub const CONTROL_PANE_OFF: ReleaseVersion = ReleaseVersion::new(3, 7, ReleaseSuffix::FINAL);
pub const CAPTURE_TRIM_BLANK_CELLS: ReleaseVersion =
ReleaseVersion::new(3, 4, ReleaseSuffix::FINAL);
pub const CLIENTS_HIDE_STOPPED: ReleaseVersion =
ReleaseVersion::new(3, 7, ReleaseSuffix::FINAL);
pub const MIRRORED_LAYOUTS: ReleaseVersion = ReleaseVersion::new(3, 5, ReleaseSuffix::FINAL);
pub const JSON_LAYOUTS: ReleaseVersion =
ReleaseVersion::new(3, 8, ReleaseSuffix::RELEASE_CANDIDATE_2);
}
#[cfg(test)]
mod grammar_tests {
use super::{MAX_REPORTED_TOKEN, ReleaseVersion, TmuxVersion};
use crate::Error;
fn parsed(raw: &str) -> Result<TmuxVersion, Error> {
TmuxVersion::parse_output(format!("tmux {raw}\n").as_bytes())
}
fn report(raw: &str) -> Result<TmuxVersion, Error> {
let output = format!("tmux {raw}\n");
TmuxVersion::parse_report(output.as_bytes(), output.len())
}
fn rank(raw: &str) -> ReleaseVersion {
parsed(raw)
.unwrap_or_else(|error| panic!("{raw}: {error:?}"))
.behavior_release()
.unwrap_or_else(|| panic!("{raw} names no release"))
}
#[test]
fn the_canonical_grammar_accepts_refuses_and_orders() {
for raw in [
"3.3a", "3.4", "3.7d", "3.8", "3.10", "4.0", "3.4-rc", "3.8-rc", "3.8-rc2", "next-3.4",
"next-3.9",
] {
let version = parsed(raw).unwrap_or_else(|error| panic!("{raw}: {error:?}"));
assert!(version.ensure_supported().is_ok(), "{raw} is supported");
}
for raw in ["3.3", "3.2a", "3.3-rc", "3.2-rc5", "next-3.3"] {
let version = parsed(raw).unwrap_or_else(|error| panic!("{raw}: {error:?}"));
assert!(
matches!(
version.ensure_supported(),
Err(Error::UnsupportedTmuxVersion { .. })
),
"{raw} is below the floor"
);
}
for raw in [
"master",
"openbsd-7.6",
"3",
"garbage",
"3.3a-master",
"3.3a-dev",
"next-3.3a",
"next-3.9-master",
] {
let outcome = report(raw).and_then(|version| version.ensure_supported());
assert!(
matches!(outcome, Err(Error::UnrecognizedTmuxVersion { .. })),
"{raw} is unrecognized: {outcome:?}"
);
}
let long = "x".repeat(MAX_REPORTED_TOKEN + 1);
let at_limit = "x".repeat(MAX_REPORTED_TOKEN);
for output in [
b"tmux \n".to_vec(),
b"tmux 3.3a garbage\n".to_vec(),
b"tmux 3.3a".to_vec(),
b"3.3a\n".to_vec(),
format!("tmux {long}\n").into_bytes(),
"tmux 3.3\u{e9}\n".as_bytes().to_vec(),
b"tmux 3.3\x01\n".to_vec(),
] {
let outcome = TmuxVersion::parse_report(&output, 7);
assert!(
matches!(outcome, Err(Error::InvalidVersionOutput { output_len: 7 })),
"{}: {outcome:?}",
output.escape_ascii()
);
}
let outcome = TmuxVersion::parse_report(format!("tmux {at_limit}\n").as_bytes(), 0);
assert!(
matches!(outcome, Err(Error::UnrecognizedTmuxVersion { .. })),
"a token at the limit is kept: {outcome:?}"
);
for chain in [
&["next-3.7", "3.7-rc", "3.7-rc3", "3.7", "3.7a"][..],
&["next-3.4", "3.4-rc", "3.4"],
&["3.9", "3.10"],
] {
for pair in chain.windows(2) {
assert!(
rank(pair[0]) < rank(pair[1]),
"{} ranks below {}",
pair[0],
pair[1]
);
}
}
let control = ReleaseVersion::new(3, 7, crate::ReleaseSuffix::FINAL);
for (raw, allowed) in [
("3.8-rc2", true),
("next-3.8", true),
("3.7", true),
("3.7d", true),
("next-3.7", false),
("3.7-rc2", false),
("3.6b", false),
] {
let version = parsed(raw).unwrap_or_else(|error| panic!("{raw}: {error:?}"));
assert_eq!(
version.has_behavior(&control),
allowed,
"{raw} control acquisition"
);
}
}
}