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.
This commit is contained in:
@@ -42,6 +42,84 @@ requests a path and never infers one from name or ref. It requires no descriptor
|
|||||||
An Interface needs no Object, separate name/scope/context arguments or lookup.
|
An Interface needs no Object, separate name/scope/context arguments or lookup.
|
||||||
There is no document aggregator, configuration or details selector.
|
There is no document aggregator, configuration or details selector.
|
||||||
|
|
||||||
|
## Typed display tokens
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wip_protocol::{Object, InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
|
||||||
|
use wip_text_view::{
|
||||||
|
Interface, TokenKind, tokenize_object, tokenize_interface, to_plain_text,
|
||||||
|
render_object, render_interface,
|
||||||
|
};
|
||||||
|
|
||||||
|
let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
|
||||||
|
let object = Object {
|
||||||
|
name: "item".into(), description: None, interfaces: vec![reference.clone()],
|
||||||
|
r#ref: None, validator: None,
|
||||||
|
};
|
||||||
|
let object_tokens = tokenize_object(&object)?;
|
||||||
|
assert_eq!(object_tokens[0].kind(), TokenKind::Keyword);
|
||||||
|
assert_eq!(object_tokens[0].text(), "object");
|
||||||
|
assert_eq!(to_plain_text(&object_tokens), render_object(&object)?);
|
||||||
|
|
||||||
|
let descriptor = InterfaceDescriptor {
|
||||||
|
format: INTERFACE_FORMAT_V1.into(), documentation: None,
|
||||||
|
types: vec![], operations: vec![],
|
||||||
|
};
|
||||||
|
let input = Interface { reference: &reference, descriptor: &descriptor };
|
||||||
|
let tokens = tokenize_interface(&input)?;
|
||||||
|
for token in &tokens {
|
||||||
|
// Custom renderers can select presentation by role without reparsing text.
|
||||||
|
if token.kind() == TokenKind::Reference {
|
||||||
|
assert_eq!(token.text(), "\"/::example\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let joined: String = tokens.iter().map(|token| token.text()).collect();
|
||||||
|
assert_eq!(to_plain_text(&tokens), joined);
|
||||||
|
assert_eq!(to_plain_text(&tokens), render_interface(&input)?);
|
||||||
|
# Ok::<(), wip_text_view::RenderError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
`tokenize_object` / `tokenize_interface` return `Result<Vec<Token>, RenderError>`.
|
||||||
|
They generate **directly from the input structure**, not by lexing a completed
|
||||||
|
Text View string. Each token owns its text (and can outlive the input) and exposes
|
||||||
|
`kind()` / `text()`. Existing render APIs use the same token generation followed
|
||||||
|
by `to_plain_text(&[Token])`; there is no second grammar/escape/layout engine.
|
||||||
|
|
||||||
|
### Kind and splitting contract
|
||||||
|
|
||||||
|
| Kind | One token contains |
|
||||||
|
| --- | --- |
|
||||||
|
| `Keyword` | One grammar keyword, Object property label, or primitive type keyword |
|
||||||
|
| `TypeName` | One type declaration name or Named type reference |
|
||||||
|
| `OperationName` | One Operation declaration name |
|
||||||
|
| `ParameterName` / `FieldName` | One parameter / record field name |
|
||||||
|
| `CaseName` | One enum or union case name |
|
||||||
|
| `String` | One complete quoted Object name or description |
|
||||||
|
| `Reference` | One complete quoted absolute Interface reference, with both quoting layers |
|
||||||
|
| `Documentation` | Entire `/// "summary"` content, including marker and internal space, excluding indentation/LF |
|
||||||
|
| `Symbol` | One punctuation mark, except the indivisible two-character `->` arrow |
|
||||||
|
| `Whitespace` | One inter-token ASCII space |
|
||||||
|
| `Newline` | One LF, including the final LF |
|
||||||
|
| `Indentation` | All leading spaces of a non-root line, two spaces per depth level |
|
||||||
|
|
||||||
|
Quoted identifiers retain their semantic role even when named like keywords.
|
||||||
|
Quotes, backticks and all WIP escapes are part of the data token, never separate
|
||||||
|
symbols. Host spaces inside quoted data remain in that token. Adjacent symbols
|
||||||
|
are separate; no empty tokens or zero-depth indentation tokens are emitted.
|
||||||
|
`TokenKind` is non-exhaustive so consumers should handle future roles with a
|
||||||
|
fallback. The API does not distinguish type declaration names from Named uses,
|
||||||
|
or enum cases from union cases; both share the documented role.
|
||||||
|
|
||||||
|
All layout is included. Concatenate token text **without separators**, not with
|
||||||
|
`join(" ")`. `to_plain_text` is only that concatenation: no interpretation,
|
||||||
|
validation, quoting, escaping or layout completion. An empty slice yields `""`;
|
||||||
|
a filtered/reordered slice is allowed but need not be a complete signature.
|
||||||
|
|
||||||
|
Token text is escaped for **WIP Text View**, not for HTML or other destinations.
|
||||||
|
Custom renderers must escape for their destination and treat Host text as data,
|
||||||
|
not markup or instructions. This crate supplies no HTML, ANSI, color or theme
|
||||||
|
renderer/adapter and no external text lexer/parser or AST.
|
||||||
|
|
||||||
## Output and validation
|
## Output and validation
|
||||||
|
|
||||||
- UTF-8 plain text with LF, two-space indentation and a final LF, without ANSI,
|
- UTF-8 plain text with LF, two-space indentation and a final LF, without ANSI,
|
||||||
@@ -63,8 +141,8 @@ There is no document aggregator, configuration or details selector.
|
|||||||
- Object ref/validator, children, domain data and Interface scope_ref/validator
|
- Object ref/validator, children, domain data and Interface scope_ref/validator
|
||||||
are not signature content. No annotations are inferred from operation names.
|
are not signature content. No annotations are inferred from operation names.
|
||||||
|
|
||||||
Both functions return `Result<String, RenderError>` and never return partial
|
The render functions return `Result<String, RenderError>`; token generation
|
||||||
signatures. Resource checks happen first, followed by existing Protocol validators:
|
returns `Result<Vec<Token>, RenderError>`. Neither returns partial signatures. Resource checks happen first, followed by existing Protocol validators:
|
||||||
Object shape and reference uniqueness, or Interface reference shape plus descriptor
|
Object shape and reference uniqueness, or Interface reference shape plus descriptor
|
||||||
structure, duplicate names, unknown Named references and cycles. Descriptor format
|
structure, duplicate names, unknown Named references and cycles. Descriptor format
|
||||||
is checked after semantics: valid but unsupported formats return
|
is checked after semantics: valid but unsupported formats return
|
||||||
@@ -94,7 +172,13 @@ the spec's Object, issue.read, CloseResult, search/return documentation and
|
|||||||
identifier examples (with permitted multiline layout), plus nested documentation.
|
identifier examples (with permitted multiline layout), plus nested documentation.
|
||||||
The suite checks all TypeExpr variants/positions, ordering and empty distinctions,
|
The suite checks all TypeExpr variants/positions, ordering and empty distinctions,
|
||||||
keyword and Unicode names, both reference quoting layers, all required control
|
keyword and Unicode names, both reference quoting layers, all required control
|
||||||
escapes, input immutability, metadata exclusion and typed rejection cases.
|
escapes, input immutability, metadata exclusion and typed rejection cases. Every
|
||||||
|
existing case compares the render result (including exact errors) with token
|
||||||
|
text concatenation and `to_plain_text`. Additional tests assert complete Object
|
||||||
|
token splitting, all semantic roles, keyword collisions, atomic escaped data,
|
||||||
|
all documentation positions, layout tokens and ownership beyond input lifetime.
|
||||||
|
Input budgets bound token counts per node, indentation depth and escape expansion;
|
||||||
|
token generation checks them before any rendering/validation traversal.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
cargo test -p wip-text-view
|
cargo test -p wip-text-view
|
||||||
|
|||||||
@@ -45,11 +45,42 @@
|
|||||||
//! assert_eq!(render_interface(&interface)?, "interface \"/::example\" {\n}\n");
|
//! assert_eq!(render_interface(&interface)?, "interface \"/::example\" {\n}\n");
|
||||||
//! # Ok::<(), wip_text_view::RenderError>(())
|
//! # Ok::<(), wip_text_view::RenderError>(())
|
||||||
//! ```
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Typed display tokens
|
||||||
|
//!
|
||||||
|
//! [`tokenize_object`] and [`tokenize_interface`] traverse the input structure
|
||||||
|
//! directly and return owned [`Token`] sequences, including all layout. Existing
|
||||||
|
//! render APIs use this same generation path followed by [`to_plain_text`]. The
|
||||||
|
//! input limits above also bound token allocation: tokens per node, indentation
|
||||||
|
//! depth and escape expansion are bounded; no partial signature is returned.
|
||||||
|
//! Token text is WIP-escaped, **not HTML-escaped** or escaped for other destinations.
|
||||||
|
//! See [`Token`] for the semantic splitting contract; no renderer/theme is supplied.
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use wip_protocol::{InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
|
||||||
|
//! use wip_text_view::{Interface, TokenKind, tokenize_interface, to_plain_text, render_interface};
|
||||||
|
//! let reference = InterfaceReference { scope: "/".into(), name: "example".into() };
|
||||||
|
//! let descriptor = InterfaceDescriptor {
|
||||||
|
//! format: INTERFACE_FORMAT_V1.into(), documentation: None,
|
||||||
|
//! types: vec![], operations: vec![],
|
||||||
|
//! };
|
||||||
|
//! let input = Interface { reference: &reference, descriptor: &descriptor };
|
||||||
|
//! let tokens = tokenize_interface(&input)?;
|
||||||
|
//! assert_eq!(tokens[0].kind(), TokenKind::Keyword);
|
||||||
|
//! assert_eq!(tokens[0].text(), "interface");
|
||||||
|
//! let concatenated: String = tokens.iter().map(|token| token.text()).collect();
|
||||||
|
//! assert_eq!(to_plain_text(&tokens), concatenated);
|
||||||
|
//! assert_eq!(to_plain_text(&tokens), render_interface(&input)?);
|
||||||
|
//! # Ok::<(), wip_text_view::RenderError>(())
|
||||||
|
//! ```
|
||||||
|
|
||||||
#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
mod limits;
|
mod limits;
|
||||||
mod render;
|
mod render;
|
||||||
|
mod token;
|
||||||
|
|
||||||
|
pub use token::{Token, TokenKind, to_plain_text};
|
||||||
|
|
||||||
use std::{error::Error, fmt};
|
use std::{error::Error, fmt};
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
@@ -135,6 +166,15 @@ impl Error for RenderError {
|
|||||||
/// distinct; ref and validator bytes are ignored for display. Input is unmodified.
|
/// distinct; ref and validator bytes are ignored for display. Input is unmodified.
|
||||||
/// See [crate documentation](crate) for limits and caller responsibilities.
|
/// See [crate documentation](crate) for limits and caller responsibilities.
|
||||||
pub fn render_object(object: &Object) -> Result<String, RenderError> {
|
pub fn render_object(object: &Object) -> Result<String, RenderError> {
|
||||||
|
tokenize_object(object).map(|tokens| to_plain_text(&tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates the complete owned display token sequence for one Object signature.
|
||||||
|
///
|
||||||
|
/// Uses the same validation, limits, metadata exclusion and caller responsibilities
|
||||||
|
/// as [`render_object`]. No partial sequence is returned on error. Input is not
|
||||||
|
/// modified. See [`Token`] and [`TokenKind`] for semantic roles and splitting.
|
||||||
|
pub fn tokenize_object(object: &Object) -> Result<Vec<Token>, RenderError> {
|
||||||
limits::object(object)?;
|
limits::object(object)?;
|
||||||
object.validate().map_err(RenderError::InvalidObject)?;
|
object.validate().map_err(RenderError::InvalidObject)?;
|
||||||
Ok(render::object(object))
|
Ok(render::object(object))
|
||||||
@@ -148,6 +188,16 @@ pub fn render_object(object: &Object) -> Result<String, RenderError> {
|
|||||||
/// documentation summaries are shown, without interpreting Host text.
|
/// documentation summaries are shown, without interpreting Host text.
|
||||||
/// Input is unmodified. See [crate documentation](crate) for safety limits.
|
/// Input is unmodified. See [crate documentation](crate) for safety limits.
|
||||||
pub fn render_interface(interface: &Interface<'_>) -> Result<String, RenderError> {
|
pub fn render_interface(interface: &Interface<'_>) -> Result<String, RenderError> {
|
||||||
|
tokenize_interface(interface).map(|tokens| to_plain_text(&tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates the complete owned display token sequence for one Interface signature.
|
||||||
|
///
|
||||||
|
/// Uses the same validation, limits, format support and summary-only documentation
|
||||||
|
/// as [`render_interface`]. No partial sequence is returned on error. Input is not
|
||||||
|
/// modified. See [`Token`] and [`TokenKind`] for semantic roles and splitting.
|
||||||
|
/// This traverses structured declarations directly, not a rendered-text lexer.
|
||||||
|
pub fn tokenize_interface(interface: &Interface<'_>) -> Result<Vec<Token>, RenderError> {
|
||||||
limits::interface(interface)?;
|
limits::interface(interface)?;
|
||||||
interface
|
interface
|
||||||
.reference
|
.reference
|
||||||
@@ -164,3 +214,8 @@ pub fn render_interface(interface: &Interface<'_>) -> Result<String, RenderError
|
|||||||
}
|
}
|
||||||
Ok(render::interface(interface))
|
Ok(render::interface(interface))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep both README examples executable as external-consumer doctests.
|
||||||
|
#[cfg(doctest)]
|
||||||
|
#[doc = include_str!("../README.md")]
|
||||||
|
mod readme {}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
use wip_protocol::{Documentation, InterfaceReference, Object, OperationDeclaration, TypeExpr};
|
use wip_protocol::{Documentation, InterfaceReference, Object, OperationDeclaration, TypeExpr};
|
||||||
|
|
||||||
use crate::Interface;
|
use crate::{Interface, Token, TokenKind};
|
||||||
|
use TokenKind::{
|
||||||
|
CaseName, FieldName, Indentation, Keyword, Newline, OperationName, ParameterName, Reference,
|
||||||
|
Symbol, TypeName, Whitespace,
|
||||||
|
};
|
||||||
|
|
||||||
// Quoting a component and quoting the complete reference are separate layers.
|
// Quoting a component and quoting the complete reference are separate layers.
|
||||||
fn reference(reference: &InterfaceReference) -> String {
|
fn reference(reference: &InterfaceReference) -> String {
|
||||||
@@ -90,159 +94,240 @@ fn quote(value: &str, delimiter: char) -> String {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn object(object: &Object) -> String {
|
pub(crate) fn object(object: &Object) -> Vec<Token> {
|
||||||
let mut output = format!("object {{\n name: {};\n", quote(&object.name, '"'));
|
let mut writer = Writer(Vec::new());
|
||||||
|
writer.word(Keyword, "object");
|
||||||
|
writer.space();
|
||||||
|
writer.symbol("{");
|
||||||
|
writer.newline();
|
||||||
|
writer.indent(1);
|
||||||
|
writer.word(Keyword, "name");
|
||||||
|
writer.symbol(":");
|
||||||
|
writer.space();
|
||||||
|
writer.word(TokenKind::String, quote(&object.name, '"'));
|
||||||
|
writer.symbol(";");
|
||||||
|
writer.newline();
|
||||||
if let Some(description) = &object.description {
|
if let Some(description) = &object.description {
|
||||||
writeln!(output, " description: {};", quote(description, '"')).expect("String write");
|
writer.indent(1);
|
||||||
|
writer.word(Keyword, "description");
|
||||||
|
writer.symbol(":");
|
||||||
|
writer.space();
|
||||||
|
writer.word(TokenKind::String, quote(description, '"'));
|
||||||
|
writer.symbol(";");
|
||||||
|
writer.newline();
|
||||||
}
|
}
|
||||||
output.push_str(" interfaces: [");
|
writer.indent(1);
|
||||||
|
writer.word(Keyword, "interfaces");
|
||||||
|
writer.symbol(":");
|
||||||
|
writer.space();
|
||||||
|
writer.symbol("[");
|
||||||
for (index, interface) in object.interfaces.iter().enumerate() {
|
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
output.push_str(", ");
|
writer.symbol(",");
|
||||||
|
writer.space();
|
||||||
}
|
}
|
||||||
output.push_str("e(&reference(interface), '"'));
|
writer.word(Reference, quote(&reference(interface), '"'));
|
||||||
}
|
}
|
||||||
output.push_str("];\n}\n");
|
writer.symbol("]");
|
||||||
output
|
writer.symbol(";");
|
||||||
|
writer.newline();
|
||||||
|
writer.symbol("}");
|
||||||
|
writer.newline();
|
||||||
|
writer.0
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn interface(interface: &Interface<'_>) -> String {
|
pub(crate) fn interface(interface: &Interface<'_>) -> Vec<Token> {
|
||||||
let mut writer = Writer(String::new());
|
let mut writer = Writer(Vec::new());
|
||||||
writer.doc(&interface.descriptor.documentation, 0);
|
writer.doc(&interface.descriptor.documentation, 0);
|
||||||
writeln!(
|
writer.word(Keyword, "interface");
|
||||||
writer.0,
|
writer.space();
|
||||||
"interface {} {{",
|
writer.word(Reference, quote(&reference(interface.reference), '"'));
|
||||||
quote(&reference(interface.reference), '"')
|
writer.space();
|
||||||
)
|
writer.symbol("{");
|
||||||
.expect("String write");
|
writer.newline();
|
||||||
for declaration in &interface.descriptor.types {
|
for declaration in &interface.descriptor.types {
|
||||||
writer.doc(&declaration.documentation, 1);
|
writer.doc(&declaration.documentation, 1);
|
||||||
writer.indent(1);
|
writer.indent(1);
|
||||||
write!(writer.0, "type {} = ", identifier(&declaration.name)).expect("String write");
|
writer.word(Keyword, "type");
|
||||||
|
writer.space();
|
||||||
|
writer.word(TypeName, identifier(&declaration.name));
|
||||||
|
writer.space();
|
||||||
|
writer.symbol("=");
|
||||||
|
writer.space();
|
||||||
writer.expr(&declaration.definition, 1);
|
writer.expr(&declaration.definition, 1);
|
||||||
writer.0.push_str(";\n");
|
writer.symbol(";");
|
||||||
|
writer.newline();
|
||||||
}
|
}
|
||||||
for operation in &interface.descriptor.operations {
|
for operation in &interface.descriptor.operations {
|
||||||
writer.operation(operation);
|
writer.operation(operation);
|
||||||
}
|
}
|
||||||
writer.0.push_str("}\n");
|
writer.symbol("}");
|
||||||
|
writer.newline();
|
||||||
writer.0
|
writer.0
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Writer(String);
|
struct Writer(Vec<Token>);
|
||||||
|
|
||||||
impl Writer {
|
impl Writer {
|
||||||
|
fn word(&mut self, kind: TokenKind, text: impl Into<String>) {
|
||||||
|
self.0.push(Token {
|
||||||
|
kind,
|
||||||
|
text: text.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn symbol(&mut self, text: &str) {
|
||||||
|
self.word(Symbol, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn space(&mut self) {
|
||||||
|
self.word(Whitespace, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn newline(&mut self) {
|
||||||
|
self.word(Newline, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
fn indent(&mut self, depth: usize) {
|
fn indent(&mut self, depth: usize) {
|
||||||
for _ in 0..depth {
|
if depth > 0 {
|
||||||
self.0.push_str(" ");
|
self.word(Indentation, " ".repeat(depth));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn doc(&mut self, doc: &Option<Documentation>, depth: usize) {
|
fn doc(&mut self, doc: &Option<Documentation>, depth: usize) {
|
||||||
if let Some(doc) = doc {
|
if let Some(doc) = doc {
|
||||||
self.indent(depth);
|
self.indent(depth);
|
||||||
writeln!(self.0, "/// {}", quote(&doc.summary, '"')).expect("String write");
|
self.word(
|
||||||
|
TokenKind::Documentation,
|
||||||
|
format!("/// {}", quote(&doc.summary, '"')),
|
||||||
|
);
|
||||||
|
self.newline();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn member(&mut self, name: &str, required: bool, ty: &TypeExpr, depth: usize) {
|
fn member(&mut self, kind: TokenKind, name: &str, required: bool, ty: &TypeExpr, depth: usize) {
|
||||||
self.indent(depth);
|
self.indent(depth);
|
||||||
self.0.push_str(&identifier(name));
|
self.word(kind, identifier(name));
|
||||||
if !required {
|
if !required {
|
||||||
self.0.push('?');
|
self.symbol("?");
|
||||||
}
|
}
|
||||||
self.0.push_str(": ");
|
self.symbol(":");
|
||||||
|
self.space();
|
||||||
self.expr(ty, depth);
|
self.expr(ty, depth);
|
||||||
self.0.push_str(",\n");
|
self.symbol(",");
|
||||||
|
self.newline();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn operation(&mut self, operation: &OperationDeclaration) {
|
fn operation(&mut self, operation: &OperationDeclaration) {
|
||||||
self.doc(&operation.documentation, 1);
|
self.doc(&operation.documentation, 1);
|
||||||
self.indent(1);
|
self.indent(1);
|
||||||
write!(self.0, "operation {}(", identifier(&operation.name)).expect("String write");
|
self.word(Keyword, "operation");
|
||||||
|
self.space();
|
||||||
|
self.word(OperationName, identifier(&operation.name));
|
||||||
|
self.symbol("(");
|
||||||
if !operation.parameters.is_empty() {
|
if !operation.parameters.is_empty() {
|
||||||
self.0.push('\n');
|
self.newline();
|
||||||
for parameter in &operation.parameters {
|
for parameter in &operation.parameters {
|
||||||
self.doc(¶meter.documentation, 2);
|
self.doc(¶meter.documentation, 2);
|
||||||
self.member(¶meter.name, parameter.required, ¶meter.r#type, 2);
|
self.member(
|
||||||
|
ParameterName,
|
||||||
|
¶meter.name,
|
||||||
|
parameter.required,
|
||||||
|
¶meter.r#type,
|
||||||
|
2,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
self.indent(1);
|
self.indent(1);
|
||||||
}
|
}
|
||||||
self.0.push(')');
|
self.symbol(")");
|
||||||
if operation.returns.documentation.is_some() {
|
if operation.returns.documentation.is_some() {
|
||||||
self.0.push('\n');
|
self.newline();
|
||||||
self.doc(&operation.returns.documentation, 1);
|
self.doc(&operation.returns.documentation, 1);
|
||||||
self.indent(1);
|
self.indent(1);
|
||||||
self.0.push_str("-> ");
|
|
||||||
} else {
|
} else {
|
||||||
self.0.push_str(" -> ");
|
self.space();
|
||||||
}
|
}
|
||||||
|
self.symbol("->");
|
||||||
|
self.space();
|
||||||
self.expr(&operation.returns.r#type, 1);
|
self.expr(&operation.returns.r#type, 1);
|
||||||
self.0.push_str(";\n");
|
self.symbol(";");
|
||||||
|
self.newline();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starts at the current cursor. Composite closing braces align with the
|
// Starts at the current cursor. Composite closing braces align with the
|
||||||
// declaration/field/parameter containing this expression, not its width.
|
// declaration/field/parameter containing this expression, not its width.
|
||||||
fn expr(&mut self, expr: &TypeExpr, depth: usize) {
|
fn expr(&mut self, expr: &TypeExpr, depth: usize) {
|
||||||
match expr {
|
match expr {
|
||||||
TypeExpr::Unit => self.0.push_str("unit"),
|
TypeExpr::Unit => self.word(Keyword, "unit"),
|
||||||
TypeExpr::Boolean => self.0.push_str("boolean"),
|
TypeExpr::Boolean => self.word(Keyword, "boolean"),
|
||||||
TypeExpr::Integer => self.0.push_str("integer"),
|
TypeExpr::Integer => self.word(Keyword, "integer"),
|
||||||
TypeExpr::Number => self.0.push_str("number"),
|
TypeExpr::Number => self.word(Keyword, "number"),
|
||||||
TypeExpr::String => self.0.push_str("string"),
|
TypeExpr::String => self.word(Keyword, "string"),
|
||||||
TypeExpr::Bytes => self.0.push_str("bytes"),
|
TypeExpr::Bytes => self.word(Keyword, "bytes"),
|
||||||
TypeExpr::Json => self.0.push_str("json"),
|
TypeExpr::Json => self.word(Keyword, "json"),
|
||||||
TypeExpr::Entry => self.0.push_str("entry"),
|
TypeExpr::Entry => self.word(Keyword, "entry"),
|
||||||
TypeExpr::Named { name } => self.0.push_str(&identifier(name)),
|
TypeExpr::Named { name } => self.word(TypeName, identifier(name)),
|
||||||
TypeExpr::List { items } => {
|
TypeExpr::List { items } => {
|
||||||
self.0.push('[');
|
self.symbol("[");
|
||||||
self.expr(items, depth);
|
self.expr(items, depth);
|
||||||
self.0.push(']');
|
self.symbol("]");
|
||||||
}
|
}
|
||||||
TypeExpr::Record { fields } => {
|
TypeExpr::Record { fields } => {
|
||||||
self.0.push('{');
|
self.symbol("{");
|
||||||
if !fields.is_empty() {
|
if !fields.is_empty() {
|
||||||
self.0.push('\n');
|
self.newline();
|
||||||
for field in fields {
|
for field in fields {
|
||||||
self.doc(&field.documentation, depth + 1);
|
self.doc(&field.documentation, depth + 1);
|
||||||
self.member(&field.name, field.required, &field.r#type, depth + 1);
|
self.member(
|
||||||
|
FieldName,
|
||||||
|
&field.name,
|
||||||
|
field.required,
|
||||||
|
&field.r#type,
|
||||||
|
depth + 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
self.indent(depth);
|
self.indent(depth);
|
||||||
}
|
}
|
||||||
self.0.push('}');
|
self.symbol("}");
|
||||||
}
|
}
|
||||||
TypeExpr::Enum { cases } => {
|
TypeExpr::Enum { cases } => {
|
||||||
self.0.push_str("enum {");
|
self.word(Keyword, "enum");
|
||||||
|
self.space();
|
||||||
|
self.symbol("{");
|
||||||
if !cases.is_empty() {
|
if !cases.is_empty() {
|
||||||
self.0.push('\n');
|
self.newline();
|
||||||
for case in cases {
|
for case in cases {
|
||||||
self.doc(&case.documentation, depth + 1);
|
self.doc(&case.documentation, depth + 1);
|
||||||
self.indent(depth + 1);
|
self.indent(depth + 1);
|
||||||
self.0.push_str(&identifier(&case.name));
|
self.word(CaseName, identifier(&case.name));
|
||||||
self.0.push_str(",\n");
|
self.symbol(",");
|
||||||
|
self.newline();
|
||||||
}
|
}
|
||||||
self.indent(depth);
|
self.indent(depth);
|
||||||
}
|
}
|
||||||
self.0.push('}');
|
self.symbol("}");
|
||||||
}
|
}
|
||||||
TypeExpr::Union { cases } => {
|
TypeExpr::Union { cases } => {
|
||||||
self.0.push_str("union {");
|
self.word(Keyword, "union");
|
||||||
|
self.space();
|
||||||
|
self.symbol("{");
|
||||||
if !cases.is_empty() {
|
if !cases.is_empty() {
|
||||||
self.0.push('\n');
|
self.newline();
|
||||||
for case in cases {
|
for case in cases {
|
||||||
self.doc(&case.documentation, depth + 1);
|
self.doc(&case.documentation, depth + 1);
|
||||||
self.indent(depth + 1);
|
self.indent(depth + 1);
|
||||||
self.0.push_str(&identifier(&case.name));
|
self.word(CaseName, identifier(&case.name));
|
||||||
if let Some(payload) = &case.payload {
|
if let Some(payload) = &case.payload {
|
||||||
self.0.push('(');
|
self.symbol("(");
|
||||||
self.expr(payload, depth + 1);
|
self.expr(payload, depth + 1);
|
||||||
self.0.push(')');
|
self.symbol(")");
|
||||||
}
|
}
|
||||||
self.0.push_str(",\n");
|
self.symbol(",");
|
||||||
|
self.newline();
|
||||||
}
|
}
|
||||||
self.indent(depth);
|
self.indent(depth);
|
||||||
}
|
}
|
||||||
self.0.push('}');
|
self.symbol("}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Owned display data, independent of the Protocol wire model.
|
||||||
|
|
||||||
|
/// Semantic display role assigned from the input structure, not from text parsing.
|
||||||
|
///
|
||||||
|
/// Identifiers retain their role even when quoted or identical to a keyword.
|
||||||
|
/// Type declarations and Named expressions both use [`Self::TypeName`]. Enum and
|
||||||
|
/// union cases both use [`Self::CaseName`]. See [`Token`] for splitting rules.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum TokenKind {
|
||||||
|
/// Grammar keyword, including Object property labels and primitive types.
|
||||||
|
Keyword,
|
||||||
|
/// Named type declaration or reference within the same Interface.
|
||||||
|
TypeName,
|
||||||
|
/// Operation declaration name (not an invocation target).
|
||||||
|
OperationName,
|
||||||
|
/// Operation parameter name.
|
||||||
|
ParameterName,
|
||||||
|
/// Record field name.
|
||||||
|
FieldName,
|
||||||
|
/// Enum or union case name.
|
||||||
|
CaseName,
|
||||||
|
/// Quoted Object name or description.
|
||||||
|
String,
|
||||||
|
/// Entire quoted absolute Interface reference, including both quoting layers.
|
||||||
|
Reference,
|
||||||
|
/// Entire `/// "summary"` line content, excluding indentation and LF.
|
||||||
|
Documentation,
|
||||||
|
/// One punctuation mark, or the complete two-character return arrow `->`.
|
||||||
|
Symbol,
|
||||||
|
/// One inter-token ASCII space, never indentation or text inside quotes.
|
||||||
|
Whitespace,
|
||||||
|
/// One LF (`\n`), including the final signature LF.
|
||||||
|
Newline,
|
||||||
|
/// All leading spaces on a non-root line: two ASCII spaces per depth level.
|
||||||
|
Indentation,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One owned, already escaped fragment of a Text View signature.
|
||||||
|
///
|
||||||
|
/// Each keyword, identifier, quoted string and complete quoted reference is one
|
||||||
|
/// token, including its delimiters and escapes. Documentation is one token for
|
||||||
|
/// the `///` marker, space and quoted summary together. Symbols are individual
|
||||||
|
/// punctuation marks except `->`, which is indivisible. Spaces between tokens,
|
||||||
|
/// each LF, and each nonzero line indentation have separate tokens. No empty
|
||||||
|
/// tokens are emitted; an empty value still has its quotes. Adjacent symbols are
|
||||||
|
/// not merged. Host whitespace inside quoted data stays inside that data token.
|
||||||
|
///
|
||||||
|
/// The generation APIs emit all layout: concatenating [`Self::text`] in order,
|
||||||
|
/// without separators, produces exactly the existing plain-text signature.
|
||||||
|
/// Tokens own their text and can outlive the input; they are display data, not
|
||||||
|
/// an AST, lexer output, Protocol serialization or identity/call-target data.
|
||||||
|
///
|
||||||
|
/// Text is escaped for **WIP Text View**, not for HTML or any other destination.
|
||||||
|
/// Custom renderers must apply destination-specific escaping and must not treat
|
||||||
|
/// Host documentation as markup or instructions.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Token {
|
||||||
|
pub(crate) kind: TokenKind,
|
||||||
|
pub(crate) text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Token {
|
||||||
|
/// Returns the semantic display role, without reparsing the output text.
|
||||||
|
pub fn kind(&self) -> TokenKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the exact output fragment, with WIP quoting and escapes applied.
|
||||||
|
pub fn text(&self) -> &str {
|
||||||
|
&self.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concatenates token text in order, with no separators or further processing.
|
||||||
|
///
|
||||||
|
/// This performs no grammar interpretation, validation, quoting, escaping or
|
||||||
|
/// layout completion. An empty slice yields an empty string. Filtering or
|
||||||
|
/// reordering tokens is allowed, but need not produce a complete signature.
|
||||||
|
/// Object/Interface generation limits are checked by [`crate::tokenize_object`]
|
||||||
|
/// and [`crate::tokenize_interface`], not by this concatenation function.
|
||||||
|
pub fn to_plain_text(tokens: &[Token]) -> String {
|
||||||
|
tokens.iter().map(Token::text).collect()
|
||||||
|
}
|
||||||
@@ -1,6 +1,32 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use wip_protocol::*;
|
use wip_protocol::*;
|
||||||
use wip_text_view::{Interface, RenderError, ResourceLimit, render_interface, render_object};
|
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 {
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
InterfaceReference {
|
InterfaceReference {
|
||||||
@@ -803,3 +829,228 @@ fn input_limits_reject_without_truncation_and_allow_boundary() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user