use std::fmt;
use std::str::FromStr;
use crate::ObjectKind;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Revision {
base: Base,
steps: Vec<Step>,
path: Option<String>,
}
impl Revision {
pub fn head() -> Self {
Self {
base: Base::Head,
steps: Vec::new(),
path: None,
}
}
pub fn base(&self) -> &Base {
&self.base
}
pub fn steps(&self) -> &[Step] {
&self.steps
}
pub fn path(&self) -> Option<&str> {
self.path.as_deref()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Base {
Head,
Name(String),
Reflog {
name: Option<String>,
index: usize,
},
Upstream {
branch: Option<String>,
},
Push {
branch: Option<String>,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Step {
Ancestor(usize),
Parent(usize),
Peel(Peel),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Peel {
Tags,
Object,
Kind(ObjectKind),
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Range {
Include(Revision),
Exclude(Revision),
Between {
from: Revision,
to: Revision,
},
Symmetric {
left: Revision,
right: Revision,
},
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[error("invalid revision `{text}`: {reason}")]
pub struct ParseRevisionError {
text: String,
reason: Reason,
}
impl ParseRevisionError {
fn new(text: &str, reason: Reason) -> Self {
Self {
text: text.to_owned(),
reason,
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn is_unsupported(&self) -> bool {
matches!(self.reason, Reason::Unsupported(_))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Reason {
Empty,
Malformed(&'static str),
Unsupported(&'static str),
}
impl fmt::Display for Reason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("empty revision"),
Self::Malformed(what) => f.write_str(what),
Self::Unsupported(what) => write!(f, "{what} are not supported yet"),
}
}
}
impl FromStr for Revision {
type Err = ParseRevisionError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let error = |reason| ParseRevisionError::new(text, reason);
let (expression, path) = match path_separator(text) {
Some(0) => {
return Err(error(Reason::Unsupported(
"index paths and message searches (`:path`, `:/text`)",
)));
}
Some(colon) => (&text[..colon], Some(&text[colon + 1..])),
None => (text, None),
};
if let Some(path) = path
&& (path.starts_with("./") || path.starts_with("../") || path == "." || path == "..")
{
return Err(error(Reason::Unsupported(
"paths relative to the working directory",
)));
}
let end = expression
.find(['~', '^'])
.into_iter()
.chain(expression.find("@{"))
.min()
.unwrap_or(expression.len());
let (name, mut rest) = expression.split_at(end);
let base = if let Some(selector) = rest.strip_prefix("@{") {
let close = selector
.find('}')
.ok_or_else(|| error(Reason::Malformed("unclosed `@{`")))?;
rest = &selector[close + 1..];
let name = match name {
"" | "@" => None,
name => Some(name.to_owned()),
};
at_selector(&selector[..close], name).map_err(error)?
} else {
match name {
"" => return Err(error(Reason::Empty)),
"@" => Base::Head,
name => Base::Name(name.to_owned()),
}
};
let steps = steps(rest).map_err(error)?;
Ok(Self {
base,
steps,
path: path.map(str::to_owned),
})
}
}
impl FromStr for Range {
type Err = ParseRevisionError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
if let Some(excluded) = text.strip_prefix('^') {
return Ok(Self::Exclude(excluded.parse()?));
}
let range_end = path_separator(text).unwrap_or(text.len());
let side = |side: &str| {
if side.is_empty() {
Ok(Revision::head())
} else {
side.parse()
}
};
if let Some(dots) = text[..range_end].find("..") {
let (left, right) = (&text[..dots], &text[dots + 2..]);
if let Some(right) = right.strip_prefix('.') {
return Ok(Self::Symmetric {
left: side(left)?,
right: side(right)?,
});
}
return Ok(Self::Between {
from: side(left)?,
to: side(right)?,
});
}
if text.ends_with("^@") || text.ends_with("^!") || text.contains("^-") {
return Err(ParseRevisionError::new(
text,
Reason::Unsupported("`^@`, `^!` and `^-` parent shorthands"),
));
}
Ok(Self::Include(text.parse()?))
}
}
fn path_separator(text: &str) -> Option<usize> {
let mut depth = 0usize;
for (index, byte) in text.bytes().enumerate() {
match byte {
b'{' => depth += 1,
b'}' => depth = depth.saturating_sub(1),
b':' if depth == 0 => return Some(index),
_ => {}
}
}
None
}
fn at_selector(selector: &str, name: Option<String>) -> Result<Base, Reason> {
if selector.eq_ignore_ascii_case("u") || selector.eq_ignore_ascii_case("upstream") {
return Ok(Base::Upstream { branch: name });
}
if selector.eq_ignore_ascii_case("push") {
return Ok(Base::Push { branch: name });
}
if selector.starts_with('-') {
return Err(Reason::Unsupported("previous checkouts (`@{-N}`)"));
}
if !selector.is_empty() && selector.bytes().all(|b| b.is_ascii_digit()) {
let index = selector
.parse()
.map_err(|_| Reason::Malformed("reflog index out of range"))?;
return Ok(Base::Reflog { name, index });
}
Err(Reason::Unsupported("dated reflog selectors (`@{date}`)"))
}
fn steps(mut rest: &str) -> Result<Vec<Step>, Reason> {
let mut steps = Vec::new();
while let Some(operator) = rest.chars().next() {
rest = &rest[operator.len_utf8()..];
if operator == '^'
&& let Some(inner) = rest.strip_prefix('{')
{
let close = inner.find('}').ok_or(Reason::Malformed("unclosed `^{`"))?;
let peel = match &inner[..close] {
"" => Peel::Tags,
"object" => Peel::Object,
"commit" => Peel::Kind(ObjectKind::Commit),
"tree" => Peel::Kind(ObjectKind::Tree),
"blob" => Peel::Kind(ObjectKind::Blob),
"tag" => Peel::Kind(ObjectKind::Tag),
text if text.starts_with('/') => {
return Err(Reason::Unsupported("message searches (`^{/text}`)"));
}
_ => return Err(Reason::Malformed("unknown `^{...}` object kind")),
};
steps.push(Step::Peel(peel));
rest = &inner[close + 1..];
continue;
}
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
let count = if digits == 0 {
1
} else {
rest[..digits]
.parse()
.map_err(|_| Reason::Malformed("step count out of range"))?
};
rest = &rest[digits..];
steps.push(match operator {
'~' => Step::Ancestor(count),
'^' => Step::Parent(count),
'@' | '!' | '-' => {
return Err(Reason::Unsupported("`^@`, `^!` and `^-` parent shorthands"));
}
_ => return Err(Reason::Malformed("expected `~`, `^` or `:` after a name")),
});
}
Ok(steps)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(text: &str) -> Revision {
text.parse().unwrap()
}
fn name(text: &str) -> Base {
Base::Name(text.to_owned())
}
#[test]
fn parses_steps_in_order() {
let revision = parse("v1.0^{}~3^2^^{tree}");
assert_eq!(revision.base(), &name("v1.0"));
assert_eq!(
revision.steps(),
[
Step::Peel(Peel::Tags),
Step::Ancestor(3),
Step::Parent(2),
Step::Parent(1),
Step::Peel(Peel::Kind(ObjectKind::Tree)),
]
);
assert_eq!(parse("HEAD~").steps(), [Step::Ancestor(1)]);
assert_eq!(parse("HEAD^0").steps(), [Step::Parent(0)]);
}
#[test]
fn parses_at_forms() {
assert_eq!(parse("@").base(), &Base::Head);
assert_eq!(parse("@~2").steps(), [Step::Ancestor(2)]);
assert_eq!(
parse("@{2}").base(),
&Base::Reflog {
name: None,
index: 2
}
);
assert_eq!(
parse("main@{0}~1").base(),
&Base::Reflog {
name: Some("main".into()),
index: 0
}
);
assert_eq!(parse("@{U}").base(), &Base::Upstream { branch: None });
assert_eq!(
parse("topic@{upstream}").base(),
&Base::Upstream {
branch: Some("topic".into())
}
);
assert_eq!(parse("@{push}").base(), &Base::Push { branch: None });
}
#[test]
fn parses_paths() {
assert_eq!(parse("HEAD:").path(), Some(""));
assert_eq!(parse("HEAD^{tree}:a/b").path(), Some("a/b"));
assert_eq!(parse("main:a..b").path(), Some("a..b"));
}
#[test]
fn parses_ranges() {
let range = |text: &str| text.parse::<Range>().unwrap();
assert_eq!(range("^main"), Range::Exclude(parse("main")));
assert_eq!(
range("a..b"),
Range::Between {
from: parse("a"),
to: parse("b")
}
);
assert_eq!(
range("..b"),
Range::Between {
from: Revision::head(),
to: parse("b")
}
);
assert_eq!(
range("a..."),
Range::Symmetric {
left: parse("a"),
right: Revision::head()
}
);
assert_eq!(range("main:x..y"), Range::Include(parse("main:x..y")));
}
#[test]
fn rejects_malformed_and_unsupported() {
for text in [
"",
"~1",
"main^{",
"main@{1",
"main^{nope}",
"main~x",
"HEAD~é",
] {
let error = text.parse::<Revision>().unwrap_err();
assert!(!error.is_unsupported(), "{text}: {error}");
}
for text in [
":path",
":/fix",
"main^{/fix}",
"@{-1}",
"main@{yesterday}",
"HEAD:./x",
] {
let error = text.parse::<Revision>().unwrap_err();
assert!(error.is_unsupported(), "{text}: {error}");
}
for text in ["main^@", "main^!", "main^-"] {
assert!(
text.parse::<Range>().unwrap_err().is_unsupported(),
"{text}"
);
}
}
}