Skip to main content

bynk_lsp/
wire_contract_request.rs

1//! #855: `bynk/wireContract` — the wire-contract-peek custom LSP request.
2//!
3//! File-scoped + position, like #846's `bynk/sequenceModel` (the panel is
4//! per-handler, not project-wide like #851's `bynk/architectureModel`): the
5//! params carry a `textDocument` + `position` and the result is one
6//! handler's wire contract, or `null` when nothing resolves. Same on-demand
7//! posture as every custom request in this server — no `workspace/*/refresh`
8//! nudge exists for one and none is needed (see `sequence_request`'s module
9//! doc for why).
10//!
11//! Two responsibilities live here: [`WcModel`] and its hand-mirrored `Wc*`
12//! siblings — a plain serde shape for [`bynk_ide::wire_contract::WireContractModel`]
13//! and everything it contains ([`bynk_check::wire::WireModel`] included) —
14//! and [`to_wire`], which lowers one to the other, `Span` → `{uri, range}`
15//! the same way `architecture_request.rs` does (this module reuses its
16//! `WireLoc` under the `WcLoc` alias rather than redeclaring an identical
17//! struct).
18//!
19//! **Cross-file type locations.** A boundary type may be declared in a
20//! *different* file than the handler being hovered — the whole reason
21//! `ContextBoundaryInfo::types` (Phase 3) is a *combined* table (own
22//! declarations plus every `uses` target's). `WireContractModel::type_sites`
23//! (`bynk-ide/src/wire_contract.rs`) carries only a bare `Span` per type
24//! name, with no file — a single project-relative path cannot be assumed
25//! (that path is the *handler's* file, not necessarily the type's). This
26//! module resolves each boundary type's own file the same way every other
27//! cross-file lookup in this server does: through the already-assembled
28//! [`bynk_check::index::ProjectIndex`], searching the candidate units in
29//! `doc_scope`'s order (itself first, then `uses` targets, then `consumes`
30//! targets — #848's existing search order, reused rather than re-derived).
31//! A type whose def cannot be resolved this way renders with `loc: null`
32//! rather than a guessed location — the same "never send a bogus location"
33//! rule `architecture_request.rs`'s `wire_loc` follows.
34
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38
39use bynk_check::index::{ProjectIndex, SymbolKey, SymbolKind};
40use bynk_check::wire::{
41    self, BaseGuard, Expected, JsonKind, Provenance, Revalidation, UncheckedReason, WireBody,
42    WireField, WireInst, WireModel, WireRef, WireScalar, WireSum, WireType,
43};
44use bynk_ide::wire_contract::{
45    BoundaryKind, Envelope, HttpResponse, NoCrossContextReason, ResponseOrigin, WireContractModel,
46};
47use bynk_syntax::ast::{PredKind, TypeDecl};
48use tower_lsp::lsp_types::Range;
49
50pub use crate::architecture_request::WireLoc as WcLoc;
51
52/// The `bynk/wireContract` request payload — a text-document position, the
53/// same two-field shape `SequenceModelParams`/every cursor-anchored request
54/// in this server uses.
55///
56/// `rename_all = "camelCase"` is load-bearing: the client sends the LSP wire
57/// names `textDocument`/`position`, and #846 already shipped once as a
58/// missing-field bug from omitting this (see `sequence_request`'s and
59/// `architecture_request`'s own params docs).
60#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct WireContractParams {
63    pub text_document: tower_lsp::lsp_types::TextDocumentIdentifier,
64    pub position: tower_lsp::lsp_types::Position,
65}
66
67// ---------------------------------------------------------------------
68// Wire shape: a plain serde mirror of `bynk_ide::wire_contract::WireContractModel`
69// (and, transitively, `bynk_check::wire::WireModel`) — `Wc`-prefixed to
70// avoid the `WireWireContractModel`-style stutter a bare `Wire*` prefix
71// would produce given this module already imports the real `Wire*` types.
72// ---------------------------------------------------------------------
73
74#[derive(Debug, Clone, serde::Serialize)]
75pub struct WcModel {
76    pub unit: String,
77    pub service: String,
78    pub kind: WcBoundaryKind,
79    /// The handler's own header/signature span, against the request
80    /// document (the handler's own file — never cross-file, unlike a
81    /// boundary type's `loc`).
82    pub range: Range,
83    #[serde(rename = "handlerLine")]
84    pub handler_line: usize,
85    pub envelope: WcEnvelope,
86    /// `on call` only; `null` whenever `noCrossContext` is set.
87    pub contract: Option<WcContractForm>,
88    pub types: Vec<WcType>,
89    pub instantiations: Vec<WcInst>,
90    pub recursive: Vec<String>,
91    /// Empty for a non-HTTP handler.
92    pub responses: Vec<WcHttpResponse>,
93    #[serde(rename = "noCrossContext")]
94    pub no_cross_context: Option<&'static str>,
95}
96
97#[derive(Debug, Clone, serde::Serialize)]
98#[serde(tag = "kind")]
99pub enum WcBoundaryKind {
100    Http { method: &'static str, path: String },
101    Call,
102    Cron { expr: String },
103    Message,
104    Open,
105    Close,
106    Event,
107    Rejected,
108}
109
110#[derive(Debug, Clone, serde::Serialize)]
111#[serde(tag = "kind")]
112pub enum WcEnvelope {
113    Empty,
114    Bare {
115        param: String,
116        shape: WcRef,
117    },
118    Keyed {
119        params: Vec<WcParam>,
120    },
121    Raw {
122        param: String,
123        #[serde(rename = "contentType")]
124        content_type: String,
125    },
126}
127
128#[derive(Debug, Clone, serde::Serialize)]
129pub struct WcParam {
130    pub name: String,
131    pub shape: WcRef,
132}
133
134#[derive(Debug, Clone, serde::Serialize)]
135pub struct WcContractForm {
136    #[serde(rename = "normalForm")]
137    pub normal_form: String,
138    pub hash: String,
139}
140
141#[derive(Debug, Clone, serde::Serialize)]
142pub struct WcHttpResponse {
143    pub status: u16,
144    pub variant: String,
145    pub origin: WcResponseOrigin,
146}
147
148#[derive(Debug, Clone, serde::Serialize)]
149#[serde(tag = "kind")]
150pub enum WcResponseOrigin {
151    DeclaredSuccess,
152    Constructed { range: Range },
153    BoundaryImplicit { why: &'static str },
154}
155
156#[derive(Debug, Clone, serde::Serialize)]
157pub struct WcType {
158    pub name: String,
159    #[serde(rename = "codecSuffix")]
160    pub codec_suffix: String,
161    pub provenance: WcProvenance,
162    pub body: WcBody,
163    /// `null` when the declaring file could not be resolved through the
164    /// index (defensive only — see the module doc).
165    pub loc: Option<WcLoc>,
166}
167
168#[derive(Debug, Clone, serde::Serialize)]
169#[serde(tag = "kind")]
170pub enum WcProvenance {
171    Owned,
172    Consumed {
173        #[serde(rename = "ownerUnit")]
174        owner_unit: String,
175    },
176}
177
178#[derive(Debug, Clone, serde::Serialize)]
179#[serde(tag = "kind")]
180pub enum WcBody {
181    Scalar {
182        base: &'static str,
183        json: &'static str,
184        opaque: bool,
185        /// Declaration order — see `bynk_check::wire`'s module doc on why
186        /// this must never be sorted.
187        predicates: Vec<String>,
188        #[serde(rename = "baseGuards")]
189        base_guards: Vec<&'static str>,
190        revalidation: &'static str,
191    },
192    Record {
193        fields: Vec<WcField>,
194    },
195    Sum {
196        #[serde(rename = "wireDiscriminant")]
197        wire_discriminant: &'static str,
198        #[serde(rename = "memoryDiscriminant")]
199        memory_discriminant: &'static str,
200        variants: Vec<WcVariant>,
201    },
202}
203
204#[derive(Debug, Clone, serde::Serialize)]
205pub struct WcField {
206    pub name: String,
207    pub shape: WcRef,
208    #[serde(rename = "pathSegment")]
209    pub path_segment: String,
210    /// The default initialiser's source rendering (`bynk_fmt::expr_to_string`),
211    /// `null` when the field has none.
212    pub default: Option<String>,
213}
214
215#[derive(Debug, Clone, serde::Serialize)]
216pub struct WcVariant {
217    pub name: String,
218    pub payload: Vec<WcField>,
219}
220
221#[derive(Debug, Clone, serde::Serialize)]
222#[serde(tag = "kind")]
223pub enum WcRef {
224    Base {
225        base: &'static str,
226        json: &'static str,
227        guards: Vec<&'static str>,
228        expected: WcExpected,
229    },
230    Bytes,
231    Named {
232        name: String,
233    },
234    Inst {
235        key: String,
236    },
237    Unit,
238    Unchecked {
239        reason: &'static str,
240    },
241}
242
243#[derive(Debug, Clone, serde::Serialize)]
244#[serde(tag = "kind")]
245pub enum WcExpected {
246    Json { json: &'static str },
247    Integer,
248    FiniteNumber,
249    Base64String,
250    SumVariantKind,
251}
252
253#[derive(Debug, Clone, serde::Serialize)]
254#[serde(tag = "kind")]
255pub enum WcInst {
256    ResultInst {
257        key: String,
258        ok: WcRef,
259        err: WcRef,
260    },
261    OptionInst {
262        key: String,
263        inner: WcRef,
264    },
265    ListInst {
266        key: String,
267        elem: WcRef,
268    },
269    MapInst {
270        key: String,
271        #[serde(rename = "mapKey")]
272        map_key: WcRef,
273        val: WcRef,
274    },
275    RecordInst {
276        key: String,
277        name: String,
278        args: Vec<WcRef>,
279    },
280    SumInst {
281        key: String,
282        name: String,
283        args: Vec<WcRef>,
284    },
285}
286
287// -- scalar enum -> wire-string helpers (matches this codebase's convention
288// -- of `&'static str` fields over deriving `Serialize` on the upstream
289// -- enums directly — see `sequence_request.rs`'s `participant_kind_str` etc).
290
291fn json_kind_str(j: JsonKind) -> &'static str {
292    match j {
293        JsonKind::Number => "Number",
294        JsonKind::String => "String",
295        JsonKind::Boolean => "Boolean",
296        JsonKind::Object => "Object",
297        JsonKind::Array => "Array",
298        JsonKind::Null => "Null",
299    }
300}
301
302fn base_guard_str(g: BaseGuard) -> &'static str {
303    match g {
304        BaseGuard::Integral => "Integral",
305        BaseGuard::Finite => "Finite",
306    }
307}
308
309fn unchecked_reason_str(r: UncheckedReason) -> &'static str {
310    match r {
311        UncheckedReason::Effect => "Effect",
312        UncheckedReason::ValidationError => "ValidationError",
313        UncheckedReason::JsonError => "JsonError",
314        UncheckedReason::HttpResult => "HttpResult",
315        UncheckedReason::QueueResult => "QueueResult",
316        UncheckedReason::Asset => "Asset",
317    }
318}
319
320fn revalidation_str(r: Revalidation) -> &'static str {
321    match r {
322        Revalidation::ViaConstructor => "ViaConstructor",
323        Revalidation::Inline => "Inline",
324        Revalidation::StructuralOnly => "StructuralOnly",
325        Revalidation::Base64Decode => "Base64Decode",
326    }
327}
328
329fn no_cross_context_str(r: NoCrossContextReason) -> &'static str {
330    match r {
331        NoCrossContextReason::NotACallHandler => "NotACallHandler",
332        NoCrossContextReason::SingleContext => "SingleContext",
333    }
334}
335
336/// A `PredKind`'s source-like rendering, predicate args included
337/// (`MaxLength(20)`, not just `"MaxLength"`) — `PredKind::name()`
338/// (`bynk-syntax/src/ast.rs`) drops the payload, which the peek's whole
339/// point is to show. Mirrors `bynk_fmt::fmt`'s private `pred_to_string`
340/// (that one renders a `RefinementPred`, the AST wrapper; `WireScalar::
341/// predicates` is bare `Vec<PredKind>`, `bynk-check/src/wire.rs`'s own
342/// extraction), reusing its public `escape_string` rather than
343/// re-implementing string escaping.
344fn pred_kind_str(p: &PredKind) -> String {
345    match p {
346        PredKind::Matches(re) => format!("Matches(\"{}\")", bynk_fmt::escape_string(re)),
347        PredKind::InRange(a, b) => format!("InRange({}, {})", a.value, b.value),
348        PredKind::InRangeF(a, b) => format!("InRange({}, {})", a.lexeme, b.lexeme),
349        PredKind::MinLength(n) => format!("MinLength({n})"),
350        PredKind::MaxLength(n) => format!("MaxLength({n})"),
351        PredKind::Length(n) => format!("Length({n})"),
352        PredKind::NonNegative => "NonNegative".to_string(),
353        PredKind::Positive => "Positive".to_string(),
354        PredKind::NonEmpty => "NonEmpty".to_string(),
355    }
356}
357
358fn provenance_wire(p: &Provenance) -> WcProvenance {
359    match p {
360        Provenance::Owned => WcProvenance::Owned,
361        Provenance::Consumed { owner_unit } => WcProvenance::Consumed {
362            owner_unit: owner_unit.clone(),
363        },
364    }
365}
366
367fn expected_wire(e: &Expected) -> WcExpected {
368    match e {
369        Expected::Json(j) => WcExpected::Json {
370            json: json_kind_str(*j),
371        },
372        Expected::Integer => WcExpected::Integer,
373        Expected::FiniteNumber => WcExpected::FiniteNumber,
374        Expected::Base64String => WcExpected::Base64String,
375        Expected::SumVariantKind => WcExpected::SumVariantKind,
376    }
377}
378
379fn wc_ref(r: &WireRef) -> WcRef {
380    match r {
381        WireRef::Base {
382            base,
383            json,
384            guards,
385            expected,
386        } => WcRef::Base {
387            base: base.name(),
388            json: json_kind_str(*json),
389            guards: guards.iter().map(|g| base_guard_str(*g)).collect(),
390            expected: expected_wire(expected),
391        },
392        WireRef::Bytes => WcRef::Bytes,
393        WireRef::Named { name } => WcRef::Named {
394            name: bynk_check::symbols::display_type_name(name).to_string(),
395        },
396        WireRef::Inst { key } => WcRef::Inst { key: key.clone() },
397        WireRef::Unit => WcRef::Unit,
398        WireRef::Unchecked { reason } => WcRef::Unchecked {
399            reason: unchecked_reason_str(*reason),
400        },
401    }
402}
403
404fn wc_field(f: &WireField) -> WcField {
405    WcField {
406        name: f.name.clone(),
407        shape: wc_ref(&f.shape),
408        path_segment: f.path_segment.clone(),
409        default: f
410            .default
411            .as_ref()
412            .map(|(e, _ty)| bynk_fmt::expr_to_string(e)),
413    }
414}
415
416fn wc_body(b: &WireBody) -> WcBody {
417    match b {
418        WireBody::Scalar(WireScalar {
419            base,
420            json,
421            opaque,
422            predicates,
423            base_guards,
424            revalidation,
425        }) => WcBody::Scalar {
426            base: base.name(),
427            json: json_kind_str(*json),
428            opaque: *opaque,
429            predicates: predicates.iter().map(pred_kind_str).collect(),
430            base_guards: base_guards.iter().map(|g| base_guard_str(*g)).collect(),
431            revalidation: revalidation_str(*revalidation),
432        },
433        WireBody::Record { fields } => WcBody::Record {
434            fields: fields.iter().map(wc_field).collect(),
435        },
436        WireBody::Sum(WireSum {
437            wire_discriminant,
438            memory_discriminant,
439            variants,
440        }) => WcBody::Sum {
441            wire_discriminant,
442            memory_discriminant,
443            variants: variants
444                .iter()
445                .map(|v| WcVariant {
446                    name: v.name.clone(),
447                    payload: v.payload.iter().map(wc_field).collect(),
448                })
449                .collect(),
450        },
451    }
452}
453
454/// Resolve `name`'s own declaration site through the project index,
455/// searching `search_order` (self-first — #848's `doc_scope` order) so a
456/// type declared in a different file of the same unit, or in a `uses`
457/// target, resolves to *its* file rather than the handler's. `None` when no
458/// candidate unit's index entry resolves — the type renders with `loc:
459/// null` rather than a guessed location.
460fn resolve_type_loc(
461    project_root: &Path,
462    index: &ProjectIndex,
463    snapshots: &HashMap<PathBuf, String>,
464    search_order: &[String],
465    name: &str,
466) -> Option<WcLoc> {
467    for unit in search_order {
468        let key = SymbolKey {
469            unit: unit.clone(),
470            kind: SymbolKind::Type,
471            name: name.to_string(),
472        };
473        let Some(entry) = index.symbols.get(&key) else {
474            continue;
475        };
476        let Some(def) = &entry.def else { continue };
477        let Some(text) = snapshots.get(&def.path) else {
478            continue;
479        };
480        let Ok(uri) = tower_lsp::lsp_types::Url::from_file_path(project_root.join(&def.path))
481        else {
482            continue;
483        };
484        return Some(WcLoc {
485            uri,
486            range: crate::position::span_to_range(text, def.span),
487        });
488    }
489    None
490}
491
492fn wc_type(
493    t: &WireType,
494    project_root: &Path,
495    index: &ProjectIndex,
496    snapshots: &HashMap<PathBuf, String>,
497    search_order: &[String],
498) -> WcType {
499    // #2087: a qualified key reads, and resolves, as its declaration's name.
500    let name = bynk_check::symbols::display_type_name(&t.name);
501    WcType {
502        name: name.to_string(),
503        codec_suffix: t.codec_suffix.clone(),
504        provenance: provenance_wire(&t.provenance),
505        body: wc_body(&t.body),
506        loc: resolve_type_loc(project_root, index, snapshots, search_order, name),
507    }
508}
509
510/// Lower one boundary occurrence of a generic instantiation. `WireInst`
511/// (`bynk-check/src/wire.rs`) carries raw `TypeRef`s for its type
512/// arguments — the same "shape, not TS" IR the emitter's codec generation
513/// renders — so each argument is resolved through
514/// [`bynk_check::wire::wire_ref`] (the same single-level resolver
515/// `wire_contract_for_service` uses for envelope params) before lowering,
516/// which is why `types` (the owning unit's combined type table) threads
517/// through here.
518fn wc_inst(inst: &WireInst, types: &HashMap<String, Arc<TypeDecl>>) -> WcInst {
519    let key = inst.ts_name();
520    match inst {
521        WireInst::ResultInst { ok, err } => WcInst::ResultInst {
522            key,
523            ok: wc_ref(&wire::wire_ref(ok, types)),
524            err: wc_ref(&wire::wire_ref(err, types)),
525        },
526        WireInst::OptionInst { inner } => WcInst::OptionInst {
527            key,
528            inner: wc_ref(&wire::wire_ref(inner, types)),
529        },
530        WireInst::ListInst { elem } => WcInst::ListInst {
531            key,
532            elem: wc_ref(&wire::wire_ref(elem, types)),
533        },
534        WireInst::MapInst { key: k, val } => WcInst::MapInst {
535            key,
536            map_key: wc_ref(&wire::wire_ref(k, types)),
537            val: wc_ref(&wire::wire_ref(val, types)),
538        },
539        WireInst::RecordInst { name, args } => WcInst::RecordInst {
540            key,
541            name: name.clone(),
542            args: args
543                .iter()
544                .map(|a| wc_ref(&wire::wire_ref(a, types)))
545                .collect(),
546        },
547        WireInst::SumInst { name, args } => WcInst::SumInst {
548            key,
549            name: name.clone(),
550            args: args
551                .iter()
552                .map(|a| wc_ref(&wire::wire_ref(a, types)))
553                .collect(),
554        },
555    }
556}
557
558fn boundary_kind_wire(k: &BoundaryKind) -> WcBoundaryKind {
559    match k {
560        BoundaryKind::Http { method, path } => WcBoundaryKind::Http {
561            method: method.as_str(),
562            path: path.clone(),
563        },
564        BoundaryKind::Call => WcBoundaryKind::Call,
565        BoundaryKind::Cron { expr } => WcBoundaryKind::Cron { expr: expr.clone() },
566        BoundaryKind::Message => WcBoundaryKind::Message,
567        BoundaryKind::Open => WcBoundaryKind::Open,
568        BoundaryKind::Close => WcBoundaryKind::Close,
569        BoundaryKind::Event => WcBoundaryKind::Event,
570        BoundaryKind::Rejected => WcBoundaryKind::Rejected,
571    }
572}
573
574fn envelope_wire(e: &Envelope) -> WcEnvelope {
575    match e {
576        Envelope::Empty => WcEnvelope::Empty,
577        Envelope::Bare { param, shape } => WcEnvelope::Bare {
578            param: param.clone(),
579            shape: wc_ref(shape),
580        },
581        Envelope::Raw {
582            param,
583            content_type,
584        } => WcEnvelope::Raw {
585            param: param.clone(),
586            content_type: content_type.clone(),
587        },
588        Envelope::Keyed { params } => WcEnvelope::Keyed {
589            params: params
590                .iter()
591                .map(|(n, s)| WcParam {
592                    name: n.clone(),
593                    shape: wc_ref(s),
594                })
595                .collect(),
596        },
597    }
598}
599
600fn wc_response(r: &HttpResponse, text: &str) -> WcHttpResponse {
601    WcHttpResponse {
602        status: r.status,
603        variant: r.variant.clone(),
604        origin: match &r.origin {
605            ResponseOrigin::DeclaredSuccess => WcResponseOrigin::DeclaredSuccess,
606            ResponseOrigin::Constructed { span } => WcResponseOrigin::Constructed {
607                range: crate::position::span_to_range(text, *span),
608            },
609            ResponseOrigin::BoundaryImplicit { why } => WcResponseOrigin::BoundaryImplicit { why },
610        },
611    }
612}
613
614/// Lower a [`WireContractModel`] to its wire shape.
615///
616/// `text` is the handler's **own** file's committed snapshot (the request
617/// document) — `model.handler_span` and each HTTP response's `Constructed`
618/// span both index into it. `types` is the owning unit's combined type
619/// table (`ContextBoundaryInfo::types`), needed to resolve each generic
620/// instantiation's type-argument shapes (see `wc_inst`). `index` +
621/// `snapshots` + `search_order` resolve each boundary type's own declaring
622/// file (see the module doc's "Cross-file type locations" section) — a
623/// deliberate widening of the plan's sketched `to_wire(model, project_root,
624/// snapshots)` signature, which had no way to know *which* file a `uses`-
625/// imported boundary type's span belongs to.
626pub fn to_wire(
627    model: &WireContractModel,
628    project_root: &Path,
629    text: &str,
630    types: &HashMap<String, Arc<TypeDecl>>,
631    index: &ProjectIndex,
632    snapshots: &HashMap<PathBuf, String>,
633    search_order: &[String],
634) -> WcModel {
635    let WireModel {
636        types: wtypes,
637        instantiations,
638        recursive,
639    } = &model.boundary;
640    WcModel {
641        unit: model.unit.clone(),
642        service: model.service.clone(),
643        kind: boundary_kind_wire(&model.kind),
644        range: crate::position::span_to_range(text, model.handler_span),
645        handler_line: model.handler_line,
646        envelope: envelope_wire(&model.envelope),
647        contract: model.contract.as_ref().map(|c| WcContractForm {
648            // #2087: shown with each qualified key as its declaration's name;
649            // `hash` is still over the canonical form.
650            normal_form: bynk_check::symbols::display_type_names_in(&c.normal_form),
651            hash: c.hash.clone(),
652        }),
653        types: wtypes
654            .iter()
655            .map(|t| wc_type(t, project_root, index, snapshots, search_order))
656            .collect(),
657        instantiations: instantiations.iter().map(|i| wc_inst(i, types)).collect(),
658        recursive: recursive.iter().cloned().collect(),
659        responses: model
660            .responses
661            .iter()
662            .map(|r| wc_response(r, text))
663            .collect(),
664        no_cross_context: model.no_cross_context.map(no_cross_context_str),
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn params_deserialize_from_camel_case_wire_json() {
674        let json = serde_json::json!({
675            "textDocument": { "uri": "file:///a/b.bynk" },
676            "position": { "line": 3, "character": 7 },
677        });
678        let params: WireContractParams =
679            serde_json::from_value(json).expect("camelCase textDocument/position must deserialize");
680        assert_eq!(params.text_document.uri.as_str(), "file:///a/b.bynk");
681        assert_eq!(params.position.line, 3);
682        assert_eq!(params.position.character, 7);
683    }
684
685    #[test]
686    fn pred_kind_str_renders_predicate_arguments_not_just_the_bare_name() {
687        assert_eq!(pred_kind_str(&PredKind::NonEmpty), "NonEmpty");
688        assert_eq!(pred_kind_str(&PredKind::MaxLength(20)), "MaxLength(20)");
689    }
690}