Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ pub struct Node {
pub enum NodeProperty {
/// Authored node kind.
Kind(Spanned<String>),
/// Authored theme-local icon identifier.
/// Authored theme or namespaced provider icon identifier.
Icon(Spanned<String>),
/// Authored visible detail.
Detail(Spanned<String>),
Expand Down
2 changes: 1 addition & 1 deletion src/ir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Optional visible detail.
pub detail: Option<String>,
Expand Down
20 changes: 19 additions & 1 deletion src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
),
);
}
Expand Down Expand Up @@ -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())
Expand Down
39 changes: 38 additions & 1 deletion src/validation/tests.rs
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion tests/specification-revision
Original file line number Diff line number Diff line change
@@ -1 +1 @@
7f9154d22702ddf02f2713bbc06dde7bdf635806
80df39151df67eedb4cfcb2de9f964129cd87466