Skip to main content

bynk_ide/
documentation.rs

1//! #847: the documentation-model query — a file's declarations aggregated into
2//! a rendered reference page ("live rustdoc for Bynk"), for the VS Code
3//! "Show Documentation" webview.
4//!
5//! A pure, read-only IDE query. It does two things and reuses everything else:
6//!
7//! - **Traversal.** It walks the parsed unit's `items` with an *exhaustive*
8//!   match on [`CommonsItem`] — the same shape `bynk-lsp`'s `document_symbols`
9//!   walk uses — so a new declaration kind is a compile error here, in
10//!   `push_item`, not a silently-missed row on the page. (Risk: "a new
11//!   doc-bearing node kind is added and silently missed by the aggregator.")
12//!
13//! - **Per-declaration rendering.** Each entry's Markdown (its fenced signature
14//!   plus its doc-comment prose) is produced by hover's own `describe_*`
15//!   assembly in [`crate::symbols`] — not a parallel renderer. Sharing that code
16//!   path is what keeps the doc page from drifting from hover. (Risk:
17//!   "divergence from hover — two code paths formatting the same doc
18//!   differently.")
19//!
20//! What this module adds on top is the page *structure*: the declaration's
21//! heading name, its nesting depth (top-level item → its ops/handlers), a
22//! `documented` flag driving the "no documentation" coverage placeholder, and
23//! the name span each heading links back to (click-to-code). The Markdown is
24//! rendered — HTML-disabled — by the webview; nothing here emits HTML.
25//!
26//! Tier 1 (Decision A) was **file-scoped**: [`documentation_model`] builds the
27//! model from one file's text, exactly like `document_symbols`. #1885 adds
28//! [`documentation_model_merged`], which merges every file of a multi-file unit
29//! into one page. That is unambiguous now that a unit carries at most one
30//! module doc (`bynk.project.duplicate_module_doc`): the page's lede is that
31//! doc, from whichever file has it, and its entries are every file's
32//! declarations, each tagged with the file it came from.
33
34use std::path::{Path, PathBuf};
35
36use bynk_syntax::ast::*;
37use bynk_syntax::lexer::tokenize;
38use bynk_syntax::parser::parse_unit_with_recovery;
39use bynk_syntax::span::Span;
40
41use crate::symbols;
42
43/// A file's declarations rendered as an ordered, hierarchical reference page.
44#[derive(Debug, Clone, PartialEq)]
45pub struct DocModel {
46    /// The declared unit name (the page title) — `demo.app` for a
47    /// `context demo.app`, `tokens` for an `adapter tokens`.
48    pub unit_name: String,
49    /// `"commons"` / `"context"` / `"adapter"` — the unit's own keyword, shown
50    /// as the page's kind.
51    pub unit_kind: &'static str,
52    /// The unit declaration's own doc comment, if any (rendered above the
53    /// entries as the page's lede).
54    pub unit_doc: Option<String>,
55    /// The unit-name span — the page title links back to the header.
56    pub unit_span: Span,
57    /// Declarations in source order, each carrying its nesting `depth`.
58    pub entries: Vec<DocEntry>,
59}
60
61/// One declaration on the page: a heading, its rendered signature+doc Markdown,
62/// and where it lives in the source.
63#[derive(Debug, Clone, PartialEq)]
64pub struct DocEntry {
65    /// The heading text — a bare name (`Api`), a compound member key
66    /// (`Counter.bump`, `Clock.now`), or a provider's `Cap = Provider`.
67    pub name: String,
68    /// A short kind label for the heading badge (`"service"`, `"handler"`, …).
69    pub kind: &'static str,
70    /// Nesting level: top-level declarations are `0`; a capability's ops and a
71    /// service/agent's handlers are `1`.
72    pub depth: u32,
73    /// The declaration's Markdown — a fenced `bynk` signature, followed by its
74    /// doc-comment prose when documented. Produced by hover's `describe_*`
75    /// (see the module doc).
76    pub markdown: String,
77    /// Whether this declaration carries a doc comment. Drives the webview's
78    /// "no documentation" placeholder (Decision B: the page doubles as a
79    /// doc-coverage view, with a toggle to hide the undocumented).
80    pub documented: bool,
81    /// The declaration's name span — the heading and signature link here.
82    pub span: Span,
83    /// #1885: the file `span` is in, for a page merged from several files
84    /// ([`documentation_model_merged`]); `None` on a single-file page, whose
85    /// spans are all in the one file it was built from.
86    pub file: Option<PathBuf>,
87}
88
89/// A parsed unit's page-relevant parts: its kind keyword, name, name span,
90/// module doc and items.
91type PageParts<'u> = (
92    &'static str,
93    String,
94    Span,
95    &'u Option<Doc>,
96    &'u [CommonsItem],
97);
98
99/// The [`PageParts`] of `unit`; `None` for a `suite`.
100fn page_parts(unit: &SourceUnit) -> Option<PageParts<'_>> {
101    match unit {
102        SourceUnit::Commons(c) => Some((
103            "commons",
104            c.name.joined(),
105            c.name.span,
106            &c.documentation,
107            &c.items,
108        )),
109        SourceUnit::Context(c) => Some((
110            "context",
111            c.name.joined(),
112            c.name.span,
113            &c.documentation,
114            &c.items,
115        )),
116        SourceUnit::Adapter(a) => Some((
117            "adapter",
118            a.name.joined(),
119            a.name.span,
120            &a.documentation,
121            &a.items,
122        )),
123        SourceUnit::Suite(_) => None,
124    }
125}
126
127fn parse(text: &str) -> Option<SourceUnit> {
128    let tokens = tokenize(text).ok()?;
129    parse_unit_with_recovery(&tokens, text).0
130}
131
132/// Build the documentation model for a single file's `text`. Returns `None`
133/// when the file has no recognisable unit header, or is a test suite (`suite`
134/// units are not a documentation unit in Tier 1 — their `case`/`stub` members
135/// have no `describe_*` renderer, and a doc page for tests is out of scope).
136///
137/// Since #1885 the language server serves [`documentation_model_merged`]
138/// instead, so this has no production caller. It stays as the file-scoped
139/// core that needs no project: this module's unit tests pin each declaration
140/// kind's entry through it, and a host with a lone buffer (no project to merge
141/// across) can still build a page from one text.
142pub fn documentation_model(text: &str) -> Option<DocModel> {
143    let unit = parse(text)?;
144    let (unit_kind, unit_name, unit_span, unit_doc, items) = page_parts(&unit)?;
145    let mut entries = Vec::new();
146    for item in items {
147        push_item(&mut entries, item);
148    }
149    Some(DocModel {
150        unit_name,
151        unit_kind,
152        unit_doc: unit_doc.as_deref().map(str::to_owned),
153        unit_span,
154        entries,
155    })
156}
157
158/// #1885: the documentation model for the unit that `primary` declares,
159/// merged across every file in `files` that declares the same unit.
160///
161/// `files` is `(path, text)` for the candidate files — typically the unit's
162/// files from the project's unit→sources map; any that declare another unit,
163/// a different kind, or a `suite` are skipped, and `primary` need not be among
164/// them (it is always included). The page title and `unit_span` come from
165/// `primary`, the file the page was asked for. The lede is the unit's module
166/// doc from whichever file carries it (at most one does; should a broken
167/// project have more, the first in path order wins). Entries are every file's
168/// declarations, files in path order and each file's in source order, each
169/// tagged with its [`DocEntry::file`]. The result is the same page whichever
170/// of the unit's files is `primary`, apart from the title's span.
171pub fn documentation_model_merged<'a>(
172    primary: (&'a Path, &'a str),
173    files: impl IntoIterator<Item = (&'a Path, &'a str)>,
174) -> Option<DocModel> {
175    let unit = parse(primary.1)?;
176    let (unit_kind, unit_name, unit_span, _, _) = page_parts(&unit)?;
177    let mut all: Vec<(&Path, &str)> = files.into_iter().collect();
178    if !all.iter().any(|(p, _)| *p == primary.0) {
179        all.push(primary);
180    }
181    all.sort_by(|a, b| a.0.cmp(b.0));
182    all.dedup_by(|a, b| a.0 == b.0);
183    let mut unit_doc: Option<String> = None;
184    let mut entries = Vec::new();
185    for (path, text) in all {
186        let Some(unit) = parse(text) else {
187            continue;
188        };
189        let Some((kind, name, _, _, items)) = page_parts(&unit) else {
190            continue;
191        };
192        if kind != unit_kind || name != unit_name {
193            continue;
194        }
195        if unit_doc.is_none() {
196            unit_doc = unit.module_doc().map(str::to_string);
197        }
198        let start = entries.len();
199        for item in items {
200            push_item(&mut entries, item);
201        }
202        for e in &mut entries[start..] {
203            e.file = Some(path.to_path_buf());
204        }
205    }
206    Some(DocModel {
207        unit_name,
208        unit_kind,
209        unit_doc,
210        unit_span,
211        entries,
212    })
213}
214
215/// Append `item`'s entry — and any nested member entries (capability ops,
216/// service/agent handlers) — to `out`, in source order.
217///
218/// The `match` is exhaustive over [`CommonsItem`] on purpose: a new item kind
219/// will not compile until it is given a page entry, which is the guard against
220/// the "silently-missed declaration kind" risk. Nested members reuse the same
221/// discipline against their own child lists.
222fn push_item(out: &mut Vec<DocEntry>, item: &CommonsItem) {
223    match item {
224        CommonsItem::Type(t) => {
225            out.push(DocEntry {
226                name: t.name.name.clone(),
227                kind: "type",
228                depth: 0,
229                markdown: symbols::describe_type(t),
230                documented: t.documentation.is_some(),
231                span: t.name.span,
232                file: None,
233            });
234            push_member_docs(out, t);
235        }
236        CommonsItem::Fn(f) => out.push(DocEntry {
237            name: f.name.display(),
238            kind: match f.name {
239                FnName::Free(_) => "function",
240                FnName::Method { .. } => "method",
241            },
242            depth: 0,
243            markdown: symbols::describe_fn(f),
244            documented: f.documentation.is_some(),
245            span: f.name.ident().span,
246            file: None,
247        }),
248        CommonsItem::Capability(c) => {
249            out.push(DocEntry {
250                name: c.name.name.clone(),
251                kind: "capability",
252                depth: 0,
253                markdown: symbols::describe_capability(c),
254                documented: c.documentation.is_some(),
255                span: c.name.span,
256                file: None,
257            });
258            for op in &c.ops {
259                out.push(DocEntry {
260                    name: format!("{}.{}", c.name.name, op.name.name),
261                    kind: "operation",
262                    depth: 1,
263                    markdown: symbols::describe_capability_op(c, op),
264                    documented: op.documentation.is_some(),
265                    span: op.name.span,
266                    file: None,
267                });
268            }
269        }
270        CommonsItem::Provider(p) => out.push(DocEntry {
271            name: format!("{} = {}", p.capability.name, p.provider_name.name),
272            kind: "provider",
273            depth: 0,
274            markdown: symbols::describe_provider(p),
275            documented: p.documentation.is_some(),
276            span: p.provider_name.span,
277            file: None,
278        }),
279        CommonsItem::Service(s) => {
280            out.push(DocEntry {
281                name: s.name.name.clone(),
282                kind: "service",
283                depth: 0,
284                markdown: symbols::describe_service(s),
285                documented: s.documentation.is_some(),
286                span: s.name.span,
287                file: None,
288            });
289            for h in &s.handlers {
290                out.push(DocEntry {
291                    // A service handler is identified by its route, not a
292                    // dispatch name (`on GET("/x")`), so the heading is the
293                    // route line itself.
294                    name: symbols::handler_line(h),
295                    kind: "handler",
296                    depth: 1,
297                    markdown: symbols::describe_service_handler(s, h),
298                    documented: h.documentation.is_some(),
299                    span: h.span,
300                    file: None,
301                });
302            }
303        }
304        CommonsItem::Agent(a) => {
305            out.push(DocEntry {
306                name: a.name.name.clone(),
307                kind: "agent",
308                depth: 0,
309                markdown: symbols::describe_agent(a),
310                documented: a.documentation.is_some(),
311                span: a.name.span,
312                file: None,
313            });
314            for h in &a.handlers {
315                let handler = h
316                    .method_name
317                    .as_ref()
318                    .map(|m| m.name.clone())
319                    .unwrap_or_else(|| "call".to_string());
320                out.push(DocEntry {
321                    name: format!("{}.{}", a.name.name, handler),
322                    kind: "handler",
323                    depth: 1,
324                    markdown: symbols::describe_agent_handler(a, h, &handler),
325                    documented: h.documentation.is_some(),
326                    span: h.method_name.as_ref().map(|m| m.span).unwrap_or(h.span),
327                    file: None,
328                });
329            }
330        }
331        CommonsItem::Actor(a) => out.push(DocEntry {
332            name: a.name.name.clone(),
333            kind: "actor",
334            depth: 0,
335            markdown: symbols::describe_actor(a),
336            documented: a.documentation.is_some(),
337            span: a.name.span,
338            file: None,
339        }),
340        // message-bundles slice 1 (#859): a messages block, keyed by its tag.
341        CommonsItem::Messages(m) => {
342            out.push(DocEntry {
343                name: m.tag.clone(),
344                kind: "messages",
345                depth: 0,
346                markdown: symbols::describe_messages(m),
347                documented: m.documentation.is_some(),
348                span: m.tag_span,
349                file: None,
350            });
351            // #1888: a documented entry, under its bundle.
352            for e in &m.entries {
353                if let Some(doc) = &e.documentation {
354                    out.push(DocEntry {
355                        name: format!("{}.{}", m.tag, e.code),
356                        kind: "message",
357                        depth: 1,
358                        markdown: format!(
359                            "```bynk\n\"{}\" => …\n```\n\n{}",
360                            e.code,
361                            doc.trim_end()
362                        ),
363                        documented: true,
364                        span: e.code_span,
365                        file: None,
366                    });
367                }
368            }
369        }
370        // Events track, slice 0 (spine #936): an `event` documents exactly
371        // like a `type` whose body is a record — same synthetic `TypeDecl`
372        // `EventDecl::as_type_decl` builds elsewhere.
373        CommonsItem::Event(e) => {
374            out.push(DocEntry {
375                name: e.name.name.clone(),
376                kind: "event",
377                depth: 0,
378                markdown: symbols::describe_type(&e.as_type_decl()),
379                documented: e.documentation.is_some(),
380                span: e.name.span,
381                file: None,
382            });
383            push_member_docs(out, &e.as_type_decl());
384        }
385        // #1883, slice 1a.
386        CommonsItem::Asset(a) => out.push(DocEntry {
387            name: a.name.name.clone(),
388            kind: "asset",
389            depth: 0,
390            markdown: symbols::describe_asset(a),
391            documented: a.documentation.is_some(),
392            span: a.name.span,
393            file: None,
394        }),
395        // #2387, slice 1.
396        CommonsItem::Embed(e) => out.push(DocEntry {
397            name: e.name.name.clone(),
398            kind: "embed",
399            depth: 0,
400            markdown: symbols::describe_embed(e),
401            documented: e.documentation.is_some(),
402            span: e.name.span,
403            file: None,
404        }),
405    }
406}
407
408/// #1888: the documented members of `t`, under it: a record's fields, a sum's
409/// variants (depth 1) and their payload fields (depth 2), each rendered by
410/// hover's own `describe_*`. Only a documented member gets an entry, so a type
411/// whose members carry no docs keeps its single entry, and the coverage view
412/// does not list every field of every record as missing documentation.
413fn push_member_docs(out: &mut Vec<DocEntry>, t: &TypeDecl) {
414    match &t.body {
415        TypeBody::Record(r) => {
416            for f in r.fields.iter().filter(|f| f.documentation.is_some()) {
417                out.push(DocEntry {
418                    name: format!("{}.{}", t.name.name, f.name.name),
419                    kind: "field",
420                    depth: 1,
421                    markdown: symbols::describe_record_field(t, f),
422                    documented: true,
423                    span: f.name.span,
424                    file: None,
425                });
426            }
427        }
428        TypeBody::Sum(s) => {
429            for v in &s.variants {
430                if v.documentation.is_some() {
431                    out.push(DocEntry {
432                        name: format!("{}.{}", t.name.name, v.name.name),
433                        kind: "variant",
434                        depth: 1,
435                        markdown: symbols::describe_variant(t, v),
436                        documented: true,
437                        span: v.name.span,
438                        file: None,
439                    });
440                }
441                for p in v.payload.iter().filter(|p| p.documentation.is_some()) {
442                    out.push(DocEntry {
443                        name: format!("{}.{}.{}", t.name.name, v.name.name, p.name.name),
444                        kind: "field",
445                        depth: 2,
446                        markdown: symbols::describe_variant_field(t, v, p),
447                        documented: true,
448                        span: p.name.span,
449                        file: None,
450                    });
451                }
452            }
453        }
454        TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    /// Fixture: a fully-documented context with a nested hierarchy — a
463    /// capability with an op, a service with a handler, and an agent with a
464    /// handler. Asserts every declaration appears, in source order, with its
465    /// doc rendered and its depth reflecting the hierarchy.
466    const DOCUMENTED_CTX: &str = r#"---
467The demo application context.
468---
469context demo.app
470
471---
472Reads the wall clock.
473---
474capability Clock {
475  ---
476  Milliseconds since the Unix epoch.
477  ---
478  fn now() -> Int
479}
480
481---
482An amount in the smallest currency unit.
483---
484type Money = Int where NonNegative
485
486---
487The public HTTP surface.
488---
489service Api from http {
490  ---
491  Returns the current instant.
492  ---
493  on GET("/now") () -> Effect[Int] given Clock {
494    Clock.now()
495  }
496}
497
498---
499A per-key running total.
500---
501agent Counter {
502  key id: Int
503  store value: Cell[Int]
504  ---
505  Adds `amount` to the total and returns the new value.
506  ---
507  on call bump(amount: Int) -> Effect[Int] {
508    let _ <- value.update((v) => v + amount)
509    value
510  }
511}
512"#;
513
514    #[test]
515    fn documented_context_aggregates_every_declaration_in_order_and_hierarchy() {
516        let model = documentation_model(DOCUMENTED_CTX).expect("a context model");
517        assert_eq!(model.unit_name, "demo.app");
518        assert_eq!(model.unit_kind, "context");
519        assert_eq!(
520            model.unit_doc.as_deref(),
521            Some("The demo application context.")
522        );
523
524        // Names, in source order, with the nested members interleaved after
525        // their owners.
526        let names: Vec<(&str, u32, &str)> = model
527            .entries
528            .iter()
529            .map(|e| (e.name.as_str(), e.depth, e.kind))
530            .collect();
531        assert_eq!(
532            names,
533            vec![
534                ("Clock", 0, "capability"),
535                ("Clock.now", 1, "operation"),
536                ("Money", 0, "type"),
537                ("Api", 0, "service"),
538                ("on GET(\"/now\")", 1, "handler"),
539                ("Counter", 0, "agent"),
540                ("Counter.bump", 1, "handler"),
541            ]
542        );
543
544        // Every declaration here is documented, and the doc prose reaches the
545        // rendered Markdown (proving the reuse of hover's assembly).
546        assert!(model.entries.iter().all(|e| e.documented));
547        let money = model.entries.iter().find(|e| e.name == "Money").unwrap();
548        assert!(
549            money
550                .markdown
551                .contains("An amount in the smallest currency unit.")
552        );
553        assert!(money.markdown.contains("```bynk"));
554        let bump = model
555            .entries
556            .iter()
557            .find(|e| e.name == "Counter.bump")
558            .unwrap();
559        assert!(bump.markdown.contains("Adds `amount` to the total"));
560    }
561
562    #[test]
563    fn undocumented_declarations_are_flagged_for_the_coverage_placeholder() {
564        let src = "commons demo.x {\n\
565                   ---\n\
566                   Has docs.\n\
567                   ---\n\
568                   type Documented = Int\n\
569                   type Undocumented = Int\n\
570                   fn helper(n: Int) -> Int { n }\n\
571                   }";
572        let model = documentation_model(src).expect("a commons model");
573        assert_eq!(model.unit_kind, "commons");
574        let documented = model
575            .entries
576            .iter()
577            .find(|e| e.name == "Documented")
578            .unwrap();
579        assert!(documented.documented);
580        let undoc = model
581            .entries
582            .iter()
583            .find(|e| e.name == "Undocumented")
584            .unwrap();
585        assert!(!undoc.documented);
586        // An undocumented declaration still renders its signature — the page is
587        // a reference, not only a comment dump (Decision C).
588        assert!(undoc.markdown.contains("```bynk"));
589        let helper = model.entries.iter().find(|e| e.name == "helper").unwrap();
590        assert!(!helper.documented);
591    }
592
593    #[test]
594    fn suite_units_have_no_documentation_page() {
595        let src = "suite for demo.app {\n\
596                   case works {\n\
597                   expect true\n\
598                   }\n\
599                   }";
600        // A `suite` unit is not a documentation unit in Tier 1.
601        assert!(documentation_model(src).is_none());
602    }
603
604    #[test]
605    fn empty_input_yields_no_model() {
606        assert!(documentation_model("").is_none());
607    }
608
609    /// #1888: documented members are listed under their type, in either doc
610    /// form: fields and variants at depth 1, a variant's payload field at
611    /// depth 2, a messages entry under its bundle. Undocumented ones are not.
612    #[test]
613    fn documented_members_are_listed_under_their_type() {
614        let src = "commons m\n\nuses bynk.locale\nuses bynk.locale.types\n\n--- A run.\ntype Run = {\n  --- The repo.\n  repo: String,\n  n: Int,\n}\n\ntype Shape =\n  ---\n  Round.\n  ---\n  | Circle(\n    --- The radius.\n    r: Int,\n  )\n  | Dot\n\nmessages \"en\" @reference {\n  --- Greets.\n  \"hi\" => \"Hello\"\n  \"bye\" => \"Bye\"\n}\n";
615        let model = documentation_model(src).expect("a model");
616        let rows: Vec<(&str, &str, u32)> = model
617            .entries
618            .iter()
619            .map(|e| (e.name.as_str(), e.kind, e.depth))
620            .collect();
621        assert_eq!(
622            rows,
623            [
624                ("Run", "type", 0),
625                ("Run.repo", "field", 1),
626                ("Shape", "type", 0),
627                ("Shape.Circle", "variant", 1),
628                ("Shape.Circle.r", "field", 2),
629                ("en", "messages", 0),
630                ("en.hi", "message", 1),
631            ]
632        );
633        let repo = &model.entries[1];
634        assert!(repo.documented && repo.markdown.contains("The repo."));
635        assert!(model.entries[3].markdown.contains("Round."));
636        assert!(model.entries[4].markdown.contains("The radius."));
637        assert!(model.entries[6].markdown.contains("Greets."));
638    }
639
640    /// #1888: on the merged page of a multi-file unit, each file's member
641    /// entries follow their type and carry that file, and a `--|` module doc
642    /// is the page's lede.
643    #[test]
644    fn the_merged_page_lists_member_docs_with_their_file() {
645        use std::path::Path;
646        let a = "--- The model.\ncommons m\n\ntype Run = {\n  --- The repo.\n  repo: String,\n}\n";
647        let b = "commons m\n\ntype Outcome = enum {\n  --- Passed.\n  Pass,\n  Fail,\n}\n";
648        let (pa, pb) = (Path::new("a.bynk"), Path::new("b.bynk"));
649        let model = documentation_model_merged((pa, a), [(pb, b)]).expect("a model");
650        assert_eq!(model.unit_doc.as_deref(), Some("The model."));
651        let rows: Vec<(&str, u32, Option<&Path>)> = model
652            .entries
653            .iter()
654            .map(|e| (e.name.as_str(), e.depth, e.file.as_deref()))
655            .collect();
656        assert_eq!(
657            rows,
658            [
659                ("Run", 0, Some(pa)),
660                ("Run.repo", 1, Some(pa)),
661                ("Outcome", 0, Some(pb)),
662                ("Outcome.Pass", 1, Some(pb)),
663            ]
664        );
665    }
666
667    #[test]
668    fn adapter_unit_documents_its_items() {
669        let src = "adapter tokens {\n\
670                   binding \"./tokens.binding.ts\"\n\
671                   exports capability { Jwt }\n\
672                   capability Jwt {\n\
673                   fn sign(secret: String) -> Effect[String]\n\
674                   }\n\
675                   provides Jwt = JoseJwt\n\
676                   }";
677        let model = documentation_model(src).expect("an adapter model");
678        assert_eq!(model.unit_name, "tokens");
679        assert_eq!(model.unit_kind, "adapter");
680        assert!(
681            model
682                .entries
683                .iter()
684                .any(|e| e.name == "Jwt" && e.kind == "capability")
685        );
686        assert!(
687            model
688                .entries
689                .iter()
690                .any(|e| e.name == "Jwt.sign" && e.kind == "operation")
691        );
692        assert!(
693            model
694                .entries
695                .iter()
696                .any(|e| e.name == "Jwt = JoseJwt" && e.kind == "provider")
697        );
698    }
699}