diff --git a/src/ast.rs b/src/ast.rs index 872b717..b7cb907 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -101,7 +101,7 @@ pub struct Node { pub enum NodeProperty { /// Authored node kind. Kind(Spanned), - /// Authored theme-local icon identifier. + /// Authored theme or namespaced provider icon identifier. Icon(Spanned), /// Authored visible detail. Detail(Spanned), diff --git a/src/ir.rs b/src/ir.rs index 4b55ae8..760abb1 100644 --- a/src/ir.rs +++ b/src/ir.rs @@ -57,7 +57,7 @@ pub struct Node { pub label: String, /// Effective semantic node kind. pub kind: NodeKind, - /// Optional theme-local icon identifier. + /// Optional theme or namespaced provider icon identifier. pub icon_id: Option, /// Optional visible detail. pub detail: Option, diff --git a/src/validation.rs b/src/validation.rs index 0654830..b7e7908 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -669,7 +669,7 @@ impl<'document> Validator<'document> { identifier.span, ) .with_help( - "Use 1 to 64 lowercase ASCII letters, digits, or hyphens, starting with a letter or digit.", + "Use an icon name of 1 to 64 lowercase ASCII letters, digits, or hyphens, optionally prefixed by a lowercase provider namespace and one colon.", ), ); } @@ -841,6 +841,24 @@ fn is_identifier(value: &str) -> bool { } fn is_icon_identifier(value: &str) -> bool { + match value.split_once(':') { + Some((provider, icon)) => { + !icon.contains(':') && is_provider_namespace(provider) && is_icon_name(icon) + } + None => is_icon_name(value), + } +} + +fn is_provider_namespace(value: &str) -> bool { + let bytes = value.as_bytes(); + (2..=32).contains(&bytes.len()) + && bytes[0].is_ascii_lowercase() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') +} + +fn is_icon_name(value: &str) -> bool { let bytes = value.as_bytes(); (1..=64).contains(&bytes.len()) && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) diff --git a/src/validation/tests.rs b/src/validation/tests.rs index 582a29b..ec9fa90 100644 --- a/src/validation/tests.rs +++ b/src/validation/tests.rs @@ -1,7 +1,7 @@ use crate::diagnostic::Severity; use crate::ir::{Direction, EdgeDirection, EdgeKind, ElementId, NodeKind}; -use super::levenshtein; +use super::{is_icon_identifier, levenshtein}; fn compile(source: &str) -> crate::CompileOutput { crate::compile(source) @@ -125,6 +125,43 @@ diagram "Icons" { assert!(codes.contains(&"STK3008")); } +#[test] +fn validation_accepts_theme_and_provider_icon_identifiers() { + for identifier in [ + "api", + "1password", + "aws:s3", + "gcp:cloud-run", + "azure:storage-accounts", + ] { + assert!(is_icon_identifier(identifier), "rejected {identifier}"); + } + for identifier in [ + "Bad_icon", + "a:s3", + "AWS:s3", + "aws:", + "aws:_s3", + "aws:s3:object", + ] { + assert!(!is_icon_identifier(identifier), "accepted {identifier}"); + } + + let output = compile( + r#"stack 1.0 +diagram "Provider" { + node assets "Amazon S3" { kind storage icon "aws:s3" } +}"#, + ); + assert!(output.diagnostics.is_empty()); + assert_eq!( + output + .diagram + .and_then(|diagram| diagram.nodes[0].icon_id.clone()), + Some("aws:s3".to_owned()) + ); +} + #[test] fn validation_warns_when_node_degree_exceeds_twelve() { let mut source = String::from("stack 1.0\ndiagram \"Dense\" {\n node hub \"Hub\"\n"); diff --git a/tests/specification-revision b/tests/specification-revision index 20d6940..25d3046 100644 --- a/tests/specification-revision +++ b/tests/specification-revision @@ -1 +1 @@ -7f9154d22702ddf02f2713bbc06dde7bdf635806 +80df39151df67eedb4cfcb2de9f964129cd87466