Skip to main content

panproto_parse/
registry.rs

1//! Parser registry mapping protocol names to full-AST parser implementations.
2
3use std::path::Path;
4use std::sync::Arc;
5
6use panproto_schema::{AbstractSchema, DecoratedSchema, Schema};
7use rustc_hash::FxHashMap;
8
9use crate::error::ParseError;
10use crate::layout_policy::LayoutPolicy;
11use crate::theory_extract::ExtractedTheoryMeta;
12
13/// A full-AST parser and emitter for a specific programming language.
14///
15/// Each implementation wraps a tree-sitter grammar and its auto-derived theory,
16/// providing parse (source → Schema) and emit (Schema → source) operations.
17pub trait AstParser: Send + Sync {
18    /// The panproto protocol name (e.g. `"typescript"`, `"python"`).
19    fn protocol_name(&self) -> &str;
20
21    /// Parse source code into a full-AST [`Schema`].
22    ///
23    /// # Errors
24    ///
25    /// Returns [`ParseError`] if tree-sitter parsing fails or schema construction fails.
26    fn parse(&self, source: &[u8], file_path: &str) -> Result<Schema, ParseError>;
27
28    /// Emit a [`Schema`] back to source code bytes.
29    ///
30    /// The emitter walks the schema graph top-down, using formatting constraints
31    /// (comment, indent, blank-lines-before) to reproduce the original formatting.
32    ///
33    /// # Errors
34    ///
35    /// Returns [`ParseError::EmitFailed`] if emission fails.
36    fn emit(&self, schema: &Schema) -> Result<Vec<u8>, ParseError>;
37
38    /// File extensions this parser handles (e.g. `["ts", "tsx"]`).
39    fn supported_extensions(&self) -> &[&str];
40
41    /// The auto-derived theory metadata for this language.
42    fn theory_meta(&self) -> &ExtractedTheoryMeta;
43
44    /// Render a by-construction [`Schema`] (one with no parse-recovered
45    /// byte positions or interstitials) to source bytes.
46    ///
47    /// Unlike [`emit`](Self::emit), which reconstructs source from
48    /// byte-position fragments stored on the schema during `parse`,
49    /// `emit_pretty` walks tree-sitter `grammar.json` production rules
50    /// to render schemas built from scratch via `SchemaBuilder`.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`ParseError::EmitFailed`] when the language has no
55    /// vendored `grammar.json`, when a vertex's kind is not a grammar
56    /// rule, or when a required field has no corresponding schema edge.
57    fn emit_pretty(&self, schema: &Schema) -> Result<Vec<u8>, ParseError> {
58        self.emit_pretty_with_policy(schema, &crate::emit_pretty::FormatPolicy::default())
59    }
60
61    /// Render a by-construction [`Schema`] under a caller-supplied
62    /// [`FormatPolicy`](crate::emit_pretty::FormatPolicy).
63    ///
64    /// The policy governs every configurable aspect of the rendered
65    /// output: separator between glued tokens, newline byte sequence,
66    /// indent width, line-break and indent-open/close token sets. The
67    /// default policy (used by [`emit_pretty`](Self::emit_pretty))
68    /// targets syntactic validity with ASCII conventions; callers
69    /// supplying their own policy can pin idiomatic formatting.
70    ///
71    /// # Errors
72    ///
73    /// Returns [`ParseError::EmitFailed`] when the language has no
74    /// vendored `grammar.json`, when a vertex's kind is not a grammar
75    /// rule, or when a required field has no corresponding schema edge.
76    fn emit_pretty_with_policy(
77        &self,
78        schema: &Schema,
79        policy: &crate::emit_pretty::FormatPolicy,
80    ) -> Result<Vec<u8>, ParseError> {
81        let _ = (schema, policy);
82        Err(ParseError::EmitFailed {
83            protocol: self.protocol_name().to_owned(),
84            reason: format!(
85                "emit_pretty_with_policy not implemented for protocol '{}'",
86                self.protocol_name()
87            ),
88        })
89    }
90}
91
92/// Registry of all full-AST parsers, keyed by protocol name.
93///
94/// Provides language detection by file extension and dispatches parse/emit
95/// operations to the appropriate language parser.
96pub struct ParserRegistry {
97    /// Parsers keyed by protocol name.
98    ///
99    /// Held by `Arc` (not `Box`) so the same handle can be shared with
100    /// the layout-enrichment registry without re-wrapping at every
101    /// lookup. Registration installs both: the parser into `parsers`
102    /// and a thin adapter into the lens crate's enrichment registry.
103    parsers: FxHashMap<String, Arc<dyn AstParser>>,
104    /// Extension → protocol name mapping.
105    extension_map: FxHashMap<String, String>,
106}
107
108impl ParserRegistry {
109    /// Create a new registry populated with all enabled language parsers.
110    ///
111    /// With the `grammars` feature (default), this populates the registry from
112    /// `panproto-grammars`, which provides up to 259 tree-sitter languages.
113    /// Without the `grammars` feature, this returns an empty registry; call
114    /// [`register`](Self::register) to add parsers manually using individual
115    /// grammar crates.
116    #[must_use]
117    pub fn new() -> Self {
118        let mut registry = Self {
119            parsers: FxHashMap::default(),
120            extension_map: FxHashMap::default(),
121        };
122
123        #[cfg(feature = "grammars")]
124        for grammar in panproto_grammars::grammars() {
125            let config = crate::languages::walker_configs::walker_config_for(grammar.name);
126            match crate::languages::common::LanguageParser::from_language_with_grammar_json(
127                grammar.name,
128                grammar.extensions.to_vec(),
129                grammar.language,
130                grammar.node_types,
131                grammar.tags_query,
132                config,
133                grammar.grammar_json,
134            ) {
135                Ok(p) => registry.register(Box::new(p)),
136                Err(err) => {
137                    let _ = err;
138                    #[cfg(debug_assertions)]
139                    eprintln!(
140                        "warning: grammar '{}' theory extraction failed: {err}",
141                        grammar.name
142                    );
143                }
144            }
145        }
146
147        registry
148    }
149
150    /// Register a parser implementation.
151    ///
152    /// In addition to keying the parser by its protocol name, this
153    /// installs a [`LayoutEnricher`](panproto_lens::enrichment_registry::LayoutEnricher)
154    /// adapter into the global enrichment registry so that a
155    /// `parse_emit_protolens(protocol, …)` instantiation finds a
156    /// synthesis driver without any further wiring.
157    pub fn register(&mut self, parser: Box<dyn AstParser>) {
158        let name = parser.protocol_name().to_owned();
159        for ext in parser.supported_extensions() {
160            self.extension_map.insert((*ext).to_owned(), name.clone());
161        }
162        let arc: Arc<dyn AstParser> = Arc::from(parser);
163        crate::decorate::register_layout_enricher(Arc::clone(&arc));
164        self.parsers.insert(name, arc);
165    }
166
167    /// Register a tree-sitter language as a full-AST parser.
168    ///
169    /// Used by `panproto-grammars-*` companion crates that ship grammars
170    /// outside the default `panproto-grammars` build. The byte-slice
171    /// arguments must outlive this registry; the canonical pattern is
172    /// for the companion to bake the data into `&'static` rodata at
173    /// compile time and pass references that are valid for the process
174    /// lifetime.
175    ///
176    /// `walker_config` is looked up by `name` from the bundled per-language
177    /// configuration table. Languages without a tailored configuration
178    /// fall back to the default walker config.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`ParseError`] if theory extraction from `node_types_json`
183    /// fails or if the tags query rejects compilation.
184    pub fn register_external_grammar(
185        &mut self,
186        name: &'static str,
187        extensions: Vec<&'static str>,
188        language: tree_sitter::Language,
189        node_types_json: &'static [u8],
190        tags_query: Option<&'static str>,
191        grammar_json: Option<&'static [u8]>,
192    ) -> Result<(), crate::error::ParseError> {
193        let config = crate::languages::walker_configs::walker_config_for(name);
194        let parser = crate::languages::common::LanguageParser::from_language_with_grammar_json(
195            name,
196            extensions,
197            language,
198            node_types_json,
199            tags_query,
200            config,
201            grammar_json,
202        )?;
203        self.register(Box::new(parser));
204        Ok(())
205    }
206
207    /// Owned-data variant of [`register_external_grammar`](Self::register_external_grammar).
208    ///
209    /// Accepts `String` / `Vec<u8>` rather than `&'static` references. The
210    /// caller is presumed not to have process-lifetime rodata available
211    /// (typical dev-time use: bytes read from disk via the Python binding's
212    /// override hook). To match the trait's `'static` lifetime requirement
213    /// the inputs are leaked into the heap; the leak is one-time per
214    /// override.
215    ///
216    /// This is the registration primitive for grammar-author workflows
217    /// where a grammar's `parser.c` / `grammar.json` / `node-types.json`
218    /// are evolving outside the panproto release cadence. Production
219    /// builds should continue to use [`register_external_grammar`](Self::register_external_grammar) with
220    /// `'static` data baked into the binary at compile time.
221    ///
222    /// # Errors
223    ///
224    /// Returns [`ParseError`] if theory extraction or tags-query
225    /// compilation fails.
226    pub fn register_external_grammar_owned(
227        &mut self,
228        name: String,
229        extensions: Vec<String>,
230        language: tree_sitter::Language,
231        node_types_json: Vec<u8>,
232        tags_query: Option<String>,
233        grammar_json: Option<Vec<u8>>,
234    ) -> Result<(), crate::error::ParseError> {
235        let name_static: &'static str = Box::leak(name.into_boxed_str());
236        let extensions_static: Vec<&'static str> = extensions
237            .into_iter()
238            .map(|s| Box::leak(s.into_boxed_str()) as &'static str)
239            .collect();
240        let node_types_static: &'static [u8] = Box::leak(node_types_json.into_boxed_slice());
241        let tags_query_static: Option<&'static str> =
242            tags_query.map(|s| Box::leak(s.into_boxed_str()) as &'static str);
243        let grammar_json_static: Option<&'static [u8]> =
244            grammar_json.map(|v| Box::leak(v.into_boxed_slice()) as &'static [u8]);
245
246        self.register_external_grammar(
247            name_static,
248            extensions_static,
249            language,
250            node_types_static,
251            tags_query_static,
252            grammar_json_static,
253        )
254    }
255
256    /// Remove a registration by protocol name.
257    ///
258    /// Drops the parser and any extension mappings that pointed at it.
259    /// Returns `true` if a parser was removed, `false` if no such
260    /// registration existed. Primarily intended for grammar-author
261    /// workflows where a registered grammar is being replaced by a
262    /// freshly-compiled version mid-process.
263    pub fn unregister(&mut self, name: &str) -> bool {
264        let removed = self.parsers.remove(name).is_some();
265        if removed {
266            self.extension_map.retain(|_, v| v != name);
267        }
268        removed
269    }
270
271    /// Override a registered grammar with new owned data.
272    ///
273    /// Equivalent to [`unregister`](Self::unregister) followed by
274    /// [`register_external_grammar_owned`](Self::register_external_grammar_owned),
275    /// and intended for the same grammar-author dev workflow. Any
276    /// extension mappings previously bound to `name` are replaced by
277    /// the new `extensions`.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`ParseError`] if theory extraction or tags-query
282    /// compilation fails on the new grammar; in that case the prior
283    /// registration is already gone.
284    pub fn override_grammar(
285        &mut self,
286        name: String,
287        extensions: Vec<String>,
288        language: tree_sitter::Language,
289        node_types_json: Vec<u8>,
290        tags_query: Option<String>,
291        grammar_json: Option<Vec<u8>>,
292    ) -> Result<(), crate::error::ParseError> {
293        self.unregister(&name);
294        self.register_external_grammar_owned(
295            name,
296            extensions,
297            language,
298            node_types_json,
299            tags_query,
300            grammar_json,
301        )
302    }
303
304    /// Detect the language protocol for a file path by its extension.
305    ///
306    /// Returns `None` if the extension is not recognized (caller should
307    /// fall back to the `raw_file` protocol).
308    #[must_use]
309    pub fn detect_language(&self, path: &Path) -> Option<&str> {
310        path.extension()
311            .and_then(|ext| ext.to_str())
312            .and_then(|ext| self.extension_map.get(ext))
313            .map(String::as_str)
314    }
315
316    /// Parse a file by detecting its language from the file path.
317    ///
318    /// # Errors
319    ///
320    /// Returns [`ParseError::UnknownLanguage`] if the file extension is not recognized.
321    /// Returns other [`ParseError`] variants if parsing fails.
322    pub fn parse_file(&self, path: &Path, content: &[u8]) -> Result<Schema, ParseError> {
323        let protocol = self
324            .detect_language(path)
325            .ok_or_else(|| ParseError::UnknownLanguage {
326                extension: path
327                    .extension()
328                    .and_then(|e| e.to_str())
329                    .unwrap_or("")
330                    .to_owned(),
331            })?;
332
333        self.parse_with_protocol(protocol, content, &path.display().to_string())
334    }
335
336    /// Parse source code with a specific protocol name.
337    ///
338    /// # Errors
339    ///
340    /// Returns [`ParseError::UnknownLanguage`] if the protocol is not registered.
341    pub fn parse_with_protocol(
342        &self,
343        protocol: &str,
344        content: &[u8],
345        file_path: &str,
346    ) -> Result<Schema, ParseError> {
347        let parser = self
348            .parsers
349            .get(protocol)
350            .ok_or_else(|| ParseError::UnknownLanguage {
351                extension: protocol.to_owned(),
352            })?;
353
354        parser.parse(content, file_path)
355    }
356
357    /// Emit a schema back to source code bytes using the specified protocol.
358    ///
359    /// # Errors
360    ///
361    /// Returns [`ParseError::UnknownLanguage`] if the protocol is not registered.
362    pub fn emit_with_protocol(
363        &self,
364        protocol: &str,
365        schema: &Schema,
366    ) -> Result<Vec<u8>, ParseError> {
367        let parser = self
368            .parsers
369            .get(protocol)
370            .ok_or_else(|| ParseError::UnknownLanguage {
371                extension: protocol.to_owned(),
372            })?;
373
374        parser.emit(schema)
375    }
376
377    /// Render a by-construction schema using the named protocol.
378    ///
379    /// # Errors
380    ///
381    /// Returns [`ParseError::UnknownLanguage`] if the protocol is not
382    /// registered, or [`ParseError::EmitFailed`] from the underlying
383    /// parser's `emit_pretty`.
384    pub fn emit_pretty_with_protocol(
385        &self,
386        protocol: &str,
387        schema: &Schema,
388    ) -> Result<Vec<u8>, ParseError> {
389        let parser = self
390            .parsers
391            .get(protocol)
392            .ok_or_else(|| ParseError::UnknownLanguage {
393                extension: protocol.to_owned(),
394            })?;
395
396        parser.emit_pretty(schema)
397    }
398
399    /// Report the test-verification status of `emit_pretty` for a
400    /// given protocol.
401    ///
402    /// The status is a programmatic check that downstream tooling
403    /// (e.g. quivers, schema-migration pipelines) can use to refuse
404    /// emit on protocols whose fixed-point law has never been
405    /// exercised by panproto's test suite. The three tiers are:
406    ///
407    /// * [`EmitVerificationStatus::Verified`] — the protocol has an
408    ///   explicit fixed-point or roundtrip test in panproto's suite.
409    ///   `emit_pretty(parse(emit_pretty(s))) == emit_pretty(s)` is
410    ///   known to hold on representative source.
411    /// * [`EmitVerificationStatus::Generic`] — the protocol is
412    ///   registered (a tree-sitter grammar is vendored) and the
413    ///   generic dispatch path applies, but no per-language test
414    ///   asserts emit correctness. Output is structurally derived
415    ///   from `grammar.json` + the universal cassette layer and is
416    ///   likely correct, but unverified.
417    /// * [`EmitVerificationStatus::Unsupported`] — the protocol is
418    ///   not registered, OR is registered but no `grammar.json` was
419    ///   vendored at build time. `emit_pretty` will return
420    ///   [`ParseError::EmitFailed`].
421    #[must_use]
422    pub fn emit_verification_status(&self, protocol: &str) -> EmitVerificationStatus {
423        if !self.parsers.contains_key(protocol) {
424            return EmitVerificationStatus::Unsupported;
425        }
426        if VERIFIED_EMIT_PROTOCOLS.binary_search(&protocol).is_ok() {
427            EmitVerificationStatus::Verified
428        } else {
429            EmitVerificationStatus::Generic
430        }
431    }
432}
433
434/// Programmatic verification tier for [`ParserRegistry::emit_verification_status`].
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
436pub enum EmitVerificationStatus {
437    /// `emit_pretty` for this protocol has a test in panproto's suite
438    /// asserting the fixed-point law on representative source.
439    Verified,
440    /// The protocol is registered and the generic dispatch path
441    /// applies, but no per-language test asserts emit correctness.
442    Generic,
443    /// The protocol is not registered, or its grammar lacks the
444    /// vendored `grammar.json` that `emit_pretty` requires.
445    Unsupported,
446}
447
448/// Protocols whose `emit_pretty` has an explicit fixed-point or
449/// roundtrip test in panproto's test suite.
450///
451/// Maintenance: when a new `<lang>_emit_is_fixed_point` or
452/// `<lang>_roundtrip` test lands in `crates/panproto-parse/tests/`,
453/// add the protocol name here. Names MUST be kept in sorted order so
454/// the binary-search lookup in [`ParserRegistry::emit_verification_status`]
455/// works.
456const VERIFIED_EMIT_PROTOCOLS: &[&str] = &[
457    "bash",
458    "bugs",
459    "c",
460    "cpp",
461    "csharp",
462    "go",
463    "jags",
464    "java",
465    "javascript",
466    "julia",
467    "php",
468    "python",
469    "rust",
470    "scheme",
471    "stan",
472    "typescript",
473];
474
475impl ParserRegistry {
476    /// Decorate an [`AbstractSchema`] with the layout enrichment
477    /// fibre required by `emit_pretty_with_protocol` and friends.
478    ///
479    /// This is the put-direction of the parse / decorate / emit lens
480    /// at `protocol`. The implementation routes through the same
481    /// grammar walker as `emit_pretty` followed by `parse`, so the
482    /// resulting [`DecoratedSchema`] carries a complete layout fibre
483    /// recovered by the parse-side walker — `start-byte`, `end-byte`,
484    /// every `interstitial-N`, `chose-alt-fingerprint`, and
485    /// `chose-alt-child-kinds`.
486    ///
487    /// The section law holds up to kind- and edge-multiset
488    /// equivalence: `forget_layout(decorate(a)) ≅ a` modulo vertex-id
489    /// renaming. Grammars where parsing consolidates tokens that the
490    /// emitter rendered as separate sequences (e.g. lilypond's `c'4`
491    /// re-parses to a single note) do not preserve a one-to-one
492    /// vertex correspondence, so the result's vertex IDs are always
493    /// freshly minted by the parser.
494    ///
495    /// # Errors
496    ///
497    /// Returns [`ParseError::UnknownLanguage`] when `protocol` is not
498    /// registered, [`ParseError::SchemaConstruction`] when the
499    /// abstract schema was built for a different protocol than
500    /// `protocol`, [`ParseError::EmitFailed`] when the grammar walker
501    /// cannot render the abstract schema (missing `grammar.json`,
502    /// vertex kind not a rule), or any other parser error if the
503    /// re-parse step rejects the canonical bytes (a regression in the
504    /// parse/emit pipeline, not a user bug).
505    pub fn decorate(
506        &self,
507        protocol: &str,
508        abstract_schema: &AbstractSchema,
509        policy: &LayoutPolicy,
510    ) -> Result<DecoratedSchema, ParseError> {
511        let parser = self
512            .parsers
513            .get(protocol)
514            .ok_or_else(|| ParseError::UnknownLanguage {
515                extension: protocol.to_owned(),
516            })?;
517        // `decorate_with_parser` enforces the protocol-match invariant
518        // between the parser and the abstract schema, so no extra guard
519        // is needed here.
520        crate::decorate::decorate_with_parser(parser.as_ref(), abstract_schema, policy)
521    }
522
523    /// Render an [`AbstractSchema`] to canonical source bytes under
524    /// `policy`.
525    ///
526    /// Implementation note: this is exactly the first emit step of
527    /// [`decorate`](Self::decorate) — `decorate` then re-parses to
528    /// recover the layout fibre, but if all the caller wants is the
529    /// bytes, the re-parse is wasted work. Going through
530    /// `emit_pretty_with_policy` directly preserves every field of
531    /// `policy` in the output (`separator`, `newline`, `indent_width`,
532    /// `line_break_after`, `indent_open` / `indent_close`).
533    ///
534    /// # Errors
535    ///
536    /// See [`decorate`](Self::decorate).
537    pub fn pretty_with_protocol(
538        &self,
539        protocol: &str,
540        abstract_schema: &AbstractSchema,
541        policy: &LayoutPolicy,
542    ) -> Result<Vec<u8>, ParseError> {
543        let parser = self
544            .parsers
545            .get(protocol)
546            .ok_or_else(|| ParseError::UnknownLanguage {
547                extension: protocol.to_owned(),
548            })?;
549        check_protocol_match(
550            protocol,
551            abstract_schema.as_schema(),
552            "pretty_with_protocol",
553        )?;
554        parser.emit_pretty_with_policy(abstract_schema.as_schema(), policy)
555    }
556
557    /// Return the canonical [`Protolens`](panproto_lens::Protolens)
558    /// describing the parse / decorate / emit relationship at
559    /// `protocol`.
560    ///
561    /// The protolens encodes the schema-level structure of the
562    /// relationship: source-side strips the layout enrichment fibre,
563    /// target-side adds it via the registered
564    /// [`LayoutEnricher`](panproto_lens::enrichment_registry::LayoutEnricher).
565    /// It composes with the rest of the `panproto-lens` protolens
566    /// algebra for chain-law reasoning. The operational entry points
567    /// for running the relationship on real schemas are
568    /// [`decorate`](Self::decorate),
569    /// [`pretty_with_protocol`](Self::pretty_with_protocol), and
570    /// [`emit_pretty_with_protocol`](Self::emit_pretty_with_protocol).
571    ///
572    /// # Errors
573    ///
574    /// Returns [`ParseError::UnknownLanguage`] when `protocol` is not
575    /// registered.
576    pub fn parse_emit_protolens(
577        &self,
578        protocol: &str,
579        policy: &LayoutPolicy,
580    ) -> Result<panproto_lens::Protolens, ParseError> {
581        if !self.parsers.contains_key(protocol) {
582            return Err(ParseError::UnknownLanguage {
583                extension: protocol.to_owned(),
584            });
585        }
586        Ok(crate::parse_emit_protolens::parse_emit_protolens(
587            protocol, policy,
588        ))
589    }
590
591    /// Get the theory metadata for a specific protocol.
592    #[must_use]
593    pub fn theory_meta(&self, protocol: &str) -> Option<&ExtractedTheoryMeta> {
594        self.parsers.get(protocol).map(|p| p.theory_meta())
595    }
596
597    /// List all registered protocol names.
598    pub fn protocol_names(&self) -> impl Iterator<Item = &str> {
599        self.parsers.keys().map(String::as_str)
600    }
601
602    /// O(1) lookup: is a parser already registered for `protocol`?
603    ///
604    /// Useful for dedup at the registration boundary. The umbrella
605    /// `panproto-grammars-all` companion pack overlaps with both the
606    /// built-in core grammars and every per-group pack; callers can
607    /// short-circuit before re-registering rather than scanning
608    /// `protocol_names()` linearly.
609    #[must_use]
610    pub fn has_parser(&self, protocol: &str) -> bool {
611        self.parsers.contains_key(protocol)
612    }
613
614    /// Get the number of registered parsers.
615    #[must_use]
616    pub fn len(&self) -> usize {
617        self.parsers.len()
618    }
619
620    /// Check if the registry is empty.
621    #[must_use]
622    pub fn is_empty(&self) -> bool {
623        self.parsers.is_empty()
624    }
625}
626
627impl Default for ParserRegistry {
628    fn default() -> Self {
629        Self::new()
630    }
631}
632
633/// Guard against running parser-tied operations on a schema built
634/// for a different protocol. Catches the user-visible error of
635/// passing (say) a JSON schema to a Python parser before the
636/// underlying grammar walker would surface it as an opaque rule
637/// mismatch.
638fn check_protocol_match(
639    expected: &str,
640    schema: &Schema,
641    operation: &'static str,
642) -> Result<(), ParseError> {
643    if schema.protocol == expected {
644        Ok(())
645    } else {
646        Err(ParseError::SchemaConstruction {
647            reason: format!(
648                "{operation}: protocol mismatch — registry called with '{expected}' but \
649                 schema carries protocol '{}'",
650                schema.protocol,
651            ),
652        })
653    }
654}