use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnnotationToken {
pub name: String,
pub args: Option<String>,
}
impl AnnotationToken {
pub fn render(&self) -> String {
match &self.args {
Some(args) => format!("@{}({})", self.name, args),
None => format!("@{}", self.name),
}
}
pub fn is_doc_meta(&self) -> bool {
self.name == "doc" || self.name == "meta"
}
pub fn identity(&self) -> String {
if self.name == "meta"
&& let Some(key) = self.meta_key()
{
return format!("meta:{key}");
}
self.name.clone()
}
pub fn meta_key(&self) -> Option<String> {
if self.name != "meta" {
return None;
}
first_string_literal(self.args.as_deref()?)
}
pub fn doc(value: &str) -> Self {
AnnotationToken {
name: "doc".to_string(),
args: Some(escaped_string_literal(value)),
}
}
pub fn meta(key: &str, value: &str) -> Self {
AnnotationToken {
name: "meta".to_string(),
args: Some(format!(
"{}, {}",
escaped_string_literal(key),
escaped_string_literal(value)
)),
}
}
}
pub fn split_annotation_tokens(flags: &str) -> Vec<AnnotationToken> {
let bytes = flags.as_bytes();
let mut tokens = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'@' {
i += 1;
continue;
}
i += 1;
let name_start = i;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
if i == name_start {
continue;
}
let name = flags[name_start..i].to_string();
let mut args = None;
if i < bytes.len() && bytes[i] == b'(' {
i += 1;
let args_start = i;
let mut depth: u32 = 1;
let mut in_string = false;
while i < bytes.len() {
match bytes[i] {
b'\\' if in_string => {
i += 1;
}
b'"' => in_string = !in_string,
b'(' if !in_string => depth += 1,
b')' if !in_string => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
i += 1;
}
args = Some(flags[args_start..i.min(bytes.len())].to_string());
if i < bytes.len() {
i += 1;
}
}
tokens.push(AnnotationToken { name, args });
}
tokens
}
pub fn constraint_part(flags: &str) -> String {
split_annotation_tokens(flags)
.into_iter()
.filter(|token| !token.is_doc_meta())
.map(|token| token.render())
.collect::<Vec<_>>()
.join(" ")
}
pub fn typeql_uses_schema_annotations(typeql: &str) -> bool {
let bytes = typeql.as_bytes();
let mut in_string = false;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' if in_string => {
i += 1;
}
b'"' => in_string = !in_string,
b'@' if !in_string => {
let rest = &typeql[i..];
if rest.starts_with("@doc(") || rest.starts_with("@meta(") {
return true;
}
}
_ => {}
}
i += 1;
}
false
}
pub fn escaped_string_literal(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\r' => out.push_str("\\r"),
other => out.push(other),
}
}
out.push('"');
out
}
fn first_string_literal(args: &str) -> Option<String> {
let start = args.find('"')?;
let mut out = String::new();
let mut chars = args[start + 1..].chars();
while let Some(c) = chars.next() {
match c {
'"' => return Some(out),
'\\' => match chars.next()? {
'n' => out.push('\n'),
't' => out.push('\t'),
'r' => out.push('\r'),
other => out.push(other),
},
other => out.push(other),
}
}
None
}
pub fn diff_annotation_tokens(
old: &[AnnotationToken],
new: &[AnnotationToken],
) -> AnnotationTokenDiff {
let old_map: BTreeMap<String, &AnnotationToken> =
old.iter().map(|t| (t.identity(), t)).collect();
let new_map: BTreeMap<String, &AnnotationToken> =
new.iter().map(|t| (t.identity(), t)).collect();
let mut diff = AnnotationTokenDiff::default();
for (identity, new_token) in &new_map {
match old_map.get(identity) {
None => diff.added.push((*new_token).clone()),
Some(old_token) => {
if old_token.render() != new_token.render() {
diff.changed
.push(((*old_token).clone(), (*new_token).clone()));
}
}
}
}
for (identity, old_token) in &old_map {
if !new_map.contains_key(identity) {
diff.removed.push((*old_token).clone());
}
}
diff
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AnnotationTokenDiff {
pub added: Vec<AnnotationToken>,
pub removed: Vec<AnnotationToken>,
pub changed: Vec<(AnnotationToken, AnnotationToken)>,
}
impl AnnotationTokenDiff {
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn token(name: &str, args: Option<&str>) -> AnnotationToken {
AnnotationToken {
name: name.to_string(),
args: args.map(str::to_string),
}
}
#[test]
fn splits_parameterless_and_parameterized_tokens() {
let tokens = split_annotation_tokens("@key @card(1..5) @doc(\"a b\") @meta(\"k\", \"v\")");
assert_eq!(
tokens,
vec![
token("key", None),
token("card", Some("1..5")),
token("doc", Some("\"a b\"")),
token("meta", Some("\"k\", \"v\"")),
]
);
}
#[test]
fn string_literals_shield_parentheses_and_escapes() {
let tokens = split_annotation_tokens(r#"@doc("has ) paren and \" quote") @unique"#);
assert_eq!(
tokens,
vec![
token("doc", Some(r#""has ) paren and \" quote""#)),
token("unique", None),
]
);
}
#[test]
fn meta_identity_is_keyed_per_meta_key() {
let tokens = split_annotation_tokens(r#"@meta("a", "1") @meta("b", "2")"#);
assert_eq!(tokens[0].identity(), "meta:a");
assert_eq!(tokens[1].identity(), "meta:b");
}
#[test]
fn constraint_part_drops_doc_meta() {
assert_eq!(constraint_part(r#"@key @doc("x") @meta("k", "v")"#), "@key");
assert_eq!(constraint_part(r#"@doc("x")"#), "");
}
#[test]
fn diff_groups_added_removed_changed() {
let old = split_annotation_tokens(r#"@key @card(1..5) @doc("old") @meta("a", "1")"#);
let new = split_annotation_tokens(r#"@key @card(1..9) @meta("a", "2") @meta("b", "3")"#);
let diff = diff_annotation_tokens(&old, &new);
assert_eq!(diff.added, vec![token("meta", Some("\"b\", \"3\""))]);
assert_eq!(diff.removed, vec![token("doc", Some("\"old\""))]);
assert_eq!(
diff.changed,
vec![
(token("card", Some("1..5")), token("card", Some("1..9"))),
(
token("meta", Some("\"a\", \"1\"")),
token("meta", Some("\"a\", \"2\""))
),
]
);
}
#[test]
fn identical_strings_diff_empty() {
let tokens = split_annotation_tokens(r#"@unique @doc("same")"#);
assert!(diff_annotation_tokens(&tokens, &tokens).is_empty());
}
#[test]
fn typeql_annotation_scan_ignores_string_literals() {
assert!(typeql_uses_schema_annotations(
"define\nentity person @doc(\"docs\");"
));
assert!(typeql_uses_schema_annotations(
"define\nperson owns name @meta(\"k\", \"v\");"
));
assert!(!typeql_uses_schema_annotations(
"define\nattribute note, value string @values(\"use @doc(x) here\");"
));
assert!(!typeql_uses_schema_annotations(
"define\nentity person, owns name @key;"
));
}
}