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.
1057 lines
32 KiB
Rust
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)));
|
|
}
|