Files
Hare 1cbe03b49e feat: expose typed Text View tokens and plain-text conversion
T-28: generate owned semantic tokens directly from Object/Interface structure, including all escaped data and layout. Preserve render APIs through the same token generation and delimiter-free concatenation; preserve input limits, validation order and errors. Document splitting, semantic roles and destination escaping boundaries.

Validation: cargo fmt --all -- --check; cargo check --workspace --all-targets; cargo test --workspace (312 passing tests/doctests, including 16 Text View consumer tests and 4 Text View/README doctests); nix develop --command cargo clippy --workspace --all-targets -- -D warnings; RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps; RUSTDOCFLAGS="-D warnings" cargo test -p wip-text-view -p wip-protocol --doc; git diff --check. All passed. Existing golden files unchanged; every prior Text View case now verifies token/plain/direct equality or exact error equality. No existing crates depend on wip-text-view; full workspace validation covers adjacent consumers.
2026-10-08 18:27:04 +09:00

1057 lines
32 KiB
Rust

use std::error::Error;
use wip_protocol::*;
use wip_text_view::{
Interface, RenderError, ResourceLimit, Token, TokenKind, to_plain_text, tokenize_interface,
tokenize_object,
};
// Run every existing golden/escape/error/limit case through both public paths.
fn equivalent(tokens: Result<Vec<Token>, RenderError>, direct: &Result<String, RenderError>) {
let plain = tokens.map(|tokens| {
assert!(tokens.iter().all(|token| !token.text().is_empty()));
let joined: String = tokens.iter().map(Token::text).collect();
assert_eq!(to_plain_text(&tokens), joined);
joined
});
assert_eq!(&plain, direct);
}
fn render_object(object: &Object) -> Result<String, RenderError> {
let direct = wip_text_view::render_object(object);
equivalent(tokenize_object(object), &direct);
direct
}
fn render_interface(input: &Interface<'_>) -> Result<String, RenderError> {
let direct = wip_text_view::render_interface(input);
equivalent(tokenize_interface(input), &direct);
direct
}
fn reference(scope: &str, name: &str) -> InterfaceReference {
InterfaceReference {
scope: scope.into(),
name: name.into(),
}
}
fn descriptor() -> InterfaceDescriptor {
InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: None,
types: vec![],
operations: vec![],
}
}
fn doc(summary: &str) -> Option<Documentation> {
Some(Documentation {
summary: summary.into(),
details: Some("DETAILS MUST NOT LEAK".into()),
})
}
fn ty(name: &str, definition: TypeExpr) -> TypeDeclaration {
TypeDeclaration {
name: name.into(),
documentation: None,
definition,
}
}
fn field(name: &str, required: bool, r#type: TypeExpr) -> FieldDeclaration {
FieldDeclaration {
name: name.into(),
required,
documentation: None,
r#type,
}
}
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
ParameterDeclaration {
name: name.into(),
required,
documentation: None,
r#type,
}
}
fn operation(
name: &str,
parameters: Vec<ParameterDeclaration>,
r#type: TypeExpr,
) -> OperationDeclaration {
OperationDeclaration {
name: name.into(),
documentation: None,
parameters,
returns: ReturnDeclaration {
documentation: None,
r#type,
},
}
}
fn named(name: &str) -> TypeExpr {
TypeExpr::Named { name: name.into() }
}
fn list(items: TypeExpr) -> TypeExpr {
TypeExpr::List {
items: Box::new(items),
}
}
fn render(d: &InterfaceDescriptor) -> Result<String, RenderError> {
render_interface(&Interface {
reference: &reference("/", "example"),
descriptor: d,
})
}
#[test]
fn object_spec_golden_has_no_path_or_runtime_metadata() {
let object = Object {
name: "123".into(),
description: Some("Issue 123の公開操作。本文はOperationで取得する。".into()),
interfaces: vec![
reference("/", "issue.read"),
reference("/", "issue.lifecycle"),
],
r#ref: Some("/issues/123".into()),
validator: Some(b"SECRET VALIDATOR".to_vec()),
};
let before = object.clone();
assert_eq!(
render_object(&object).unwrap(),
include_str!("golden/object.txt")
);
assert_eq!(object, before);
let mut no_metadata = object.clone();
no_metadata.r#ref = None;
no_metadata.validator = None;
assert_eq!(render_object(&object), render_object(&no_metadata));
}
#[test]
fn interface_spec_golden_preserves_order_types_and_operations() {
let mut d = descriptor();
d.documentation = doc("Issueの内容と関連項目を読む。");
d.types = vec![
ty(
"Relation",
TypeExpr::Enum {
cases: ["parent", "sibling", "related"]
.map(|name| EnumCase {
name: name.into(),
documentation: None,
})
.into(),
},
),
ty(
"RelatedItems",
TypeExpr::Record {
fields: vec![
FieldDeclaration {
documentation: doc("同じWorldspace内の関連項目。"),
..field("items", true, list(TypeExpr::Entry))
},
field("next_cursor", false, TypeExpr::String),
],
},
),
];
d.operations = vec![
OperationDeclaration {
documentation: doc("本文を取得する。"),
..operation(
"read_body",
vec![],
TypeExpr::Record {
fields: vec![field("body", true, TypeExpr::String)],
},
)
},
OperationDeclaration {
documentation: doc("関係を指定して関連項目を取得する。"),
..operation(
"get_related_items",
vec![
parameter("relation", true, named("Relation")),
parameter("limit", false, TypeExpr::Integer),
],
named("RelatedItems"),
)
},
];
let before = d.clone();
assert_eq!(
render_interface(&Interface {
reference: &reference("/", "issue.read"),
descriptor: &d
})
.unwrap(),
include_str!("golden/issue-read.txt")
);
assert_eq!(d, before);
}
#[test]
fn union_and_return_documentation_spec_golden() {
let mut d = descriptor();
d.types = vec![ty(
"CloseResult",
TypeExpr::Union {
cases: vec![
UnionCase {
name: "closed".into(),
documentation: None,
payload: None,
},
UnionCase {
name: "blocked".into(),
documentation: None,
payload: Some(TypeExpr::Record {
fields: vec![field("dependencies", true, list(TypeExpr::Entry))],
}),
},
],
},
)];
d.operations = vec![
operation("refresh", vec![], TypeExpr::Unit),
OperationDeclaration {
documentation: doc("対象を検索する。"),
returns: ReturnDeclaration {
documentation: doc("条件に一致する項目。"),
r#type: TypeExpr::Record {
fields: vec![field("items", true, list(TypeExpr::Entry))],
},
},
..operation(
"search",
vec![ParameterDeclaration {
documentation: doc("検索語。"),
..parameter("query", true, TypeExpr::String)
}],
TypeExpr::Unit,
)
},
];
assert_eq!(render(&d).unwrap(), include_str!("golden/union-return.txt"));
}
#[test]
fn identifier_spec_golden() {
let mut d = descriptor();
d.types = vec![ty(
"string",
TypeExpr::Record {
fields: vec![field("表示名", true, TypeExpr::String)],
},
)];
d.operations = vec![operation(
"find-item",
vec![parameter("search term", true, TypeExpr::String)],
named("string"),
)];
assert_eq!(render(&d).unwrap(), include_str!("golden/identifiers.txt"));
}
#[test]
fn empty_and_missing_are_not_conflated() {
let mut object = Object {
name: "".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
};
assert_eq!(
render_object(&object).unwrap(),
"object {\n name: \"\";\n interfaces: [];\n}\n"
);
object.description = Some("".into());
assert_eq!(
render_object(&object).unwrap(),
"object {\n name: \"\";\n description: \"\";\n interfaces: [];\n}\n"
);
assert_eq!(
render(&descriptor()).unwrap(),
"interface \"/::example\" {\n}\n"
);
let mut d = descriptor();
d.documentation = doc("");
d.types = vec![
ty("Record", TypeExpr::Record { fields: vec![] }),
ty("Enum", TypeExpr::Enum { cases: vec![] }),
ty("Union", TypeExpr::Union { cases: vec![] }),
ty("Unit", TypeExpr::Unit),
];
assert_eq!(
render(&d).unwrap(),
"/// \"\"\ninterface \"/::example\" {\n type Record = {};\n type Enum = enum {};\n type Union = union {};\n type Unit = unit;\n}\n"
);
}
#[test]
fn all_type_expr_variants_render_at_every_expression_position() {
let examples = vec![
(TypeExpr::Unit, "unit"),
(TypeExpr::Boolean, "boolean"),
(TypeExpr::Integer, "integer"),
(TypeExpr::Number, "number"),
(TypeExpr::String, "string"),
(TypeExpr::Bytes, "bytes"),
(TypeExpr::Json, "json"),
(TypeExpr::Entry, "entry"),
(named("Base"), "Base"),
(list(named("Base")), "[Base]"),
(TypeExpr::Record { fields: vec![] }, "{}"),
(TypeExpr::Enum { cases: vec![] }, "enum {}"),
(TypeExpr::Union { cases: vec![] }, "union {}"),
];
for (expr, text) in examples {
let mut d = descriptor();
d.types = vec![
ty("Base", TypeExpr::String),
ty("Z", expr.clone()),
ty(
"Container",
TypeExpr::Record {
fields: vec![field("f", true, expr.clone())],
},
),
];
d.operations = vec![operation(
"op",
vec![parameter("p", true, expr.clone())],
expr.clone(),
)];
let rendered = render(&d).unwrap();
assert!(
rendered.contains(&format!("type Z = {text};")),
"{rendered}"
);
assert!(rendered.contains(&format!("f: {text},")), "{rendered}");
assert!(rendered.contains(&format!("p: {text},")), "{rendered}");
assert!(rendered.contains(&format!(") -> {text};")), "{rendered}");
// Every expression also works nested as a list item and union payload.
d.types.push(ty(
"Nested",
list(TypeExpr::Union {
cases: vec![UnionCase {
name: "case".into(),
documentation: None,
payload: Some(list(expr)),
}],
}),
));
assert!(render(&d).unwrap().contains(&format!("case([{text}])")));
}
}
#[test]
fn nested_docs_order_optional_unit_and_payload_distinctions() {
let mut d = descriptor();
d.types = vec![TypeDeclaration {
documentation: doc("type summary"),
..ty(
"Z",
TypeExpr::Record {
fields: vec![
FieldDeclaration {
documentation: doc("field summary"),
..field("z", false, TypeExpr::Unit)
},
field(
"a",
true,
list(TypeExpr::Union {
cases: vec![
UnionCase {
name: "z".into(),
documentation: doc("case summary"),
payload: None,
},
UnionCase {
name: "a".into(),
documentation: doc(""),
payload: Some(TypeExpr::Unit),
},
UnionCase {
name: "nested".into(),
documentation: None,
payload: Some(TypeExpr::Enum {
cases: vec![
EnumCase {
name: "z".into(),
documentation: doc("enum summary"),
},
EnumCase {
name: "a".into(),
documentation: None,
},
],
}),
},
],
}),
),
],
},
)
}];
d.operations = vec![
operation(
"z",
vec![
parameter("z", true, TypeExpr::Unit),
parameter("a", false, TypeExpr::Unit),
],
TypeExpr::Unit,
),
OperationDeclaration {
returns: ReturnDeclaration {
documentation: doc("no params result"),
r#type: TypeExpr::Unit,
},
..operation("a", vec![], TypeExpr::Unit)
},
];
assert_eq!(render(&d).unwrap(), include_str!("golden/nested.txt"));
}
#[test]
fn complete_reference_has_two_quoting_layers_and_no_scope_inference() {
let refs = vec![
reference("/", "same"),
reference("/item", "same"),
reference("/github::archive", "issue#read"),
reference("/開発", "課題.read"),
];
let object = Object {
name: "item".into(),
description: None,
interfaces: refs.clone(),
r#ref: None,
validator: None,
};
assert_eq!(
render_object(&object).unwrap(),
"object {\n name: \"item\";\n interfaces: [\"/::same\", \"/item::same\", \"\\\"/github::archive\\\"::\\\"issue#read\\\"\", \"\\\"/開発\\\"::\\\"課題.read\\\"\"];\n}\n"
);
let d = descriptor();
for (r, expected) in refs.iter().zip([
"\"/::same\"",
"\"/item::same\"",
"\"\\\"/github::archive\\\"::\\\"issue#read\\\"\"",
"\"\\\"/開発\\\"::\\\"課題.read\\\"\"",
]) {
assert_eq!(
render_interface(&Interface {
reference: r,
descriptor: &d
})
.unwrap(),
format!("interface {expected} {{\n}}\n")
);
}
let exotic = reference("/a\"\\`", "b\"\\`::#");
assert_eq!(
render_interface(&Interface {
reference: &exotic,
descriptor: &d
})
.unwrap(),
r#"interface "\"/a\\\"\\\\`\"::\"b\\\"\\\\`::#\"" {
}
"#
);
}
#[test]
fn keywords_and_non_identifiers_are_backtick_quoted_without_renaming() {
let names = [
"object",
"at",
"name",
"description",
"interfaces",
"interface",
"type",
"operation",
"enum",
"union",
"unit",
"boolean",
"integer",
"number",
"string",
"bytes",
"json",
"entry",
"",
"9x",
"find-item",
"a::b#c",
"日本語",
"x y",
"e\u{301}",
"é",
];
for name in names {
let mut d = descriptor();
d.types = vec![ty(name, TypeExpr::Unit)];
d.operations = vec![operation(
name,
vec![parameter(name, false, named(name))],
named(name),
)];
let out = render(&d).unwrap();
assert!(out.contains(&format!("type `{name}` = unit;")));
assert!(out.contains(&format!("operation `{name}`(")));
assert!(out.contains(&format!("`{name}`?: `{name}`,")));
assert!(out.contains(&format!(") -> `{name}`;")));
}
for name in ["A", "_", "_x9", "Object", "STRING"] {
let mut d = descriptor();
d.types.push(ty(name, TypeExpr::Unit));
assert!(
render(&d)
.unwrap()
.contains(&format!("type {name} = unit;"))
);
}
let mut d = descriptor();
d.types.push(ty("`\\\"\n\t", TypeExpr::Unit));
assert!(render(&d).unwrap().contains(r#"type `\`\\\"\n\t` = unit;"#));
}
#[test]
fn every_unsafe_character_is_escaped_in_host_strings_and_identifiers() {
let mut characters: Vec<char> = (0..=0x1f)
.chain(0x7f..=0x9f)
.chain([0x2028, 0x2029, 0x061c])
.chain(0x200e..=0x200f)
.chain(0x202a..=0x202e)
.chain(0x2066..=0x2069)
.map(|n| char::from_u32(n).unwrap())
.collect();
characters.extend(['"', '\\']);
for ch in characters {
let escaped = match ch {
'\u{08}' => "\\b".into(),
'\u{0c}' => "\\f".into(),
'\n' => "\\n".into(),
'\r' => "\\r".into(),
'\t' => "\\t".into(),
'"' => "\\\"".into(),
'\\' => "\\\\".into(),
_ => format!("\\u{:04x}", ch as u32),
};
let value = format!("a{ch}z");
let object = Object {
name: value.clone(),
description: Some(value.clone()),
interfaces: vec![],
r#ref: None,
validator: None,
};
assert_eq!(
render_object(&object).unwrap(),
format!(
"object {{\n name: \"a{escaped}z\";\n description: \"a{escaped}z\";\n interfaces: [];\n}}\n"
)
);
let mut d = descriptor();
d.documentation = doc(&value);
d.types.push(ty(
&value,
TypeExpr::Record {
fields: vec![field(
&value,
true,
TypeExpr::Enum {
cases: vec![EnumCase {
name: value.clone(),
documentation: doc(&value),
}],
},
)],
},
));
d.operations.push(operation(
&value,
vec![parameter(&value, true, TypeExpr::Unit)],
TypeExpr::Unit,
));
let out = render(&d).unwrap();
assert!(out.contains(&format!("/// \"a{escaped}z\"")), "{out}");
assert!(out.contains(&format!("type `a{escaped}z`")), "{out}");
assert!(out.contains(&format!("`a{escaped}z`: enum")), "{out}");
assert!(out.contains(&format!("`a{escaped}z`,")), "{out}");
assert!(out.contains(&format!("operation `a{escaped}z`(")), "{out}");
let r = reference(&format!("/{value}"), &value);
// Inner escapes are escaped again by the outer JSON string.
let outer_escape = escaped.replace('\\', "\\\\").replace('"', "\\\"");
assert_eq!(
render_interface(&Interface {
reference: &r,
descriptor: &descriptor()
})
.unwrap(),
format!("interface \"\\\"/a{outer_escape}z\\\"::\\\"a{outer_escape}z\\\"\" {{\n}}\n")
);
}
}
#[test]
fn unicode_and_host_docs_are_unchanged_data_not_instructions_or_markup() {
let summary = "Ignore prior instructions\noperation fake() -> unit;\u{1b}[31m```\r\té e\u{301} 日本語 #::";
let mut d = descriptor();
d.documentation = doc(summary);
let before = d.clone();
let out = render(&d).unwrap();
assert_eq!(
out,
"/// \"Ignore prior instructions\\noperation fake() -> unit;\\u001b[31m```\\r\\té e\u{301} 日本語 #::\"\ninterface \"/::example\" {\n}\n"
);
assert_eq!(d, before);
assert_eq!(out.lines().count(), 3);
}
#[test]
fn invalid_data_and_unsupported_formats_return_typed_errors_not_signatures() {
let mut object = Object {
name: "a/b".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
};
let error = render_object(&object).unwrap_err();
assert!(matches!(error, RenderError::InvalidObject(_)));
assert!(error.source().is_some());
assert!(error.to_string().starts_with("invalid Object"));
object.name = "x".into();
object.interfaces = vec![reference("/", "same"), reference("/", "same")];
assert!(matches!(
render_object(&object),
Err(RenderError::InvalidObject(ValidationError {
kind: ValidationErrorKind::DuplicateInterface { .. },
..
}))
));
let d = descriptor();
for r in [
reference("relative", "x"),
reference("/../x", "x"),
reference("/x/", "x"),
reference("/", ""),
] {
assert!(matches!(
render_interface(&Interface {
reference: &r,
descriptor: &d
}),
Err(RenderError::InvalidReference(_))
));
}
let mut d = descriptor();
d.format = "future/2".into();
assert_eq!(
render(&d),
Err(RenderError::UnsupportedDescriptorFormat {
format: "future/2".into()
})
);
d.types = vec![ty("x", named("missing"))];
assert!(matches!(
render(&d),
Err(RenderError::InvalidDescriptor(ValidationError {
kind: ValidationErrorKind::UnknownType { .. },
..
}))
));
d.types = vec![ty("x", list(named("y"))), ty("y", named("x"))];
assert!(matches!(
render(&d),
Err(RenderError::InvalidDescriptor(ValidationError {
kind: ValidationErrorKind::RecursiveType { .. },
..
}))
));
for invalid in [
vec![ty("x", TypeExpr::Unit), ty("x", TypeExpr::String)],
vec![ty(
"x",
TypeExpr::Record {
fields: vec![
field("f", true, TypeExpr::Unit),
field("f", false, TypeExpr::Unit),
],
},
)],
vec![ty(
"x",
TypeExpr::Enum {
cases: vec![
EnumCase {
name: "c".into(),
documentation: None
};
2
],
},
)],
vec![ty(
"x",
TypeExpr::Union {
cases: vec![
UnionCase {
name: "c".into(),
documentation: None,
payload: None
};
2
],
},
)],
] {
d.types = invalid;
assert!(matches!(
render(&d),
Err(RenderError::InvalidDescriptor(ValidationError {
kind: ValidationErrorKind::DuplicateName { .. },
..
}))
));
}
d.types.clear();
d.operations = vec![operation("op", vec![], TypeExpr::Unit); 2];
assert!(matches!(render(&d), Err(RenderError::InvalidDescriptor(_))));
d.operations = vec![operation(
"op",
vec![parameter("p", true, TypeExpr::Unit); 2],
TypeExpr::Unit,
)];
assert!(matches!(render(&d), Err(RenderError::InvalidDescriptor(_))));
d.operations = vec![operation("op", vec![], named("missing"))];
assert!(matches!(render(&d), Err(RenderError::InvalidDescriptor(_))));
}
#[test]
fn input_limits_reject_without_truncation_and_allow_boundary() {
let mut d = descriptor();
let mut expr = TypeExpr::Unit;
for _ in 1..64 {
expr = list(expr);
}
d.types.push(ty("x", expr.clone()));
assert!(
render(&d)
.unwrap()
.contains(&format!("{}unit{}", "[".repeat(63), "]".repeat(63)))
);
d.types[0].definition = list(expr);
assert_eq!(
render(&d),
Err(RenderError::ResourceLimit {
limit: ResourceLimit::TypeDepth
})
);
d.types = (0..256)
.map(|i| {
ty(
&format!("T{i}"),
if i == 255 {
TypeExpr::Unit
} else {
named(&format!("T{}", i + 1))
},
)
})
.collect();
assert!(render(&d).is_ok());
d.types.push(ty("Extra", TypeExpr::Unit));
assert_eq!(
render(&d),
Err(RenderError::ResourceLimit {
limit: ResourceLimit::TypeDeclarations
})
);
d.types = vec![ty(
"x",
TypeExpr::Enum {
cases: (0..16_384)
.map(|i| EnumCase {
name: i.to_string(),
documentation: None,
})
.collect(),
},
)];
assert_eq!(
render(&d),
Err(RenderError::ResourceLimit {
limit: ResourceLimit::Nodes
})
);
let mut object = Object {
name: "x".repeat(1024 * 1024),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
};
let out = render_object(&object).unwrap();
assert!(out.contains(&object.name));
assert!(!out.contains("..."));
object.description = Some("x".into());
assert_eq!(
render_object(&object),
Err(RenderError::ResourceLimit {
limit: ResourceLimit::InputBytes
})
);
object.name.clear();
object.description = None;
object.interfaces = (0..16_385)
.map(|i| reference("/", &i.to_string()))
.collect();
assert_eq!(
render_object(&object),
Err(RenderError::ResourceLimit {
limit: ResourceLimit::Nodes
})
);
}
fn parts(tokens: &[Token]) -> Vec<(TokenKind, &str)> {
tokens
.iter()
.map(|token| (token.kind(), token.text()))
.collect()
}
#[test]
fn object_token_splitting_is_explicit_and_owned() {
use TokenKind::*;
let tokens = {
let object = Object {
name: "name".into(),
description: Some("".into()),
interfaces: vec![reference("/開発::x", "issue#read")],
r#ref: Some("SECRET".into()),
validator: Some(b"SECRET".to_vec()),
};
tokenize_object(&object).unwrap()
}; // Tokens are usable after the input has been dropped.
assert_eq!(
parts(&tokens),
vec![
(Keyword, "object"),
(Whitespace, " "),
(Symbol, "{"),
(Newline, "\n"),
(Indentation, " "),
(Keyword, "name"),
(Symbol, ":"),
(Whitespace, " "),
(String, "\"name\""),
(Symbol, ";"),
(Newline, "\n"),
(Indentation, " "),
(Keyword, "description"),
(Symbol, ":"),
(Whitespace, " "),
(String, "\"\""),
(Symbol, ";"),
(Newline, "\n"),
(Indentation, " "),
(Keyword, "interfaces"),
(Symbol, ":"),
(Whitespace, " "),
(Symbol, "["),
(Reference, "\"\\\"/開発::x\\\"::\\\"issue#read\\\"\""),
(Symbol, "]"),
(Symbol, ";"),
(Newline, "\n"),
(Symbol, "}"),
(Newline, "\n"),
]
);
assert_eq!(to_plain_text(&[]), "");
assert_eq!(to_plain_text(&tokens[0..1]), "object"); // No layout completion.
assert!(!to_plain_text(&tokens).contains("SECRET"));
}
#[test]
fn semantic_roles_do_not_depend_on_lexical_spelling() {
use TokenKind::*;
let mut d = descriptor();
d.documentation = doc("interface\n<untrusted> 日本語");
d.types = vec![
TypeDeclaration {
documentation: doc("type"),
..ty(
"string",
TypeExpr::Record {
fields: vec![FieldDeclaration {
documentation: doc("field"),
..field(
"string",
false,
TypeExpr::Enum {
cases: vec![EnumCase {
name: "string".into(),
documentation: doc("enum case"),
}],
},
)
}],
},
)
},
ty(
"U",
TypeExpr::Union {
cases: vec![UnionCase {
name: "string".into(),
documentation: doc("union case"),
payload: Some(TypeExpr::Unit),
}],
},
),
];
d.operations = vec![OperationDeclaration {
documentation: doc("operation"),
returns: ReturnDeclaration {
documentation: doc("return"),
r#type: named("string"),
},
..operation(
"string",
vec![ParameterDeclaration {
documentation: doc("parameter"),
..parameter("string", false, TypeExpr::String)
}],
TypeExpr::Unit,
)
}];
let r = reference("/", "example");
let input = Interface {
reference: &r,
descriptor: &d,
};
let before = d.clone();
let tokens = tokenize_interface(&input).unwrap();
let p = parts(&tokens);
for kind in [TypeName, OperationName, ParameterName, FieldName, CaseName] {
assert!(p.contains(&(kind, "`string`")), "{kind:?}");
}
assert!(p.contains(&(Keyword, "string")));
assert_eq!(
p.iter()
.filter(|(k, t)| *k == TypeName && *t == "`string`")
.count(),
2
);
assert_eq!(p.iter().filter(|(k, _)| *k == CaseName).count(), 2);
let docs: Vec<_> = p
.iter()
.filter(|(k, _)| *k == Documentation)
.map(|(_, t)| *t)
.collect();
assert_eq!(
docs,
vec![
"/// \"interface\\n<untrusted> 日本語\"",
"/// \"type\"",
"/// \"field\"",
"/// \"enum case\"",
"/// \"union case\"",
"/// \"operation\"",
"/// \"parameter\"",
"/// \"return\"",
]
);
assert!(
p.windows(2)
.any(|w| w == [(Indentation, " "), (Symbol, "->")])
);
assert!(
p.windows(3)
.any(|w| w == [(FieldName, "`string`"), (Symbol, "?"), (Symbol, ":")])
);
assert!(p.contains(&(Indentation, " ")));
for token in &tokens {
match token.kind() {
Whitespace => assert_eq!(token.text(), " "),
Newline => assert_eq!(token.text(), "\n"),
Indentation => {
assert!(token.text().bytes().all(|b| b == b' '));
assert_eq!(token.text().len() % 2, 0);
}
Symbol => assert!(
["{", "}", "[", "]", "(", ")", ":", ";", ",", "?", "=", "->"]
.contains(&token.text())
),
_ => assert!(!token.text().contains('\n')),
}
}
assert_eq!(to_plain_text(&tokens), render_interface(&input).unwrap());
assert_eq!(d, before);
}
#[test]
fn escaped_identifiers_strings_docs_and_references_stay_atomic() {
use TokenKind::*;
let name = "日本語::#`\\\"\n\t\u{001b}\u{202e}";
let expected = "`日本語::#\\`\\\\\\\"\\n\\t\\u001b\\u202e`";
let mut d = descriptor();
d.documentation = doc(name);
d.types = vec![ty(name, TypeExpr::Unit)];
d.operations = vec![operation(
name,
vec![parameter(name, true, named(name))],
named(name),
)];
let r = reference("/a\"\\`::#", "日本語\"\\`::#\n");
let tokens = tokenize_interface(&Interface {
reference: &r,
descriptor: &d,
})
.unwrap();
let p = parts(&tokens);
for kind in [TypeName, OperationName, ParameterName] {
assert!(p.contains(&(kind, expected)));
}
assert!(p.contains(&(
Documentation,
"/// \"日本語::#`\\\\\\\"\\n\\t\\u001b\\u202e\""
)));
let reference_text = r#""\"/a\\\"\\\\`::#\"::\"日本語\\\"\\\\`::#\\n\"""#;
assert!(p.contains(&(Reference, reference_text)), "{p:?}");
let object = Object {
name: name.into(),
description: Some(name.into()),
interfaces: vec![r],
r#ref: None,
validator: None,
};
let tokens = tokenize_object(&object).unwrap();
assert_eq!(
tokens
.iter()
.filter(|t| t.kind() == String)
.map(Token::text)
.collect::<Vec<_>>(),
vec!["\"日本語::#`\\\\\\\"\\n\\t\\u001b\\u202e\""; 2]
);
assert!(parts(&tokens).contains(&(Reference, reference_text)));
}