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}
108
109#[derive(Debug, Clone, serde::Serialize)]
110#[serde(tag = "kind")]
111pub enum WcEnvelope {
112    Empty,
113    Bare { param: String, shape: WcRef },
114    Keyed { params: Vec<WcParam> },
115}
116
117#[derive(Debug, Clone, serde::Serialize)]
118pub struct WcParam {
119    pub name: String,
120    pub shape: WcRef,
121}
122
123#[derive(Debug, Clone, serde::Serialize)]
124pub struct WcContractForm {
125    #[serde(rename = "normalForm")]
126    pub normal_form: String,
127    pub hash: String,
128}
129
130#[derive(Debug, Clone, serde::Serialize)]
131pub struct WcHttpResponse {
132    pub status: u16,
133    pub variant: String,
134    pub origin: WcResponseOrigin,
135}
136
137#[derive(Debug, Clone, serde::Serialize)]
138#[serde(tag = "kind")]
139pub enum WcResponseOrigin {
140    DeclaredSuccess,
141    Constructed { range: Range },
142    BoundaryImplicit { why: &'static str },
143}
144
145#[derive(Debug, Clone, serde::Serialize)]
146pub struct WcType {
147    pub name: String,
148    #[serde(rename = "codecSuffix")]
149    pub codec_suffix: String,
150    pub provenance: WcProvenance,
151    pub body: WcBody,
152    /// `null` when the declaring file could not be resolved through the
153    /// index (defensive only — see the module doc).
154    pub loc: Option<WcLoc>,
155}
156
157#[derive(Debug, Clone, serde::Serialize)]
158#[serde(tag = "kind")]
159pub enum WcProvenance {
160    Owned,
161    Consumed {
162        #[serde(rename = "ownerUnit")]
163        owner_unit: String,
164    },
165}
166
167#[derive(Debug, Clone, serde::Serialize)]
168#[serde(tag = "kind")]
169pub enum WcBody {
170    Scalar {
171        base: &'static str,
172        json: &'static str,
173        opaque: bool,
174        /// Declaration order — see `bynk_check::wire`'s module doc on why
175        /// this must never be sorted.
176        predicates: Vec<String>,
177        #[serde(rename = "baseGuards")]
178        base_guards: Vec<&'static str>,
179        revalidation: &'static str,
180    },
181    Record {
182        fields: Vec<WcField>,
183    },
184    Sum {
185        #[serde(rename = "wireDiscriminant")]
186        wire_discriminant: &'static str,
187        #[serde(rename = "memoryDiscriminant")]
188        memory_discriminant: &'static str,
189        variants: Vec<WcVariant>,
190    },
191}
192
193#[derive(Debug, Clone, serde::Serialize)]
194pub struct WcField {
195    pub name: String,
196    pub shape: WcRef,
197    #[serde(rename = "pathSegment")]
198    pub path_segment: String,
199    /// The default initialiser's source rendering (`bynk_fmt::expr_to_string`),
200    /// `null` when the field has none.
201    pub default: Option<String>,
202}
203
204#[derive(Debug, Clone, serde::Serialize)]
205pub struct WcVariant {
206    pub name: String,
207    pub payload: Vec<WcField>,
208}
209
210#[derive(Debug, Clone, serde::Serialize)]
211#[serde(tag = "kind")]
212pub enum WcRef {
213    Base {
214        base: &'static str,
215        json: &'static str,
216        guards: Vec<&'static str>,
217        expected: WcExpected,
218    },
219    Bytes,
220    Named {
221        name: String,
222    },
223    Inst {
224        key: String,
225    },
226    Unit,
227    Unchecked {
228        reason: &'static str,
229    },
230}
231
232#[derive(Debug, Clone, serde::Serialize)]
233#[serde(tag = "kind")]
234pub enum WcExpected {
235    Json { json: &'static str },
236    Integer,
237    FiniteNumber,
238    Base64String,
239    SumVariantKind,
240}
241
242#[derive(Debug, Clone, serde::Serialize)]
243#[serde(tag = "kind")]
244pub enum WcInst {
245    ResultInst {
246        key: String,
247        ok: WcRef,
248        err: WcRef,
249    },
250    OptionInst {
251        key: String,
252        inner: WcRef,
253    },
254    ListInst {
255        key: String,
256        elem: WcRef,
257    },
258    MapInst {
259        key: String,
260        #[serde(rename = "mapKey")]
261        map_key: WcRef,
262        val: WcRef,
263    },
264    RecordInst {
265        key: String,
266        name: String,
267        args: Vec<WcRef>,
268    },
269    SumInst {
270        key: String,
271        name: String,
272        args: Vec<WcRef>,
273    },
274}
275
276// -- scalar enum -> wire-string helpers (matches this codebase's convention
277// -- of `&'static str` fields over deriving `Serialize` on the upstream
278// -- enums directly — see `sequence_request.rs`'s `participant_kind_str` etc).
279
280fn json_kind_str(j: JsonKind) -> &'static str {
281    match j {
282        JsonKind::Number => "Number",
283        JsonKind::String => "String",
284        JsonKind::Boolean => "Boolean",
285        JsonKind::Object => "Object",
286        JsonKind::Array => "Array",
287        JsonKind::Null => "Null",
288    }
289}
290
291fn base_guard_str(g: BaseGuard) -> &'static str {
292    match g {
293        BaseGuard::Integral => "Integral",
294        BaseGuard::Finite => "Finite",
295    }
296}
297
298fn unchecked_reason_str(r: UncheckedReason) -> &'static str {
299    match r {
300        UncheckedReason::Effect => "Effect",
301        UncheckedReason::ValidationError => "ValidationError",
302        UncheckedReason::JsonError => "JsonError",
303        UncheckedReason::HttpResult => "HttpResult",
304        UncheckedReason::QueueResult => "QueueResult",
305    }
306}
307
308fn revalidation_str(r: Revalidation) -> &'static str {
309    match r {
310        Revalidation::ViaConstructor => "ViaConstructor",
311        Revalidation::Inline => "Inline",
312        Revalidation::StructuralOnly => "StructuralOnly",
313        Revalidation::Base64Decode => "Base64Decode",
314    }
315}
316
317fn no_cross_context_str(r: NoCrossContextReason) -> &'static str {
318    match r {
319        NoCrossContextReason::NotACallHandler => "NotACallHandler",
320        NoCrossContextReason::SingleContext => "SingleContext",
321    }
322}
323
324/// A `PredKind`'s source-like rendering, predicate args included
325/// (`MaxLength(20)`, not just `"MaxLength"`) — `PredKind::name()`
326/// (`bynk-syntax/src/ast.rs`) drops the payload, which the peek's whole
327/// point is to show. Mirrors `bynk_fmt::fmt`'s private `pred_to_string`
328/// (that one renders a `RefinementPred`, the AST wrapper; `WireScalar::
329/// predicates` is bare `Vec<PredKind>`, `bynk-check/src/wire.rs`'s own
330/// extraction), reusing its public `escape_string` rather than
331/// re-implementing string escaping.
332fn pred_kind_str(p: &PredKind) -> String {
333    match p {
334        PredKind::Matches(re) => format!("Matches(\"{}\")", bynk_fmt::escape_string(re)),
335        PredKind::InRange(a, b) => format!("InRange({}, {})", a.value, b.value),
336        PredKind::InRangeF(a, b) => format!("InRange({}, {})", a.lexeme, b.lexeme),
337        PredKind::MinLength(n) => format!("MinLength({n})"),
338        PredKind::MaxLength(n) => format!("MaxLength({n})"),
339        PredKind::Length(n) => format!("Length({n})"),
340        PredKind::NonNegative => "NonNegative".to_string(),
341        PredKind::Positive => "Positive".to_string(),
342        PredKind::NonEmpty => "NonEmpty".to_string(),
343    }
344}
345
346fn provenance_wire(p: &Provenance) -> WcProvenance {
347    match p {
348        Provenance::Owned => WcProvenance::Owned,
349        Provenance::Consumed { owner_unit } => WcProvenance::Consumed {
350            owner_unit: owner_unit.clone(),
351        },
352    }
353}
354
355fn expected_wire(e: &Expected) -> WcExpected {
356    match e {
357        Expected::Json(j) => WcExpected::Json {
358            json: json_kind_str(*j),
359        },
360        Expected::Integer => WcExpected::Integer,
361        Expected::FiniteNumber => WcExpected::FiniteNumber,
362        Expected::Base64String => WcExpected::Base64String,
363        Expected::SumVariantKind => WcExpected::SumVariantKind,
364    }
365}
366
367fn wc_ref(r: &WireRef) -> WcRef {
368    match r {
369        WireRef::Base {
370            base,
371            json,
372            guards,
373            expected,
374        } => WcRef::Base {
375            base: base.name(),
376            json: json_kind_str(*json),
377            guards: guards.iter().map(|g| base_guard_str(*g)).collect(),
378            expected: expected_wire(expected),
379        },
380        WireRef::Bytes => WcRef::Bytes,
381        WireRef::Named { name } => WcRef::Named { name: name.clone() },
382        WireRef::Inst { key } => WcRef::Inst { key: key.clone() },
383        WireRef::Unit => WcRef::Unit,
384        WireRef::Unchecked { reason } => WcRef::Unchecked {
385            reason: unchecked_reason_str(*reason),
386        },
387    }
388}
389
390fn wc_field(f: &WireField) -> WcField {
391    WcField {
392        name: f.name.clone(),
393        shape: wc_ref(&f.shape),
394        path_segment: f.path_segment.clone(),
395        default: f
396            .default
397            .as_ref()
398            .map(|(e, _ty)| bynk_fmt::expr_to_string(e)),
399    }
400}
401
402fn wc_body(b: &WireBody) -> WcBody {
403    match b {
404        WireBody::Scalar(WireScalar {
405            base,
406            json,
407            opaque,
408            predicates,
409            base_guards,
410            revalidation,
411        }) => WcBody::Scalar {
412            base: base.name(),
413            json: json_kind_str(*json),
414            opaque: *opaque,
415            predicates: predicates.iter().map(pred_kind_str).collect(),
416            base_guards: base_guards.iter().map(|g| base_guard_str(*g)).collect(),
417            revalidation: revalidation_str(*revalidation),
418        },
419        WireBody::Record { fields } => WcBody::Record {
420            fields: fields.iter().map(wc_field).collect(),
421        },
422        WireBody::Sum(WireSum {
423            wire_discriminant,
424            memory_discriminant,
425            variants,
426        }) => WcBody::Sum {
427            wire_discriminant,
428            memory_discriminant,
429            variants: variants
430                .iter()
431                .map(|v| WcVariant {
432                    name: v.name.clone(),
433                    payload: v.payload.iter().map(wc_field).collect(),
434                })
435                .collect(),
436        },
437    }
438}
439
440/// Resolve `name`'s own declaration site through the project index,
441/// searching `search_order` (self-first — #848's `doc_scope` order) so a
442/// type declared in a different file of the same unit, or in a `uses`
443/// target, resolves to *its* file rather than the handler's. `None` when no
444/// candidate unit's index entry resolves — the type renders with `loc:
445/// null` rather than a guessed location.
446fn resolve_type_loc(
447    project_root: &Path,
448    index: &ProjectIndex,
449    snapshots: &HashMap<PathBuf, String>,
450    search_order: &[String],
451    name: &str,
452) -> Option<WcLoc> {
453    for unit in search_order {
454        let key = SymbolKey {
455            unit: unit.clone(),
456            kind: SymbolKind::Type,
457            name: name.to_string(),
458        };
459        let Some(entry) = index.symbols.get(&key) else {
460            continue;
461        };
462        let Some(def) = &entry.def else { continue };
463        let Some(text) = snapshots.get(&def.path) else {
464            continue;
465        };
466        let Ok(uri) = tower_lsp::lsp_types::Url::from_file_path(project_root.join(&def.path))
467        else {
468            continue;
469        };
470        return Some(WcLoc {
471            uri,
472            range: crate::position::span_to_range(text, def.span),
473        });
474    }
475    None
476}
477
478fn wc_type(
479    t: &WireType,
480    project_root: &Path,
481    index: &ProjectIndex,
482    snapshots: &HashMap<PathBuf, String>,
483    search_order: &[String],
484) -> WcType {
485    WcType {
486        name: t.name.clone(),
487        codec_suffix: t.codec_suffix.clone(),
488        provenance: provenance_wire(&t.provenance),
489        body: wc_body(&t.body),
490        loc: resolve_type_loc(project_root, index, snapshots, search_order, &t.name),
491    }
492}
493
494/// Lower one boundary occurrence of a generic instantiation. `WireInst`
495/// (`bynk-check/src/wire.rs`) carries raw `TypeRef`s for its type
496/// arguments — the same "shape, not TS" IR the emitter's codec generation
497/// renders — so each argument is resolved through
498/// [`bynk_check::wire::wire_ref`] (the same single-level resolver
499/// `wire_contract_for_service` uses for envelope params) before lowering,
500/// which is why `types` (the owning unit's combined type table) threads
501/// through here.
502fn wc_inst(inst: &WireInst, types: &HashMap<String, Arc<TypeDecl>>) -> WcInst {
503    let key = inst.ts_name();
504    match inst {
505        WireInst::ResultInst { ok, err } => WcInst::ResultInst {
506            key,
507            ok: wc_ref(&wire::wire_ref(ok, types)),
508            err: wc_ref(&wire::wire_ref(err, types)),
509        },
510        WireInst::OptionInst { inner } => WcInst::OptionInst {
511            key,
512            inner: wc_ref(&wire::wire_ref(inner, types)),
513        },
514        WireInst::ListInst { elem } => WcInst::ListInst {
515            key,
516            elem: wc_ref(&wire::wire_ref(elem, types)),
517        },
518        WireInst::MapInst { key: k, val } => WcInst::MapInst {
519            key,
520            map_key: wc_ref(&wire::wire_ref(k, types)),
521            val: wc_ref(&wire::wire_ref(val, types)),
522        },
523        WireInst::RecordInst { name, args } => WcInst::RecordInst {
524            key,
525            name: name.clone(),
526            args: args
527                .iter()
528                .map(|a| wc_ref(&wire::wire_ref(a, types)))
529                .collect(),
530        },
531        WireInst::SumInst { name, args } => WcInst::SumInst {
532            key,
533            name: name.clone(),
534            args: args
535                .iter()
536                .map(|a| wc_ref(&wire::wire_ref(a, types)))
537                .collect(),
538        },
539    }
540}
541
542fn boundary_kind_wire(k: &BoundaryKind) -> WcBoundaryKind {
543    match k {
544        BoundaryKind::Http { method, path } => WcBoundaryKind::Http {
545            method: method.as_str(),
546            path: path.clone(),
547        },
548        BoundaryKind::Call => WcBoundaryKind::Call,
549        BoundaryKind::Cron { expr } => WcBoundaryKind::Cron { expr: expr.clone() },
550        BoundaryKind::Message => WcBoundaryKind::Message,
551        BoundaryKind::Open => WcBoundaryKind::Open,
552        BoundaryKind::Close => WcBoundaryKind::Close,
553        BoundaryKind::Event => WcBoundaryKind::Event,
554    }
555}
556
557fn envelope_wire(e: &Envelope) -> WcEnvelope {
558    match e {
559        Envelope::Empty => WcEnvelope::Empty,
560        Envelope::Bare { param, shape } => WcEnvelope::Bare {
561            param: param.clone(),
562            shape: wc_ref(shape),
563        },
564        Envelope::Keyed { params } => WcEnvelope::Keyed {
565            params: params
566                .iter()
567                .map(|(n, s)| WcParam {
568                    name: n.clone(),
569                    shape: wc_ref(s),
570                })
571                .collect(),
572        },
573    }
574}
575
576fn wc_response(r: &HttpResponse, text: &str) -> WcHttpResponse {
577    WcHttpResponse {
578        status: r.status,
579        variant: r.variant.clone(),
580        origin: match &r.origin {
581            ResponseOrigin::DeclaredSuccess => WcResponseOrigin::DeclaredSuccess,
582            ResponseOrigin::Constructed { span } => WcResponseOrigin::Constructed {
583                range: crate::position::span_to_range(text, *span),
584            },
585            ResponseOrigin::BoundaryImplicit { why } => WcResponseOrigin::BoundaryImplicit { why },
586        },
587    }
588}
589
590/// Lower a [`WireContractModel`] to its wire shape.
591///
592/// `text` is the handler's **own** file's committed snapshot (the request
593/// document) — `model.handler_span` and each HTTP response's `Constructed`
594/// span both index into it. `types` is the owning unit's combined type
595/// table (`ContextBoundaryInfo::types`), needed to resolve each generic
596/// instantiation's type-argument shapes (see `wc_inst`). `index` +
597/// `snapshots` + `search_order` resolve each boundary type's own declaring
598/// file (see the module doc's "Cross-file type locations" section) — a
599/// deliberate widening of the plan's sketched `to_wire(model, project_root,
600/// snapshots)` signature, which had no way to know *which* file a `uses`-
601/// imported boundary type's span belongs to.
602pub fn to_wire(
603    model: &WireContractModel,
604    project_root: &Path,
605    text: &str,
606    types: &HashMap<String, Arc<TypeDecl>>,
607    index: &ProjectIndex,
608    snapshots: &HashMap<PathBuf, String>,
609    search_order: &[String],
610) -> WcModel {
611    let WireModel {
612        types: wtypes,
613        instantiations,
614        recursive,
615    } = &model.boundary;
616    WcModel {
617        unit: model.unit.clone(),
618        service: model.service.clone(),
619        kind: boundary_kind_wire(&model.kind),
620        range: crate::position::span_to_range(text, model.handler_span),
621        handler_line: model.handler_line,
622        envelope: envelope_wire(&model.envelope),
623        contract: model.contract.as_ref().map(|c| WcContractForm {
624            normal_form: c.normal_form.clone(),
625            hash: c.hash.clone(),
626        }),
627        types: wtypes
628            .iter()
629            .map(|t| wc_type(t, project_root, index, snapshots, search_order))
630            .collect(),
631        instantiations: instantiations.iter().map(|i| wc_inst(i, types)).collect(),
632        recursive: recursive.iter().cloned().collect(),
633        responses: model
634            .responses
635            .iter()
636            .map(|r| wc_response(r, text))
637            .collect(),
638        no_cross_context: model.no_cross_context.map(no_cross_context_str),
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    #[test]
647    fn params_deserialize_from_camel_case_wire_json() {
648        let json = serde_json::json!({
649            "textDocument": { "uri": "file:///a/b.bynk" },
650            "position": { "line": 3, "character": 7 },
651        });
652        let params: WireContractParams =
653            serde_json::from_value(json).expect("camelCase textDocument/position must deserialize");
654        assert_eq!(params.text_document.uri.as_str(), "file:///a/b.bynk");
655        assert_eq!(params.position.line, 3);
656        assert_eq!(params.position.character, 7);
657    }
658
659    #[test]
660    fn pred_kind_str_renders_predicate_arguments_not_just_the_bare_name() {
661        assert_eq!(pred_kind_str(&PredKind::NonEmpty), "NonEmpty");
662        assert_eq!(pred_kind_str(&PredKind::MaxLength(20)), "MaxLength(20)");
663    }
664}