feat: add standalone WIP Text View signatures
Render one Object or paired Interface entity without path/context, lookups or wire-schema changes. Preserve declarations, summaries, optional/unit and empty distinctions; validate protocol semantics and bounded input before rendering. Add five spec/nested goldens and 13 external API tests plus a standalone doctest. Validation: cargo fmt --all -- --check; cargo check --workspace --all-targets; cargo test --workspace --all-targets; cargo test --workspace --doc; nix develop -c bash (cargo clippy --workspace --all-targets -- -D warnings, RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps, cargo run --package wip-public-api-example); git diff --check. All passed. Default shell lacks clippy; declared Nix dev shell supplied it.
This commit is contained in:
@@ -4,6 +4,11 @@ All notable changes to the published WIP Rust crates are documented here.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- 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,
|
||||||
|
cache, UI, network or Protocol wire-schema changes are required.
|
||||||
|
|
||||||
- Breaking Protocol/HTTP migration from opaque Interface reference strings to
|
- Breaking Protocol/HTTP migration from opaque Interface reference strings to
|
||||||
exact `(scope, name)` records in Objects, fetches and calls, with optional
|
exact `(scope, name)` records in Objects, fetches and calls, with optional
|
||||||
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
||||||
|
|||||||
Generated
+7
@@ -1308,6 +1308,13 @@ dependencies = [
|
|||||||
"wip-protocol",
|
"wip-protocol",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wip-text-view"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"wip-protocol",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-tui"
|
name = "wip-tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ wip-http = { version = "0.1.0", path = "crates/wip-http" }
|
|||||||
wip-interface = { version = "0.1.0", path = "crates/wip-interface" }
|
wip-interface = { version = "0.1.0", path = "crates/wip-interface" }
|
||||||
wip-interface-macros = { version = "0.1.0", path = "crates/wip-interface-macros" }
|
wip-interface-macros = { version = "0.1.0", path = "crates/wip-interface-macros" }
|
||||||
wip-protocol = { version = "0.1.0", path = "crates/wip-protocol" }
|
wip-protocol = { version = "0.1.0", path = "crates/wip-protocol" }
|
||||||
|
wip-text-view = { version = "0.1.0", path = "crates/wip-text-view" }
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ under `crates/*`. It provides a transport-independent protocol model, the
|
|||||||
standard framework-independent WIP over HTTP v1 binding, a stateful
|
standard framework-independent WIP over HTTP v1 binding, a stateful
|
||||||
presentation-independent client runtime, a Rust interface-authoring facade, a
|
presentation-independent client runtime, a Rust interface-authoring facade, a
|
||||||
request-scoped host runtime, a bounded Linux filesystem projection server, an
|
request-scoped host runtime, a bounded Linux filesystem projection server, an
|
||||||
interactive terminal client, and a private public-API conformance consumer. The
|
interactive terminal client, a standalone Text View signature library, and a
|
||||||
|
private public-API conformance consumer. The
|
||||||
repository root is not itself a package.
|
repository root is not itself a package.
|
||||||
|
|
||||||
## Crate graph and responsibilities
|
## Crate graph and responsibilities
|
||||||
@@ -15,6 +16,9 @@ repository root is not itself a package.
|
|||||||
```text
|
```text
|
||||||
Published libraries
|
Published libraries
|
||||||
|
|
||||||
|
wip-text-view
|
||||||
|
└── wip-protocol
|
||||||
|
|
||||||
wip-client
|
wip-client
|
||||||
├── wip-http
|
├── wip-http
|
||||||
│ └── wip-protocol
|
│ └── wip-protocol
|
||||||
@@ -47,6 +51,7 @@ wip-public-api-example
|
|||||||
|
|
||||||
| Crate | Responsibility | Does not own |
|
| Crate | Responsibility | Does not own |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `wip-text-view` | Standalone, validated single-Object and single-Interface Text View signatures with full declarations, summaries and safe escapes. | Retrieval, cache, placement context, wire schema, UI layout, parsing or call-target reconstruction. |
|
||||||
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
|
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
|
||||||
| `wip-http` | Standard WIP over HTTP v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, and error/status classification. | HTTP I/O, URL scheme or TLS policy, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
|
| `wip-http` | Standard WIP over HTTP v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, and error/status classification. | HTTP I/O, URL scheme or TLS policy, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
|
||||||
| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
|
| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
|
||||||
@@ -68,6 +73,12 @@ The `0.1.0` release consists of these six crates:
|
|||||||
- `wip-http`
|
- `wip-http`
|
||||||
- `wip-client`
|
- `wip-client`
|
||||||
|
|
||||||
|
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;
|
||||||
|
`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.
|
||||||
|
|
||||||
The filesystem server, terminal client, and public-API example are workspace-only packages and set `publish = false`. All workspace packages require Rust 1.88 or newer and are licensed under either Apache-2.0 or MIT at your option.
|
The filesystem server, terminal client, and public-API example are workspace-only packages and set `publish = false`. All workspace packages require Rust 1.88 or newer and are licensed under either Apache-2.0 or MIT at your option.
|
||||||
|
|
||||||
See [RELEASING.md](RELEASING.md) for the dependency-ordered crates.io procedure and [CHANGELOG.md](CHANGELOG.md) for release contents.
|
See [RELEASING.md](RELEASING.md) for the dependency-ordered crates.io procedure and [CHANGELOG.md](CHANGELOG.md) for release contents.
|
||||||
|
|||||||
+6
-3
@@ -1,12 +1,12 @@
|
|||||||
# Releasing
|
# Releasing
|
||||||
|
|
||||||
The six crates published from this workspace 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 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.
|
||||||
|
|
||||||
## Preconditions
|
## Preconditions
|
||||||
|
|
||||||
1. Work from a clean `master` whose `HEAD` matches `origin/master`.
|
1. Work from a clean `master` whose `HEAD` matches `origin/master`.
|
||||||
2. Confirm the intended version in `[workspace.package]` and every internal requirement in `[workspace.dependencies]`.
|
2. Confirm the intended version in `[workspace.package]` and every internal requirement in `[workspace.dependencies]`.
|
||||||
3. Confirm ownership of all six names on crates.io.
|
3. Confirm ownership of all seven names on crates.io.
|
||||||
4. Run the complete repository validation suite:
|
4. Run the complete repository validation suite:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
@@ -44,6 +44,9 @@ cargo publish --package wip-interface-macros
|
|||||||
Wait until both versions are available from the crates.io index, then continue:
|
Wait until both versions are available from the crates.io index, then continue:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
|
cargo publish --dry-run --package wip-text-view
|
||||||
|
cargo publish --package wip-text-view
|
||||||
|
|
||||||
cargo publish --dry-run --package wip-interface
|
cargo publish --dry-run --package wip-interface
|
||||||
cargo publish --package wip-interface
|
cargo publish --package wip-interface
|
||||||
|
|
||||||
@@ -65,7 +68,7 @@ Registry propagation can take time. A dependent dry-run reporting that a WIP cra
|
|||||||
|
|
||||||
## Tag
|
## Tag
|
||||||
|
|
||||||
After all six uploads resolve successfully, tag the exact published commit and push the tag:
|
After all seven uploads resolve successfully, tag the exact published commit and push the tag:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
git tag -a v0.1.0 -m "release: 0.1.0"
|
git tag -a v0.1.0 -m "release: 0.1.0"
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "wip-text-view"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "Standalone Object and Interface Text View signatures for WIP"
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
documentation = "https://docs.rs/wip-text-view"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wip-protocol.workspace = true
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# wip-text-view
|
||||||
|
|
||||||
|
Standalone Object and Interface signature rendering for WIP Text View, following
|
||||||
|
`4.2-text-view.md` at reference revision `6086f3c` and the Protocol semantics in
|
||||||
|
`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.
|
||||||
|
|
||||||
|
## Single-entity API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wip_protocol::{Object, InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
|
||||||
|
use wip_text_view::{Interface, 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_signature = render_object(&object)?;
|
||||||
|
assert_eq!(object_signature,
|
||||||
|
"object {\n name: \"item\";\n interfaces: [\"/::example\"];\n}\n");
|
||||||
|
|
||||||
|
let descriptor = InterfaceDescriptor {
|
||||||
|
format: INTERFACE_FORMAT_V1.into(), documentation: None,
|
||||||
|
types: vec![], operations: vec![],
|
||||||
|
};
|
||||||
|
let interface = Interface { reference: &reference, descriptor: &descriptor };
|
||||||
|
let interface_signature = render_interface(&interface)?;
|
||||||
|
assert_eq!(interface_signature, "interface \"/::example\" {\n}\n");
|
||||||
|
# Ok::<(), wip_text_view::RenderError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
`Interface<'a>` is one borrowed SDK input entity containing a complete structured
|
||||||
|
reference and its corresponding descriptor, not a wire schema extension. The
|
||||||
|
caller pairs them from a coherent observation or publication. The descriptor has
|
||||||
|
no identity, so a renderer cannot prove the pairing's provenance or freshness.
|
||||||
|
For example, a caller with a validated `FetchInterfaceResponse` can borrow its
|
||||||
|
`interface` and `descriptor`; it does not pass `scope_ref` or validators.
|
||||||
|
|
||||||
|
An Object has no placement path. This API always emits `object { ... }`, never
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Output and validation
|
||||||
|
|
||||||
|
- UTF-8 plain text with LF, two-space indentation and a final LF, without ANSI,
|
||||||
|
Markdown fences, syntax highlighting or provider envelopes. Nonempty composites
|
||||||
|
and parameter lists use multiple lines; layout is deterministic here, but not a
|
||||||
|
byte-canonical protocol format.
|
||||||
|
- All Object references remain in Host order. All types, then all operations,
|
||||||
|
fields, parameters and cases remain in declaration order, without elision.
|
||||||
|
- Missing vs empty description/documentation, optional vs unit, no payload vs
|
||||||
|
unit payload, and empty record/enum/union/declaration sets remain distinct.
|
||||||
|
- Full absolute references use component quoting **and** outer JSON-string quoting.
|
||||||
|
Identifiers use keywords/backtick rules. All Host strings use standard JSON
|
||||||
|
escapes plus DEL/C1, Unicode line/paragraph separator and bidi-control escapes.
|
||||||
|
No normalization, case conversion, URL encoding or input mutation occurs.
|
||||||
|
- Documentation summaries are displayed in their prescribed positions, including
|
||||||
|
return documentation between `)` and `->`. Details are not displayed. Host text
|
||||||
|
is data, not renderer instructions or markup; quoting alone is not a prompt
|
||||||
|
injection defense. AI adapters must preserve the trust boundary.
|
||||||
|
- 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:
|
||||||
|
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
|
||||||
|
`UnsupportedDescriptorFormat`, distinct from `InvalidDescriptor`. Supported format
|
||||||
|
is exactly `wip-interface/1`. Other validation errors preserve the Protocol's typed
|
||||||
|
`ValidationError` (also exposed as the error source).
|
||||||
|
|
||||||
|
Bounded input limits are **1 MiB** of total string/opaque byte data, **16,384**
|
||||||
|
declarations/fields/parameters/cases/type expressions/references, **256** named type
|
||||||
|
declarations, and **64** TypeExpr nesting levels. Details and Object runtime
|
||||||
|
metadata count toward input bytes even though they are not rendered. A limit
|
||||||
|
returns `RenderError::ResourceLimit`, never truncates or supplies `...`. Counts
|
||||||
|
bound allocation and recursive validation before those validators run; these are
|
||||||
|
library safety limits, not Protocol restrictions.
|
||||||
|
|
||||||
|
Without path/context, this library cannot attest Object scope ancestry, placement,
|
||||||
|
observation consistency, identity, membership, authorization or currentness.
|
||||||
|
Callers validate those at their observation boundary. Callers also own retrieval,
|
||||||
|
resolution, cache, selection, multiple-signature arrangement and AI/UI adaptation.
|
||||||
|
Text is display-only, not serialization, a digest, identity/cache key or call
|
||||||
|
target. No parser or round-trip API is supplied.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
`tests/signatures.rs` is an external consumer of the public APIs. Goldens cover
|
||||||
|
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.
|
||||||
|
|
||||||
|
```console
|
||||||
|
cargo test -p wip-text-view
|
||||||
|
cargo clippy -p wip-text-view --all-targets -- -D warnings
|
||||||
|
RUSTDOCFLAGS='-D warnings' cargo doc -p wip-text-view --no-deps
|
||||||
|
```
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
//! Standalone, single-entity WIP Text View signatures.
|
||||||
|
//!
|
||||||
|
//! [`render_object`] accepts just an [`Object`], and [`render_interface`] accepts
|
||||||
|
//! just an [`Interface`] (a reference paired with its corresponding descriptor).
|
||||||
|
//! No Client, Host, HTTP, UI, retrieval, cache, path or display context is needed.
|
||||||
|
//! The caller supplies a coherent observation; descriptor identity cannot be
|
||||||
|
//! inferred from descriptor contents. No protocol wire fields are added.
|
||||||
|
//!
|
||||||
|
//! Output follows WIP `4.2-text-view.md` (reference revision `6086f3c`): UTF-8
|
||||||
|
//! plain text, LF, two-space indentation, all declarations in original order,
|
||||||
|
//! and documentation summaries only. There is no truncation or details setting.
|
||||||
|
//! Object paths are neither requested nor guessed: output uses `object { ... }`.
|
||||||
|
//! Runtime metadata is never rendered. Host documentation is untrusted data,
|
||||||
|
//! not markup or instructions; quoting does not by itself prevent prompt injection.
|
||||||
|
//!
|
||||||
|
//! Each call checks resource limits, then Protocol shape/semantic validation.
|
||||||
|
//! Interface rendering additionally requires [`wip_protocol::INTERFACE_FORMAT_V1`].
|
||||||
|
//! Limits reject the entire input, never produce partial text: at most 1 MiB of
|
||||||
|
//! input string/byte data, 16,384 declarations/fields/cases/type expressions or
|
||||||
|
//! references, 256 named type declarations (bounding validator graph recursion),
|
||||||
|
//! and 64 nested TypeExpr levels. These are implementation limits, not new
|
||||||
|
//! Protocol semantics. An Object without placement context can only be checked
|
||||||
|
//! for name/reference shape and duplicate references; the caller must validate
|
||||||
|
//! scope ancestry and observation consistency at its observation boundary.
|
||||||
|
//!
|
||||||
|
//! Signatures are display-only: no parser, round-trip, identity comparison,
|
||||||
|
//! cache key or call-target reconstruction is provided. The caller owns fetching,
|
||||||
|
//! freshness, authorization, selection and arrangement of multiple signatures.
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use wip_protocol::{Object, InterfaceReference, InterfaceDescriptor, INTERFACE_FORMAT_V1};
|
||||||
|
//! use wip_text_view::{Interface, 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: Some("not a path".into()), validator: Some(vec![1]),
|
||||||
|
//! };
|
||||||
|
//! assert_eq!(render_object(&object)?,
|
||||||
|
//! "object {\n name: \"item\";\n interfaces: [\"/::example\"];\n}\n");
|
||||||
|
//! let descriptor = InterfaceDescriptor {
|
||||||
|
//! format: INTERFACE_FORMAT_V1.into(), documentation: None,
|
||||||
|
//! types: vec![], operations: vec![],
|
||||||
|
//! };
|
||||||
|
//! let interface = Interface { reference: &reference, descriptor: &descriptor };
|
||||||
|
//! assert_eq!(render_interface(&interface)?, "interface \"/::example\" {\n}\n");
|
||||||
|
//! # Ok::<(), wip_text_view::RenderError>(())
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
|
mod limits;
|
||||||
|
mod render;
|
||||||
|
|
||||||
|
use std::{error::Error, fmt};
|
||||||
|
use wip_protocol::{
|
||||||
|
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceReference, Object, ValidationError,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One Interface input entity, borrowing its identity and corresponding data.
|
||||||
|
///
|
||||||
|
/// The caller pairs values from the same observation/publication. The descriptor
|
||||||
|
/// itself has no identity, so rendering cannot verify provenance or freshness.
|
||||||
|
/// This is an SDK display input, not a replacement wire schema or a document.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Interface<'a> {
|
||||||
|
/// Complete, absolute, Client-facing Interface reference.
|
||||||
|
pub reference: &'a InterfaceReference,
|
||||||
|
/// Descriptor corresponding to `reference`.
|
||||||
|
pub descriptor: &'a InterfaceDescriptor,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure to render a complete, validated signature. No partial text is returned.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum RenderError {
|
||||||
|
/// Object-independent Protocol validation failed.
|
||||||
|
InvalidObject(ValidationError),
|
||||||
|
/// The Interface's structured reference is malformed.
|
||||||
|
InvalidReference(ValidationError),
|
||||||
|
/// Descriptor structure or semantics failed Protocol validation.
|
||||||
|
InvalidDescriptor(ValidationError),
|
||||||
|
/// A semantically valid descriptor uses a format this renderer cannot interpret.
|
||||||
|
UnsupportedDescriptorFormat {
|
||||||
|
/// Exact unsupported format identifier.
|
||||||
|
format: String,
|
||||||
|
},
|
||||||
|
/// Input exceeds an implementation safety limit, without truncation.
|
||||||
|
ResourceLimit {
|
||||||
|
/// Limit that was exceeded.
|
||||||
|
limit: ResourceLimit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded input dimensions checked before recursive Protocol validation.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ResourceLimit {
|
||||||
|
/// Total input string and opaque byte length exceeds 1 MiB.
|
||||||
|
InputBytes,
|
||||||
|
/// Total declarations, fields, parameters, cases, expressions or references exceeds 16,384.
|
||||||
|
Nodes,
|
||||||
|
/// More than 256 named type declarations.
|
||||||
|
TypeDeclarations,
|
||||||
|
/// More than 64 nested TypeExpr levels (outer expression is level one).
|
||||||
|
TypeDepth,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RenderError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidObject(e) => write!(f, "invalid Object: {e}"),
|
||||||
|
Self::InvalidReference(e) => write!(f, "invalid Interface reference: {e}"),
|
||||||
|
Self::InvalidDescriptor(e) => write!(f, "invalid Interface descriptor: {e}"),
|
||||||
|
Self::UnsupportedDescriptorFormat { format } => {
|
||||||
|
write!(f, "unsupported descriptor format: {format:?}")
|
||||||
|
}
|
||||||
|
Self::ResourceLimit { limit } => write!(f, "Text View input exceeds {limit:?} limit"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for RenderError {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::InvalidObject(e) | Self::InvalidReference(e) | Self::InvalidDescriptor(e) => {
|
||||||
|
Some(e)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders one complete Object signature, with no placement path or inferred data.
|
||||||
|
///
|
||||||
|
/// Validates shape and ordered-set uniqueness, but cannot validate ancestry or
|
||||||
|
/// root/non-root placement without context. Missing and empty descriptions remain
|
||||||
|
/// 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> {
|
||||||
|
limits::object(object)?;
|
||||||
|
object.validate().map_err(RenderError::InvalidObject)?;
|
||||||
|
Ok(render::object(object))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders one complete Interface signature from one paired input entity.
|
||||||
|
///
|
||||||
|
/// Validates reference shape and all descriptor declarations/named references,
|
||||||
|
/// including cycles, before checking format support. No lookup or Object is
|
||||||
|
/// needed. All types precede all operations, with original member order; only
|
||||||
|
/// 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> {
|
||||||
|
limits::interface(interface)?;
|
||||||
|
interface
|
||||||
|
.reference
|
||||||
|
.validate()
|
||||||
|
.map_err(RenderError::InvalidReference)?;
|
||||||
|
interface
|
||||||
|
.descriptor
|
||||||
|
.validate()
|
||||||
|
.map_err(RenderError::InvalidDescriptor)?;
|
||||||
|
if interface.descriptor.format != INTERFACE_FORMAT_V1 {
|
||||||
|
return Err(RenderError::UnsupportedDescriptorFormat {
|
||||||
|
format: interface.descriptor.format.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(render::interface(interface))
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
use wip_protocol::{Documentation, InterfaceReference, Object, TypeExpr};
|
||||||
|
|
||||||
|
use crate::{Interface, RenderError, ResourceLimit};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Budget {
|
||||||
|
bytes: usize,
|
||||||
|
nodes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(value: usize, max: usize, limit: ResourceLimit) -> Result<(), RenderError> {
|
||||||
|
if value > max {
|
||||||
|
Err(RenderError::ResourceLimit { limit })
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Budget {
|
||||||
|
fn bytes(&mut self, len: usize) -> Result<(), RenderError> {
|
||||||
|
self.bytes = self.bytes.saturating_add(len);
|
||||||
|
check(self.bytes, 1024 * 1024, ResourceLimit::InputBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nodes(&mut self, len: usize) -> Result<(), RenderError> {
|
||||||
|
self.nodes = self.nodes.saturating_add(len);
|
||||||
|
check(self.nodes, 16_384, ResourceLimit::Nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reference(&mut self, reference: &InterfaceReference) -> Result<(), RenderError> {
|
||||||
|
self.nodes(1)?;
|
||||||
|
self.bytes(reference.scope.len())?;
|
||||||
|
self.bytes(reference.name.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc(&mut self, doc: &Option<Documentation>) -> Result<(), RenderError> {
|
||||||
|
if let Some(doc) = doc {
|
||||||
|
self.bytes(doc.summary.len())?;
|
||||||
|
self.bytes(doc.details.as_ref().map_or(0, String::len))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn declaration(&mut self, name: &str, doc: &Option<Documentation>) -> Result<(), RenderError> {
|
||||||
|
self.nodes(1)?;
|
||||||
|
self.bytes(name.len())?;
|
||||||
|
self.doc(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expr(&mut self, expr: &TypeExpr, depth: usize) -> Result<(), RenderError> {
|
||||||
|
check(depth, 64, ResourceLimit::TypeDepth)?;
|
||||||
|
self.nodes(1)?;
|
||||||
|
match expr {
|
||||||
|
TypeExpr::Named { name } => self.bytes(name.len())?,
|
||||||
|
TypeExpr::List { items } => self.expr(items, depth + 1)?,
|
||||||
|
TypeExpr::Record { fields } => {
|
||||||
|
self.nodes(fields.len())?;
|
||||||
|
for field in fields {
|
||||||
|
self.bytes(field.name.len())?;
|
||||||
|
self.doc(&field.documentation)?;
|
||||||
|
self.expr(&field.r#type, depth + 1)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TypeExpr::Enum { cases } => {
|
||||||
|
self.nodes(cases.len())?;
|
||||||
|
for case in cases {
|
||||||
|
self.bytes(case.name.len())?;
|
||||||
|
self.doc(&case.documentation)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TypeExpr::Union { cases } => {
|
||||||
|
self.nodes(cases.len())?;
|
||||||
|
for case in cases {
|
||||||
|
self.bytes(case.name.len())?;
|
||||||
|
self.doc(&case.documentation)?;
|
||||||
|
if let Some(payload) = &case.payload {
|
||||||
|
self.expr(payload, depth + 1)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn object(object: &Object) -> Result<(), RenderError> {
|
||||||
|
let mut budget = Budget::default();
|
||||||
|
budget.bytes(object.name.len())?;
|
||||||
|
budget.bytes(object.description.as_ref().map_or(0, String::len))?;
|
||||||
|
budget.bytes(object.r#ref.as_ref().map_or(0, String::len))?;
|
||||||
|
budget.bytes(object.validator.as_ref().map_or(0, Vec::len))?;
|
||||||
|
check(object.interfaces.len(), 16_384, ResourceLimit::Nodes)?;
|
||||||
|
for reference in &object.interfaces {
|
||||||
|
budget.reference(reference)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn interface(interface: &Interface<'_>) -> Result<(), RenderError> {
|
||||||
|
let mut budget = Budget::default();
|
||||||
|
let descriptor = interface.descriptor;
|
||||||
|
budget.reference(interface.reference)?;
|
||||||
|
budget.bytes(descriptor.format.len())?;
|
||||||
|
budget.doc(&descriptor.documentation)?;
|
||||||
|
check(descriptor.types.len(), 256, ResourceLimit::TypeDeclarations)?;
|
||||||
|
// Check collection counts before traversing even empty names/expressions.
|
||||||
|
check(descriptor.operations.len(), 16_384, ResourceLimit::Nodes)?;
|
||||||
|
for ty in &descriptor.types {
|
||||||
|
budget.declaration(&ty.name, &ty.documentation)?;
|
||||||
|
budget.expr(&ty.definition, 1)?;
|
||||||
|
}
|
||||||
|
for operation in &descriptor.operations {
|
||||||
|
budget.declaration(&operation.name, &operation.documentation)?;
|
||||||
|
check(operation.parameters.len(), 16_384, ResourceLimit::Nodes)?;
|
||||||
|
for parameter in &operation.parameters {
|
||||||
|
budget.declaration(¶meter.name, ¶meter.documentation)?;
|
||||||
|
budget.expr(¶meter.r#type, 1)?;
|
||||||
|
}
|
||||||
|
budget.nodes(1)?; // Return declaration.
|
||||||
|
budget.doc(&operation.returns.documentation)?;
|
||||||
|
budget.expr(&operation.returns.r#type, 1)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
use std::fmt::Write as _;
|
||||||
|
use wip_protocol::{Documentation, InterfaceReference, Object, OperationDeclaration, TypeExpr};
|
||||||
|
|
||||||
|
use crate::Interface;
|
||||||
|
|
||||||
|
// Quoting a component and quoting the complete reference are separate layers.
|
||||||
|
fn reference(reference: &InterfaceReference) -> String {
|
||||||
|
format!(
|
||||||
|
"{}::{}",
|
||||||
|
component(&reference.scope, true),
|
||||||
|
component(&reference.name, false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component(value: &str, scope: bool) -> String {
|
||||||
|
if !value.is_empty()
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b) || (scope && b == b'/'))
|
||||||
|
{
|
||||||
|
value.into()
|
||||||
|
} else {
|
||||||
|
quote(value, '"')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identifier(value: &str) -> String {
|
||||||
|
const KEYWORDS: &[&str] = &[
|
||||||
|
"object",
|
||||||
|
"at",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"interfaces",
|
||||||
|
"interface",
|
||||||
|
"type",
|
||||||
|
"operation",
|
||||||
|
"enum",
|
||||||
|
"union",
|
||||||
|
"unit",
|
||||||
|
"boolean",
|
||||||
|
"integer",
|
||||||
|
"number",
|
||||||
|
"string",
|
||||||
|
"bytes",
|
||||||
|
"json",
|
||||||
|
"entry",
|
||||||
|
];
|
||||||
|
if value
|
||||||
|
.bytes()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||||
|
&& !KEYWORDS.contains(&value)
|
||||||
|
{
|
||||||
|
value.into()
|
||||||
|
} else {
|
||||||
|
quote(value, '`')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote(value: &str, delimiter: char) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
output.push(delimiter);
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'`' if delimiter == '`' => output.push_str("\\`"),
|
||||||
|
'\\' => output.push_str("\\\\"),
|
||||||
|
'"' => output.push_str("\\\""),
|
||||||
|
'\u{08}' => output.push_str("\\b"),
|
||||||
|
'\u{0c}' => output.push_str("\\f"),
|
||||||
|
'\n' => output.push_str("\\n"),
|
||||||
|
'\r' => output.push_str("\\r"),
|
||||||
|
'\t' => output.push_str("\\t"),
|
||||||
|
'\u{00}'..='\u{1f}'
|
||||||
|
| '\u{7f}'..='\u{9f}'
|
||||||
|
| '\u{2028}'
|
||||||
|
| '\u{2029}'
|
||||||
|
| '\u{061c}'
|
||||||
|
| '\u{200e}'..='\u{200f}'
|
||||||
|
| '\u{202a}'..='\u{202e}'
|
||||||
|
| '\u{2066}'..='\u{2069}' => {
|
||||||
|
write!(output, "\\u{:04x}", ch as u32).expect("String write");
|
||||||
|
}
|
||||||
|
_ => output.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.push(delimiter);
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn object(object: &Object) -> String {
|
||||||
|
let mut output = format!("object {{\n name: {};\n", quote(&object.name, '"'));
|
||||||
|
if let Some(description) = &object.description {
|
||||||
|
writeln!(output, " description: {};", quote(description, '"')).expect("String write");
|
||||||
|
}
|
||||||
|
output.push_str(" interfaces: [");
|
||||||
|
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
output.push_str(", ");
|
||||||
|
}
|
||||||
|
output.push_str("e(&reference(interface), '"'));
|
||||||
|
}
|
||||||
|
output.push_str("];\n}\n");
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn interface(interface: &Interface<'_>) -> String {
|
||||||
|
let mut writer = Writer(String::new());
|
||||||
|
writer.doc(&interface.descriptor.documentation, 0);
|
||||||
|
writeln!(
|
||||||
|
writer.0,
|
||||||
|
"interface {} {{",
|
||||||
|
quote(&reference(interface.reference), '"')
|
||||||
|
)
|
||||||
|
.expect("String write");
|
||||||
|
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.expr(&declaration.definition, 1);
|
||||||
|
writer.0.push_str(";\n");
|
||||||
|
}
|
||||||
|
for operation in &interface.descriptor.operations {
|
||||||
|
writer.operation(operation);
|
||||||
|
}
|
||||||
|
writer.0.push_str("}\n");
|
||||||
|
writer.0
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Writer(String);
|
||||||
|
|
||||||
|
impl Writer {
|
||||||
|
fn indent(&mut self, depth: usize) {
|
||||||
|
for _ in 0..depth {
|
||||||
|
self.0.push_str(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn member(&mut self, name: &str, required: bool, ty: &TypeExpr, depth: usize) {
|
||||||
|
self.indent(depth);
|
||||||
|
self.0.push_str(&identifier(name));
|
||||||
|
if !required {
|
||||||
|
self.0.push('?');
|
||||||
|
}
|
||||||
|
self.0.push_str(": ");
|
||||||
|
self.expr(ty, depth);
|
||||||
|
self.0.push_str(",\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operation(&mut self, operation: &OperationDeclaration) {
|
||||||
|
self.doc(&operation.documentation, 1);
|
||||||
|
self.indent(1);
|
||||||
|
write!(self.0, "operation {}(", identifier(&operation.name)).expect("String write");
|
||||||
|
if !operation.parameters.is_empty() {
|
||||||
|
self.0.push('\n');
|
||||||
|
for parameter in &operation.parameters {
|
||||||
|
self.doc(¶meter.documentation, 2);
|
||||||
|
self.member(¶meter.name, parameter.required, ¶meter.r#type, 2);
|
||||||
|
}
|
||||||
|
self.indent(1);
|
||||||
|
}
|
||||||
|
self.0.push(')');
|
||||||
|
if operation.returns.documentation.is_some() {
|
||||||
|
self.0.push('\n');
|
||||||
|
self.doc(&operation.returns.documentation, 1);
|
||||||
|
self.indent(1);
|
||||||
|
self.0.push_str("-> ");
|
||||||
|
} else {
|
||||||
|
self.0.push_str(" -> ");
|
||||||
|
}
|
||||||
|
self.expr(&operation.returns.r#type, 1);
|
||||||
|
self.0.push_str(";\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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::List { items } => {
|
||||||
|
self.0.push('[');
|
||||||
|
self.expr(items, depth);
|
||||||
|
self.0.push(']');
|
||||||
|
}
|
||||||
|
TypeExpr::Record { fields } => {
|
||||||
|
self.0.push('{');
|
||||||
|
if !fields.is_empty() {
|
||||||
|
self.0.push('\n');
|
||||||
|
for field in fields {
|
||||||
|
self.doc(&field.documentation, depth + 1);
|
||||||
|
self.member(&field.name, field.required, &field.r#type, depth + 1);
|
||||||
|
}
|
||||||
|
self.indent(depth);
|
||||||
|
}
|
||||||
|
self.0.push('}');
|
||||||
|
}
|
||||||
|
TypeExpr::Enum { cases } => {
|
||||||
|
self.0.push_str("enum {");
|
||||||
|
if !cases.is_empty() {
|
||||||
|
self.0.push('\n');
|
||||||
|
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.indent(depth);
|
||||||
|
}
|
||||||
|
self.0.push('}');
|
||||||
|
}
|
||||||
|
TypeExpr::Union { cases } => {
|
||||||
|
self.0.push_str("union {");
|
||||||
|
if !cases.is_empty() {
|
||||||
|
self.0.push('\n');
|
||||||
|
for case in cases {
|
||||||
|
self.doc(&case.documentation, depth + 1);
|
||||||
|
self.indent(depth + 1);
|
||||||
|
self.0.push_str(&identifier(&case.name));
|
||||||
|
if let Some(payload) = &case.payload {
|
||||||
|
self.0.push('(');
|
||||||
|
self.expr(payload, depth + 1);
|
||||||
|
self.0.push(')');
|
||||||
|
}
|
||||||
|
self.0.push_str(",\n");
|
||||||
|
}
|
||||||
|
self.indent(depth);
|
||||||
|
}
|
||||||
|
self.0.push('}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
interface "/::example" {
|
||||||
|
type `string` = {
|
||||||
|
`表示名`: string,
|
||||||
|
};
|
||||||
|
operation `find-item`(
|
||||||
|
`search term`: string,
|
||||||
|
) -> `string`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// "Issueの内容と関連項目を読む。"
|
||||||
|
interface "/::issue.read" {
|
||||||
|
type Relation = enum {
|
||||||
|
parent,
|
||||||
|
sibling,
|
||||||
|
related,
|
||||||
|
};
|
||||||
|
type RelatedItems = {
|
||||||
|
/// "同じWorldspace内の関連項目。"
|
||||||
|
items: [entry],
|
||||||
|
next_cursor?: string,
|
||||||
|
};
|
||||||
|
/// "本文を取得する。"
|
||||||
|
operation read_body() -> {
|
||||||
|
body: string,
|
||||||
|
};
|
||||||
|
/// "関係を指定して関連項目を取得する。"
|
||||||
|
operation get_related_items(
|
||||||
|
relation: Relation,
|
||||||
|
limit?: integer,
|
||||||
|
) -> RelatedItems;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
interface "/::example" {
|
||||||
|
/// "type summary"
|
||||||
|
type Z = {
|
||||||
|
/// "field summary"
|
||||||
|
z?: unit,
|
||||||
|
a: [union {
|
||||||
|
/// "case summary"
|
||||||
|
z,
|
||||||
|
/// ""
|
||||||
|
a(unit),
|
||||||
|
nested(enum {
|
||||||
|
/// "enum summary"
|
||||||
|
z,
|
||||||
|
a,
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
operation z(
|
||||||
|
z: unit,
|
||||||
|
a?: unit,
|
||||||
|
) -> unit;
|
||||||
|
operation a()
|
||||||
|
/// "no params result"
|
||||||
|
-> unit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
object {
|
||||||
|
name: "123";
|
||||||
|
description: "Issue 123の公開操作。本文はOperationで取得する。";
|
||||||
|
interfaces: ["/::issue.read", "/::issue.lifecycle"];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
interface "/::example" {
|
||||||
|
type CloseResult = union {
|
||||||
|
closed,
|
||||||
|
blocked({
|
||||||
|
dependencies: [entry],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
operation refresh() -> unit;
|
||||||
|
/// "対象を検索する。"
|
||||||
|
operation search(
|
||||||
|
/// "検索語。"
|
||||||
|
query: string,
|
||||||
|
)
|
||||||
|
/// "条件に一致する項目。"
|
||||||
|
-> {
|
||||||
|
items: [entry],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,805 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
use wip_protocol::*;
|
||||||
|
use wip_text_view::{Interface, RenderError, ResourceLimit, render_interface, render_object};
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user