Compare commits

...
Author SHA1 Message Date
Hare 3c3edf4c6f chore: prepare 0.2.0 release 2026-10-09 00:35:06 +09:00
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
16 changed files with 678 additions and 104 deletions
+8
View File
@@ -4,6 +4,14 @@ All notable changes to the published WIP Rust crates are documented here.
## Unreleased
## 0.2.0
- Adds public Text View `Token`/`TokenKind`, `tokenize_object`,
`tokenize_interface`, and delimiter-free `to_plain_text`. Direct rendering uses
the same token generation path; HTML and terminal-color renderers are not included.
- Makes Client Interface caches scope-lifetime-aware, invalidates observations on
confirmed binding loss, and retains immutable contexts for in-flight calls.
- Adds `wip-text-view`, an independent single-entity Object/Interface signature
library with Protocol validation, typed errors, full ordered declarations,
documentation summaries and safe Text View quoting/escapes. No path, lookup,
Generated
+10 -10
View File
@@ -1235,7 +1235,7 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "wip-client"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"wip-http",
"wip-protocol",
@@ -1243,7 +1243,7 @@ dependencies = [
[[package]]
name = "wip-fs-server"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"http",
"rustix 1.1.5",
@@ -1256,7 +1256,7 @@ dependencies = [
[[package]]
name = "wip-host"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"trybuild",
"wip-interface",
@@ -1265,7 +1265,7 @@ dependencies = [
[[package]]
name = "wip-http"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"http",
@@ -1277,7 +1277,7 @@ dependencies = [
[[package]]
name = "wip-interface"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"trybuild",
"wip-interface-macros",
@@ -1286,7 +1286,7 @@ dependencies = [
[[package]]
name = "wip-interface-macros"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"proc-macro2",
"quote",
@@ -1295,11 +1295,11 @@ dependencies = [
[[package]]
name = "wip-protocol"
version = "0.1.0"
version = "0.2.0"
[[package]]
name = "wip-public-api-example"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"wip-client",
"wip-host",
@@ -1310,14 +1310,14 @@ dependencies = [
[[package]]
name = "wip-text-view"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"wip-protocol",
]
[[package]]
name = "wip-tui"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"crossterm",
+9 -9
View File
@@ -3,18 +3,18 @@ members = ["crates/*"]
resolver = "3"
[workspace.package]
version = "0.1.0"
version = "0.2.0"
edition = "2024"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
repository = "https://gitea.hareworks.net/Hare/wip-rs"
[workspace.dependencies]
wip-client = { version = "0.1.0", path = "crates/wip-client" }
wip-fs-server = { version = "0.1.0", path = "crates/wip-fs-server" }
wip-host = { version = "0.1.0", path = "crates/wip-host" }
wip-http = { version = "0.1.0", path = "crates/wip-http" }
wip-interface = { version = "0.1.0", path = "crates/wip-interface" }
wip-interface-macros = { version = "0.1.0", path = "crates/wip-interface-macros" }
wip-protocol = { version = "0.1.0", path = "crates/wip-protocol" }
wip-text-view = { version = "0.1.0", path = "crates/wip-text-view" }
wip-client = { version = "0.2.0", path = "crates/wip-client" }
wip-fs-server = { version = "0.2.0", path = "crates/wip-fs-server" }
wip-host = { version = "0.2.0", path = "crates/wip-host" }
wip-http = { version = "0.2.0", path = "crates/wip-http" }
wip-interface = { version = "0.2.0", path = "crates/wip-interface" }
wip-interface-macros = { version = "0.2.0", path = "crates/wip-interface-macros" }
wip-protocol = { version = "0.2.0", path = "crates/wip-protocol" }
wip-text-view = { version = "0.2.0", path = "crates/wip-text-view" }
+1 -1
View File
@@ -1,6 +1,6 @@
# Structured Interface reference and scope publication migration
This unreleased API/wire change follows `wip-reference` canonical Core Model,
The 0.2.0 API/wire change follows `wip-reference` canonical Core Model,
Protocol and WIP over HTTP at `6086f3c5ef10aa1464ce9c824750d7f667217bfd`.
It replaces the earlier opaque Interface reference contract; HTTP routes and
error codes have **not** changed. Peers must upgrade together: the codec does
+6 -5
View File
@@ -58,13 +58,13 @@ wip-public-api-example
| `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTP I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. |
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
| `wip-host` | Scoped interface registration and publication lifetimes, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
| `wip-fs-server` | Linux directory-handle-confined request-time projection, complete depth-bounded observations and bounded file operations, standard `wip-http` dispatch, and a loopback HTTP binary. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, TLS termination, or filesystem-wide preregistration. |
| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
## Published crates
The `0.1.0` release consists of these six crates:
The `0.2.0` release consists of these seven crates:
- `wip-protocol`
- `wip-interface-macros` — implementation dependency re-exported by `wip-interface`
@@ -72,9 +72,10 @@ The `0.1.0` release consists of these six crates:
- `wip-host`
- `wip-http`
- `wip-client`
- `wip-text-view`
The unreleased workspace also adds [`wip-text-view`](crates/wip-text-view/README.md)
as a seventh public library. Its `render_object(&Object)` API needs no path;
The [`wip-text-view`](crates/wip-text-view/README.md) library's
`render_object(&Object)` API needs no path;
`render_interface(&Interface)` takes one borrowed entity pairing the complete
structured reference with its descriptor, without changing Protocol wire fields.
Both return one complete plain-text signature or a typed error.
@@ -92,7 +93,7 @@ or application traits.
## Structured Interface references
The unreleased Protocol/HTTP API uses explicit `InterfaceReference { scope,
The 0.2.0 Protocol/HTTP API uses explicit `InterfaceReference { scope,
name }` records and optional `scope_ref` observation/precondition metadata.
Legacy opaque-string wire references are rejected. See [MIGRATION.md](MIGRATION.md)
for construction examples, validation boundaries and downstream migration status.
+3 -3
View File
@@ -1,6 +1,6 @@
# Releasing
The seven public crates in the current workspace (including the unreleased `wip-text-view`) use one version and must be released in dependency order. The filesystem server, terminal client, and public-API example set `publish = false` and are never uploaded.
The seven public crates in the current workspace (including `wip-text-view`) use one version and must be released in dependency order. The filesystem server, terminal client, and public-API example set `publish = false` and are never uploaded.
## Preconditions
@@ -71,8 +71,8 @@ Registry propagation can take time. A dependent dry-run reporting that a WIP cra
After all seven uploads resolve successfully, tag the exact published commit and push the tag:
```console
git tag -a v0.1.0 -m "release: 0.1.0"
git push origin v0.1.0
git tag -a v0.2.0 -m "chore: release 0.2.0"
git push origin v0.2.0
```
Do not move or recreate a published version or its tag. Prepare a new version for any subsequent correction.
+1 -1
View File
@@ -6,7 +6,7 @@ Presentation-independent stateful runtime for Web Interface Protocol (WIP) clien
```toml
[dependencies]
wip-client = "0.1.0"
wip-client = "0.2.0"
```
Applications provide the HTTP adapter and presentation layer, send each prepared request exactly as owned by the runtime, then return the corresponding response for completion.
+3 -3
View File
@@ -6,11 +6,11 @@ Framework-independent Host publication and operation dispatch for the Web Interf
```toml
[dependencies]
wip-host = "0.1.0"
wip-interface = "0.1.0"
wip-host = "0.2.0"
wip-interface = "0.2.0"
```
Protocol publication and dispatch use `wip_protocol::InterfaceReference { scope, name }` and compare both fields exactly. The legacy `InterfaceRef` registration token remains Host-private: `protocol_reference()` maps its opaque string verbatim to the name at root scope `/`, without parsing it. Registry lookup and fetch accept structured references; non-root references do not resolve legacy registrations. Legacy responses omit `scope_ref`.
Protocol publication and dispatch use `wip_protocol::InterfaceReference { scope, name }` and compare both fields exactly. Registration takes an explicit `Scope` and local name; `InterfaceRef::protocol_reference()` preserves those coordinates without parsing display text. Providers attach a current `ScopeObservation` inside the coherent backend boundary, and fetch returns its optional Object ref as `scope_ref`. Calls check a supplied `scope_ref` against that current binding; omission adds no identity precondition. Retire a Scope on Object deletion, replacement or movement; retained registry artifacts alone do not establish publication.
Malformed interface-reference shapes fail calls as `InvalidRequest` before object resolution. A well-shaped reference outside the target path's ancestor-or-self scope fails at the membership check as `InterfaceMismatch`.
+1 -1
View File
@@ -6,7 +6,7 @@ The crate maps the three WIP Core interactions to fixed HTTP routes and strict U
```toml
[dependencies]
wip-http = "0.1.0"
wip-http = "0.2.0"
```
```rust
+1 -1
View File
@@ -6,7 +6,7 @@ The crate re-exports `#[interface]` and `#[derive(WipType)]`, generates Interfac
```toml
[dependencies]
wip-interface = "0.1.0"
wip-interface = "0.2.0"
```
```rust
+2 -2
View File
@@ -6,7 +6,7 @@ This crate defines the canonical Object and Interface Descriptor models, the thr
```toml
[dependencies]
wip-protocol = "0.1.0"
wip-protocol = "0.2.0"
```
Interface identity is an exact `InterfaceReference { scope, name }` pair.
@@ -16,7 +16,7 @@ and Object/interaction validation at response boundaries. Fetching needs no
target Object path. Fetch responses and interface call targets carry optional
opaque string `scope_ref` metadata, not a third identity field. Descriptors do
not embed identity. See the repository's [migration guide](../../MIGRATION.md)
for the breaking Rust and HTTP wire changes and deferred consumer semantics.
for the breaking Rust and HTTP wire changes and consumer migration guidance.
See the [API documentation](https://docs.rs/wip-protocol) for the complete model and validation rules.
+93 -3
View File
@@ -5,6 +5,12 @@ Standalone Object and Interface signature rendering for WIP Text View, following
`3-protocol.md`. Only `wip-protocol` and the Rust standard library are dependencies.
No Client, Host, HTTP I/O, cache, UI framework or retrieval service is required.
```toml
[dependencies]
wip-text-view = "0.2.0"
wip-protocol = "0.2.0"
```
## Single-entity API
```rust
@@ -42,6 +48,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.
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
- UTF-8 plain text with LF, two-space indentation and a final LF, without ANSI,
@@ -63,8 +147,8 @@ There is no document aggregator, configuration or details selector.
- Object ref/validator, children, domain data and Interface scope_ref/validator
are not signature content. No annotations are inferred from operation names.
Both functions return `Result<String, RenderError>` and never return partial
signatures. Resource checks happen first, followed by existing Protocol validators:
The render functions return `Result<String, RenderError>`; token generation
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
structure, duplicate names, unknown Named references and cycles. Descriptor format
is checked after semantics: valid but unsupported formats return
@@ -94,7 +178,13 @@ the spec's Object, issue.read, CloseResult, search/return documentation and
identifier examples (with permitted multiline layout), plus nested documentation.
The suite checks all TypeExpr variants/positions, ordering and empty distinctions,
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
cargo test -p wip-text-view
+55
View File
@@ -45,11 +45,42 @@
//! assert_eq!(render_interface(&interface)?, "interface \"/::example\" {\n}\n");
//! # 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)]
mod limits;
mod render;
mod token;
pub use token::{Token, TokenKind, to_plain_text};
use std::{error::Error, fmt};
use wip_protocol::{
@@ -135,6 +166,15 @@ impl Error for RenderError {
/// distinct; ref and validator bytes are ignored for display. Input is unmodified.
/// See [crate documentation](crate) for limits and caller responsibilities.
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)?;
object.validate().map_err(RenderError::InvalidObject)?;
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.
/// Input is unmodified. See [crate documentation](crate) for safety limits.
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)?;
interface
.reference
@@ -164,3 +214,8 @@ pub fn render_interface(interface: &Interface<'_>) -> Result<String, RenderError
}
Ok(render::interface(interface))
}
// Keep both README examples executable as external-consumer doctests.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
mod readme {}
+149 -64
View File
@@ -1,7 +1,11 @@
use std::fmt::Write as _;
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.
fn reference(reference: &InterfaceReference) -> String {
@@ -90,159 +94,240 @@ fn quote(value: &str, delimiter: char) -> String {
output
}
pub(crate) fn object(object: &Object) -> String {
let mut output = format!("object {{\n name: {};\n", quote(&object.name, '"'));
pub(crate) fn object(object: &Object) -> Vec<Token> {
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 {
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() {
if index > 0 {
output.push_str(", ");
writer.symbol(",");
writer.space();
}
output.push_str(&quote(&reference(interface), '"'));
writer.word(Reference, quote(&reference(interface), '"'));
}
output.push_str("];\n}\n");
output
writer.symbol("]");
writer.symbol(";");
writer.newline();
writer.symbol("}");
writer.newline();
writer.0
}
pub(crate) fn interface(interface: &Interface<'_>) -> String {
let mut writer = Writer(String::new());
pub(crate) fn interface(interface: &Interface<'_>) -> Vec<Token> {
let mut writer = Writer(Vec::new());
writer.doc(&interface.descriptor.documentation, 0);
writeln!(
writer.0,
"interface {} {{",
quote(&reference(interface.reference), '"')
)
.expect("String write");
writer.word(Keyword, "interface");
writer.space();
writer.word(Reference, quote(&reference(interface.reference), '"'));
writer.space();
writer.symbol("{");
writer.newline();
for declaration in &interface.descriptor.types {
writer.doc(&declaration.documentation, 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.0.push_str(";\n");
writer.symbol(";");
writer.newline();
}
for operation in &interface.descriptor.operations {
writer.operation(operation);
}
writer.0.push_str("}\n");
writer.symbol("}");
writer.newline();
writer.0
}
struct Writer(String);
struct Writer(Vec<Token>);
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) {
for _ in 0..depth {
self.0.push_str(" ");
if depth > 0 {
self.word(Indentation, " ".repeat(depth));
}
}
fn doc(&mut self, doc: &Option<Documentation>, depth: usize) {
if let Some(doc) = doc {
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.0.push_str(&identifier(name));
self.word(kind, identifier(name));
if !required {
self.0.push('?');
self.symbol("?");
}
self.0.push_str(": ");
self.symbol(":");
self.space();
self.expr(ty, depth);
self.0.push_str(",\n");
self.symbol(",");
self.newline();
}
fn operation(&mut self, operation: &OperationDeclaration) {
self.doc(&operation.documentation, 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() {
self.0.push('\n');
self.newline();
for parameter in &operation.parameters {
self.doc(&parameter.documentation, 2);
self.member(&parameter.name, parameter.required, &parameter.r#type, 2);
self.member(
ParameterName,
&parameter.name,
parameter.required,
&parameter.r#type,
2,
);
}
self.indent(1);
}
self.0.push(')');
self.symbol(")");
if operation.returns.documentation.is_some() {
self.0.push('\n');
self.newline();
self.doc(&operation.returns.documentation, 1);
self.indent(1);
self.0.push_str("-> ");
} else {
self.0.push_str(" -> ");
self.space();
}
self.symbol("->");
self.space();
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
// declaration/field/parameter containing this expression, not its width.
fn expr(&mut self, expr: &TypeExpr, depth: usize) {
match expr {
TypeExpr::Unit => self.0.push_str("unit"),
TypeExpr::Boolean => self.0.push_str("boolean"),
TypeExpr::Integer => self.0.push_str("integer"),
TypeExpr::Number => self.0.push_str("number"),
TypeExpr::String => self.0.push_str("string"),
TypeExpr::Bytes => self.0.push_str("bytes"),
TypeExpr::Json => self.0.push_str("json"),
TypeExpr::Entry => self.0.push_str("entry"),
TypeExpr::Named { name } => self.0.push_str(&identifier(name)),
TypeExpr::Unit => self.word(Keyword, "unit"),
TypeExpr::Boolean => self.word(Keyword, "boolean"),
TypeExpr::Integer => self.word(Keyword, "integer"),
TypeExpr::Number => self.word(Keyword, "number"),
TypeExpr::String => self.word(Keyword, "string"),
TypeExpr::Bytes => self.word(Keyword, "bytes"),
TypeExpr::Json => self.word(Keyword, "json"),
TypeExpr::Entry => self.word(Keyword, "entry"),
TypeExpr::Named { name } => self.word(TypeName, identifier(name)),
TypeExpr::List { items } => {
self.0.push('[');
self.symbol("[");
self.expr(items, depth);
self.0.push(']');
self.symbol("]");
}
TypeExpr::Record { fields } => {
self.0.push('{');
self.symbol("{");
if !fields.is_empty() {
self.0.push('\n');
self.newline();
for field in fields {
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.0.push('}');
self.symbol("}");
}
TypeExpr::Enum { cases } => {
self.0.push_str("enum {");
self.word(Keyword, "enum");
self.space();
self.symbol("{");
if !cases.is_empty() {
self.0.push('\n');
self.newline();
for case in cases {
self.doc(&case.documentation, depth + 1);
self.indent(depth + 1);
self.0.push_str(&identifier(&case.name));
self.0.push_str(",\n");
self.word(CaseName, identifier(&case.name));
self.symbol(",");
self.newline();
}
self.indent(depth);
}
self.0.push('}');
self.symbol("}");
}
TypeExpr::Union { cases } => {
self.0.push_str("union {");
self.word(Keyword, "union");
self.space();
self.symbol("{");
if !cases.is_empty() {
self.0.push('\n');
self.newline();
for case in cases {
self.doc(&case.documentation, 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 {
self.0.push('(');
self.symbol("(");
self.expr(payload, depth + 1);
self.0.push(')');
self.symbol(")");
}
self.0.push_str(",\n");
self.symbol(",");
self.newline();
}
self.indent(depth);
}
self.0.push('}');
self.symbol("}");
}
}
}
+84
View File
@@ -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()
}
+252 -1
View File
@@ -1,6 +1,32 @@
use std::error::Error;
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 {
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)));
}