//! 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::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>, package_name: &'s str, target_name: &'s str, }, } type Result = core::result::Result; impl<'s> Label<'s> { /// Parse and analyze given str. pub fn analyze(input: &'s str) -> Result> { 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>)> { 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> { 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(()) } }