blob: d1e00a7ec385a35be338028ab3f7398e86ac980c [file] [log] [blame]
//! Bazel label parsing library.
//!
//! USAGE: `label::analyze("//foo/bar:baz")
mod label_error;
use label_error::LabelError;
/// Parse and analyze given str.
///
/// TODO: validate . and .. in target name
/// TODO: validate used characters in target name
pub fn analyze(input: &'_ str) -> Result<Label<'_>> {
Label::analyze(input)
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
pub enum Repository<'s> {
/// A `@@` prefixed name that is unique within a workspace. E.g. `@@rules_rust~0.1.2~toolchains~local_rustc`
Canonical(&'s str), // stringifies to `@@self.0` where `self.0` may be empty
/// A `@` (single) prefixed name. E.g. `@rules_rust`.
Apparent(&'s str),
}
impl<'s> Repository<'s> {
pub fn repo_name(&self) -> &'s str {
match self {
Repository::Canonical(name) => &name[2..],
Repository::Apparent(name) => &name[1..],
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Label<'s> {
Relative {
target_name: &'s str,
},
Absolute {
repository: Option<Repository<'s>>,
package_name: &'s str,
target_name: &'s str,
},
}
type Result<T, E = LabelError> = core::result::Result<T, E>;
impl<'s> Label<'s> {
/// Parse and analyze given str.
pub fn analyze(input: &'s str) -> Result<Label<'s>> {
let label = input;
if label.is_empty() {
return Err(LabelError(err(
label,
"Empty string cannot be parsed into a label.",
)));
}
if label.starts_with(':') {
return match consume_name(input, label)? {
None => Err(LabelError(err(
label,
"Relative packages must have a name.",
))),
Some(name) => Ok(Label::Relative { target_name: name }),
};
}
let (input, repository) = consume_repository_name(input, label)?;
// Shorthand labels such as `@repo` are expanded to `@repo//:repo`.
if input.is_empty() {
if let Some(ref repo) = repository {
let target_name = repo.repo_name();
if target_name.is_empty() {
return Err(LabelError(err(
label,
"invalid target name: empty target name",
)));
} else {
return Ok(Label::Absolute {
repository,
package_name: "",
target_name,
});
};
}
}
let (input, package_name) = consume_package_name(input, label)?;
let name = consume_name(input, label)?;
let name = match (package_name, name) {
(None, None) => {
return Err(LabelError(err(
label,
"labels must have a package and/or a name.",
)))
}
(Some(package_name), None) => name_from_package(package_name),
(_, Some(name)) => name,
};
Ok(Label::Absolute {
repository,
package_name: package_name.unwrap_or_default(),
target_name: name,
})
}
pub fn is_relative(&self) -> bool {
match self {
Label::Absolute { .. } => false,
Label::Relative { .. } => true,
}
}
pub fn repo(&self) -> Option<&Repository<'s>> {
match self {
Label::Absolute { repository, .. } => repository.as_ref(),
Label::Relative { .. } => None,
}
}
pub fn repo_name(&self) -> Option<&'s str> {
match self {
Label::Absolute { repository, .. } => repository.as_ref().map(|repo| repo.repo_name()),
Label::Relative { .. } => None,
}
}
pub fn package(&self) -> Option<&'s str> {
match self {
Label::Relative { .. } => None,
Label::Absolute { package_name, .. } => Some(*package_name),
}
}
pub fn name(&self) -> &'s str {
match self {
Label::Relative { target_name } => target_name,
Label::Absolute { target_name, .. } => target_name,
}
}
}
fn err<'s>(label: &'s str, msg: &'s str) -> String {
let mut err_msg = label.to_string();
err_msg.push_str(" must be a legal label; ");
err_msg.push_str(msg);
err_msg
}
fn consume_repository_name<'s>(
input: &'s str,
label: &'s str,
) -> Result<(&'s str, Option<Repository<'s>>)> {
let at_signs = {
let mut count = 0;
for char in input.chars() {
if char == '@' {
count += 1;
} else {
break;
}
}
count
};
if at_signs == 0 {
return Ok((input, None));
}
if at_signs > 2 {
return Err(LabelError(err(label, "Unexpected number of leading `@`.")));
}
let slash_pos = input.find("//").unwrap_or(input.len());
let repository_name = &input[at_signs..slash_pos];
if !repository_name.is_empty() {
if !repository_name
.chars()
.next()
.unwrap()
.is_ascii_alphabetic()
{
return Err(LabelError(err(
label,
"workspace names must start with a letter.",
)));
}
if !repository_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~')
{
return Err(LabelError(err(
label,
"workspace names \
may contain only A-Z, a-z, 0-9, '-', '_', '.', and '~'.",
)));
}
}
let repository = if at_signs == 1 {
Repository::Apparent(&input[0..slash_pos])
} else if at_signs == 2 {
if repository_name.is_empty() {
return Err(LabelError(err(
label,
"main repository labels are only represented by a single `@`.",
)));
}
Repository::Canonical(&input[0..slash_pos])
} else {
return Err(LabelError(err(label, "Unexpected number of leading `@`.")));
};
Ok((&input[slash_pos..], Some(repository)))
}
fn consume_package_name<'s>(input: &'s str, label: &'s str) -> Result<(&'s str, Option<&'s str>)> {
let is_absolute = match input.rfind("//") {
None => false,
Some(0) => true,
Some(_) => {
return Err(LabelError(err(
label,
"'//' cannot appear in the middle of the label.",
)));
}
};
let (package_name, rest) = match (is_absolute, input.find(':')) {
(false, colon_pos) if colon_pos.map_or(true, |pos| pos != 0) => {
return Err(LabelError(err(
label,
"relative packages are not permitted.",
)));
}
(_, colon_pos) => {
let (input, colon_pos) = if is_absolute {
(&input[2..], colon_pos.map(|cp| cp - 2))
} else {
(input, colon_pos)
};
match colon_pos {
Some(colon_pos) => (&input[0..colon_pos], &input[colon_pos..]),
None => (input, ""),
}
}
};
if package_name.is_empty() {
return Ok((rest, None));
}
if !package_name.chars().all(|c| {
c.is_ascii_alphanumeric()
|| c == '/'
|| c == '-'
|| c == '.'
|| c == ' '
|| c == '$'
|| c == '('
|| c == ')'
|| c == '_'
|| c == '+'
}) {
return Err(LabelError(err(
label,
"package names may contain only A-Z, \
a-z, 0-9, '/', '-', '.', ' ', '$', '(', ')', '_', and '+'.",
)));
}
if package_name.ends_with('/') {
return Err(LabelError(err(
label,
"package names may not end with '/'.",
)));
}
if rest.is_empty() && is_absolute {
// This label doesn't contain the target name, we have to use
// last segment of the package name as target name.
return Ok((
match package_name.rfind('/') {
Some(pos) => &package_name[pos..],
None => package_name,
},
Some(package_name),
));
}
Ok((rest, Some(package_name)))
}
fn consume_name<'s>(input: &'s str, label: &'s str) -> Result<Option<&'s str>> {
if input.is_empty() {
return Ok(None);
}
if input == ":" {
return Err(LabelError(err(label, "empty target name.")));
}
let name = if let Some(stripped) = input.strip_prefix(':') {
stripped
} else if let Some(stripped) = input.strip_prefix("//") {
stripped
} else {
input.strip_prefix('/').unwrap_or(input)
};
if name.starts_with('/') {
return Err(LabelError(err(
label,
"target names may not start with '/'.",
)));
}
if name.starts_with(':') {
return Err(LabelError(err(
label,
"target names may not contain with ':'.",
)));
}
Ok(Some(name))
}
fn name_from_package(package_name: &str) -> &str {
package_name
.rsplit_once('/')
.map(|tup| tup.1)
.unwrap_or(package_name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_repository_name_parsing() -> Result<()> {
assert_eq!(analyze("@repo//:foo")?.repo_name(), Some("repo"));
assert_eq!(analyze("@@repo//:foo")?.repo_name(), Some("repo"));
assert_eq!(analyze("@//:foo")?.repo_name(), Some(""));
assert_eq!(analyze("//:foo")?.repo_name(), None);
assert_eq!(analyze(":foo")?.repo_name(), None);
assert_eq!(analyze("@repo//foo/bar")?.repo_name(), Some("repo"));
assert_eq!(analyze("@@repo//foo/bar")?.repo_name(), Some("repo"));
assert_eq!(analyze("@//foo/bar")?.repo_name(), Some(""));
assert_eq!(analyze("//foo/bar")?.repo_name(), None);
assert_eq!(
analyze("foo/bar"),
Err(LabelError(
"foo/bar must be a legal label; relative packages are not permitted.".to_string()
))
);
assert_eq!(analyze("@repo//foo")?.repo_name(), Some("repo"));
assert_eq!(analyze("@@repo//foo")?.repo_name(), Some("repo"));
assert_eq!(analyze("@//foo")?.repo_name(), Some(""));
assert_eq!(analyze("//foo")?.repo_name(), None);
assert_eq!(
analyze("foo"),
Err(LabelError(
"foo must be a legal label; relative packages are not permitted.".to_string()
))
);
assert_eq!(
analyze("@@@repo//foo"),
Err(LabelError(
"@@@repo//foo must be a legal label; Unexpected number of leading `@`.".to_owned()
))
);
assert_eq!(
analyze("@@@//foo:bar"),
Err(LabelError(
"@@@//foo:bar must be a legal label; Unexpected number of leading `@`.".to_owned()
))
);
assert_eq!(
analyze("@foo:bar"),
Err(LabelError(
"@foo:bar must be a legal label; workspace names may contain only A-Z, a-z, 0-9, '-', '_', '.', and '~'.".to_string()
))
);
assert_eq!(
analyze("@AZab0123456789_-.//:foo")?.repo_name(),
Some("AZab0123456789_-.")
);
assert_eq!(
analyze("@42//:baz"),
Err(LabelError(
"@42//:baz must be a legal label; workspace names must \
start with a letter."
.to_string()
))
);
assert_eq!(
analyze("@foo#//:baz"),
Err(LabelError(
"@foo#//:baz must be a legal label; workspace names \
may contain only A-Z, a-z, 0-9, '-', '_', '.', and '~'."
.to_string()
))
);
assert_eq!(
analyze("@@//foo/bar"),
Err(LabelError(
"@@//foo/bar must be a legal label; main repository labels are only represented by a single `@`."
.to_string()
))
);
assert_eq!(
analyze("@@//:foo"),
Err(LabelError(
"@@//:foo must be a legal label; main repository labels are only represented by a single `@`."
.to_string()
))
);
assert_eq!(
analyze("@@//foo"),
Err(LabelError(
"@@//foo must be a legal label; main repository labels are only represented by a single `@`."
.to_string()
))
);
assert_eq!(
analyze("@@"),
Err(LabelError(
"@@ must be a legal label; main repository labels are only represented by a single `@`.".to_string()
)),
);
Ok(())
}
#[test]
fn test_package_name_parsing() -> Result<()> {
assert_eq!(analyze("//:baz/qux")?.package(), Some(""));
assert_eq!(analyze(":baz/qux")?.package(), None);
assert_eq!(analyze("//foo:baz/qux")?.package(), Some("foo"));
assert_eq!(analyze("//foo/bar:baz/qux")?.package(), Some("foo/bar"));
assert_eq!(
analyze("foo:baz/qux"),
Err(LabelError(
"foo:baz/qux must be a legal label; relative packages are not permitted."
.to_string()
))
);
assert_eq!(
analyze("foo/bar:baz/qux"),
Err(LabelError(
"foo/bar:baz/qux must be a legal label; relative packages are not permitted."
.to_string()
))
);
assert_eq!(analyze("//foo")?.package(), Some("foo"));
assert_eq!(
analyze("foo//bar"),
Err(LabelError(
"foo//bar must be a legal label; '//' cannot appear in the middle of the label."
.to_string()
))
);
assert_eq!(
analyze("//foo//bar"),
Err(LabelError(
"//foo//bar must be a legal label; '//' cannot appear in the middle of the label."
.to_string()
))
);
assert_eq!(
analyze("foo//bar:baz"),
Err(LabelError(
"foo//bar:baz must be a legal label; '//' cannot appear in the middle of the label."
.to_string()
))
);
assert_eq!(
analyze("//foo//bar:baz"),
Err(LabelError(
"//foo//bar:baz must be a legal label; '//' cannot appear in the middle of the label."
.to_string()
))
);
assert_eq!(
analyze("//azAZ09/-. $()_:baz")?.package(),
Some("azAZ09/-. $()_")
);
assert_eq!(
analyze("//bar#:baz"),
Err(LabelError(
"//bar#:baz must be a legal label; package names may contain only A-Z, \
a-z, 0-9, '/', '-', '.', ' ', '$', '(', ')', '_', and '+'."
.to_string()
))
);
assert_eq!(
analyze("//bar/:baz"),
Err(LabelError(
"//bar/:baz must be a legal label; package names may not end with '/'.".to_string()
))
);
assert_eq!(analyze("@repo//foo/bar")?.package(), Some("foo/bar"));
assert_eq!(analyze("//foo/bar")?.package(), Some("foo/bar"));
assert_eq!(
analyze("foo/bar"),
Err(LabelError(
"foo/bar must be a legal label; relative packages are not permitted.".to_string()
))
);
assert_eq!(analyze("@repo//foo")?.package(), Some("foo"));
assert_eq!(analyze("//foo")?.package(), Some("foo"));
assert_eq!(
analyze("foo"),
Err(LabelError(
"foo must be a legal label; relative packages are not permitted.".to_string()
))
);
Ok(())
}
#[test]
fn test_name_parsing() -> Result<()> {
assert_eq!(analyze("//foo:baz")?.name(), "baz");
assert_eq!(analyze("//foo:baz/qux")?.name(), "baz/qux");
assert_eq!(analyze(":baz/qux")?.name(), "baz/qux");
assert_eq!(
analyze("::baz/qux"),
Err(LabelError(
"::baz/qux must be a legal label; target names may not contain with ':'."
.to_string()
))
);
assert_eq!(
analyze("//bar:"),
Err(LabelError(
"//bar: must be a legal label; empty target name.".to_string()
))
);
assert_eq!(analyze("//foo")?.name(), "foo");
assert_eq!(
analyze("//bar:/foo"),
Err(LabelError(
"//bar:/foo must be a legal label; target names may not start with '/'."
.to_string()
))
);
assert_eq!(analyze("@repo//foo/bar")?.name(), "bar");
assert_eq!(analyze("//foo/bar")?.name(), "bar");
assert_eq!(
analyze("foo/bar"),
Err(LabelError(
"foo/bar must be a legal label; relative packages are not permitted.".to_string()
))
);
assert_eq!(analyze("@repo//foo")?.name(), "foo");
assert_eq!(analyze("//foo")?.name(), "foo");
assert_eq!(
analyze("foo"),
Err(LabelError(
"foo must be a legal label; relative packages are not permitted.".to_string()
))
);
assert_eq!(
analyze("@repo")?,
Label::Absolute {
repository: Some(Repository::Apparent("@repo")),
package_name: "",
target_name: "repo",
},
);
assert_eq!(
analyze("@"),
Err(LabelError(
"@ must be a legal label; invalid target name: empty target name".to_string()
)),
);
Ok(())
}
}