Skip to main content

panproto_protocols/
lib.rs

1#![allow(
2    unknown_lints,
3    clippy::match_same_arms,
4    clippy::similar_names,
5    clippy::only_used_in_recursion,
6    clippy::option_if_let_else,
7    clippy::collapsible_else_if,
8    clippy::branches_sharing_code,
9    clippy::explicit_iter_loop,
10    clippy::manual_let_else,
11    clippy::hashset_insert_after_contains,
12    clippy::set_contains_or_insert
13)]
14
15//! # panproto-protocols
16//!
17//! Built-in protocol definitions for panproto.
18//!
19//! Each protocol is defined by a schema theory GAT and an instance theory GAT,
20//! composed via colimit from reusable building-block theories. Every protocol
21//! provides both a parser (native format → `Schema`) and an emitter
22//! (`Schema` → native format) for bidirectional format conversion.
23//!
24//! ## Protocol Categories
25//!
26//! - **Serialization**: Avro, `FlatBuffers`, ASN.1, Bond, `MsgPack`
27//! - **Data Schema**: CDDL, BSON
28//! - **API**: `OpenAPI`, `AsyncAPI`, RAML, JSON:API
29//! - **Database**: `MongoDB`, Cassandra, `DynamoDB`, Neo4j, Redis
30//! - **Web/Document**: `ATProto`, DOCX, ODF
31//! - **Data Science**: Parquet, Arrow, `DataFrame`
32//! - **Domain**: `GeoJSON`, FHIR, RSS/Atom, vCard/iCal, EDI X12, SWIFT MT
33//! - **Config**: K8s CRD, Docker Compose, `CloudFormation`, Ansible
34
35/// Linguistic annotation format protocol definitions.
36pub mod annotation;
37/// API specification protocol definitions.
38pub mod api;
39/// Configuration format protocol definitions.
40pub mod config;
41/// Data schema protocol definitions.
42pub mod data_schema;
43/// Data science and analytics protocol definitions.
44pub mod data_science;
45/// Database schema protocol definitions.
46pub mod database;
47/// Domain-specific protocol definitions.
48pub mod domain;
49/// Shared emit helpers for protocol serialization.
50pub mod emit;
51/// Error types for protocol operations.
52pub mod error;
53/// Raw file protocol for non-code files (README, LICENSE, images, etc.).
54pub mod raw_file;
55/// Serialization and IDL protocol definitions.
56pub mod serialization;
57/// Shared component theory definitions (building-block GATs).
58pub mod theories;
59/// Web and document format protocol definitions.
60pub mod web_document;
61
62use panproto_schema::Schema;
63
64pub use error::ProtocolError;
65
66// Re-export existing protocols at crate root for backward compatibility.
67pub use web_document::atproto;
68
69/// Parse a bundle of schema documents into one [`Schema`], resolving
70/// cross-document references across the whole bundle.
71///
72/// A single-document parser sees one document at a time, so a reference
73/// into another document resolves to an opaque placeholder vertex
74/// carrying no fields, and a lens has nothing typed to bind to. Passing
75/// the referenced documents alongside the referring one resolves each
76/// such reference to the definition's real, typed vertex. A reference
77/// whose target is in no document of the bundle stays a placeholder,
78/// which is what marks it as genuinely external.
79///
80/// This is the protocol-dispatching entry point the generic crates call,
81/// so that protocol names stay inside this crate. A protocol gains
82/// bundle support by adding an arm here; no binding surface changes.
83///
84/// # Errors
85///
86/// Returns [`ProtocolError::Parse`] if no bundle parser is registered
87/// for `protocol`, or the protocol's own error if the documents are not
88/// a well-formed bundle for it.
89pub fn parse_schema_bundle(
90    protocol: &str,
91    docs: &[serde_json::Value],
92) -> Result<Schema, ProtocolError> {
93    match protocol {
94        "atproto" => atproto::parse_lexicon_bundle(docs),
95        other => Err(ProtocolError::Parse(format!(
96            "no bundle parser registered for protocol {other:?}; supported: [\"atproto\"]"
97        ))),
98    }
99}
100
101/// The protocol names [`parse_schema_bundle`] accepts.
102///
103/// Lets a caller report or validate bundle support without hard-coding a
104/// protocol name outside this crate.
105#[must_use]
106pub const fn bundle_parser_protocols() -> &'static [&'static str] {
107    &["atproto"]
108}
109
110/// Parse a set of schema documents into per-file schemas, keyed by path.
111///
112/// The result also carries the edges that cross document boundaries: the
113/// shape [`build_project_tree`](https://docs.rs/panproto-project)
114/// consumes to store a document set as the per-file tree the VCS diffs
115/// incrementally.
116///
117/// Where [`parse_schema_bundle`] fuses a document set into one flat
118/// [`Schema`], this keeps each document a separate schema, so a
119/// version-controlled lexicon set can reuse unchanged per-file object
120/// ids across commits. Dispatch normalizes an underscore key to its
121/// canonical hyphenated protocol name, matching [`parse_schema_bundle`].
122/// Only the protocols in [`bundle_project_protocols`] retain per-file
123/// provenance today; any other returns an error.
124///
125/// # Errors
126///
127/// Returns [`ProtocolError::Parse`] for a protocol with no per-file
128/// bundle parser, or the underlying parser's error.
129pub fn parse_schema_bundle_project(
130    protocol: &str,
131    docs: &[(std::path::PathBuf, serde_json::Value)],
132) -> Result<atproto::LexiconProject, ProtocolError> {
133    match protocol.replace('_', "-").as_str() {
134        "atproto" => {
135            let lexicon_docs: Vec<atproto::LexiconDoc> = docs
136                .iter()
137                .map(|(path, value)| atproto::LexiconDoc {
138                    path: path.clone(),
139                    value: value.clone(),
140                })
141                .collect();
142            atproto::parse_lexicon_project(&lexicon_docs)
143        }
144        other => Err(ProtocolError::Parse(format!(
145            "no per-file bundle parser registered for protocol {other:?}; supported: [\"atproto\"]"
146        ))),
147    }
148}
149
150/// Protocols whose bundle parse retains per-file provenance for the VCS
151/// (via [`parse_schema_bundle_project`]).
152#[must_use]
153pub const fn bundle_project_protocols() -> &'static [&'static str] {
154    &["atproto"]
155}
156
157/// Parse a single JSON schema *document* into a [`Schema`], dispatching
158/// on protocol name.
159///
160/// This is the generic entry point that exposes every JSON-document
161/// schema parser through one call, so a binding forwards a protocol
162/// string here rather than reaching each protocol's parser directly.
163/// Protocols whose source is text rather than JSON (SQL DDL, GraphQL
164/// SDL, `.proto`, CDDL, CQL, Cypher, `ASN.1`, Bond, `FlatBuffers`, `CoNLL-U`)
165/// are served by [`parse_schema_source`] instead.
166///
167/// The `protocol` argument is matched against each protocol's canonical
168/// [`Protocol::name`](panproto_schema::Protocol) (hyphenated). An
169/// underscore is normalized to a hyphen first, so the underscore
170/// registry keys that [`crate`] callers list (`iso_space`,
171/// `msgpack_schema`, …) resolve too; `uima` is accepted as an alias of
172/// its canonical `uima-cas`.
173///
174/// # Errors
175///
176/// Returns [`ProtocolError::Parse`] if no JSON-document parser is
177/// registered for `protocol` (a text-source protocol, or an unknown
178/// name), or the protocol's own error if the document is malformed.
179pub fn parse_schema_document(
180    protocol: &str,
181    doc: &serde_json::Value,
182) -> Result<Schema, ProtocolError> {
183    match protocol.replace('_', "-").as_str() {
184        // annotation
185        "amr" => annotation::amr::parse_amr_schema(doc),
186        "bead" => annotation::bead::parse_bead(doc),
187        "brat" => annotation::brat::parse_brat(doc),
188        "concrete" => annotation::concrete::parse_concrete_schema(doc),
189        "decomp" => annotation::decomp::parse_decomp(doc),
190        "elan" => annotation::elan::parse_elan(doc),
191        "folia" => annotation::folia::parse_folia(doc),
192        "fovea" => annotation::fovea::parse_fovea(doc),
193        "iso-space" => annotation::iso_space::parse_iso_space(doc),
194        "laf-graf" => annotation::laf_graf::parse_laf_graf(doc),
195        "naf" => annotation::naf::parse_naf(doc),
196        "nif" => annotation::nif::parse_nif_schema(doc),
197        "paula" => annotation::paula::parse_paula_schema(doc),
198        "tei" => annotation::tei::parse_tei(doc),
199        "timeml" => annotation::timeml::parse_timeml(doc),
200        "ucca" => annotation::ucca::parse_ucca(doc),
201        "uima" | "uima-cas" => annotation::uima::parse_uima_schema(doc),
202        "web-annotation" => annotation::web_annotation::parse_web_annotation_schema(doc),
203        // api
204        "asyncapi" => api::asyncapi::parse_asyncapi(doc),
205        "jsonapi" => api::jsonapi::parse_jsonapi(doc),
206        "openapi" => api::openapi::parse_openapi(doc),
207        "raml" => api::raml::parse_raml_schema(doc),
208        // config
209        "ansible" => config::ansible::parse_ansible_schema(doc),
210        "cloudformation" => config::cloudformation::parse_cfn_schema(doc),
211        "k8s-crd" => config::k8s_crd::parse_k8s_crd_schema(doc),
212        // data_schema
213        "bson" => data_schema::bson::parse_bson_schema(doc),
214        "json-schema" => data_schema::json_schema::parse_json_schema(doc),
215        // data_science
216        "arrow" => data_science::arrow::parse_arrow_schema(doc),
217        "dataframe" => data_science::dataframe::parse_dataframe_schema(doc),
218        "parquet" => data_science::parquet::parse_parquet_schema(doc),
219        // database
220        "dynamodb" => database::dynamodb::parse_dynamodb(doc),
221        "mongodb" => database::mongodb::parse_mongodb_schema(doc),
222        // domain
223        "edi-x12" => domain::edi_x12::parse_edi_schema(doc),
224        "fhir" => domain::fhir::parse_fhir_schema(doc),
225        "geojson" => domain::geojson::parse_geojson_schema(doc),
226        "rss-atom" => domain::rss_atom::parse_rss_atom_schema(doc),
227        "swift-mt" => domain::swift_mt::parse_swift_mt_schema(doc),
228        "vcard-ical" => domain::vcard_ical::parse_vcard_ical_schema(doc),
229        // serialization
230        "avro" => serialization::avro::parse_avsc(doc),
231        "msgpack-schema" => serialization::msgpack_schema::parse_msgpack_schema(doc),
232        // web_document
233        "atproto" => web_document::atproto::parse_lexicon(doc),
234        "docx" => web_document::docx::parse_docx_schema(doc),
235        "odf" => web_document::odf::parse_odf_schema(doc),
236        other => Err(ProtocolError::Parse(format!(
237            "no document parser registered for protocol {other:?}; \
238             a text-source schema (SQL DDL, GraphQL SDL, .proto, CDDL, and \
239             the like) is loaded with parse_schema_source instead"
240        ))),
241    }
242}
243
244/// Parse a *text/source* schema (an IDL or DDL string) into a
245/// [`Schema`], dispatching on protocol name.
246///
247/// The text counterpart to [`parse_schema_document`], for the protocols
248/// whose source is a language rather than a JSON document: SQL DDL,
249/// GraphQL SDL, Protocol Buffers `.proto`, CDDL, Cassandra CQL, Cypher,
250/// `ASN.1`, Microsoft Bond, `FlatBuffers` `.fbs`, and `CoNLL-U`. Name matching
251/// is the same normalization as [`parse_schema_document`].
252///
253/// # Errors
254///
255/// Returns [`ProtocolError::Parse`] if no text-source parser is
256/// registered for `protocol`, or the protocol's own error if the source
257/// is malformed.
258pub fn parse_schema_source(protocol: &str, source: &str) -> Result<Schema, ProtocolError> {
259    match protocol.replace('_', "-").as_str() {
260        "conllu" => annotation::conllu::parse_conllu(source),
261        "cddl" => data_schema::cddl::parse_cddl(source),
262        "cassandra" => database::cassandra::parse_cql(source),
263        "neo4j" => database::neo4j::parse_cypher_schema(source),
264        "redis" => database::redis::parse_redis_schema(source),
265        "asn1" => serialization::asn1::parse_asn1(source),
266        "bond" => serialization::bond::parse_bond(source),
267        "flatbuffers" => serialization::flatbuffers::parse_fbs(source),
268        "graphql" => api::graphql::parse_sdl(source),
269        "sql" => database::sql::parse_ddl(source),
270        "protobuf" => serialization::protobuf::parse_proto(source),
271        other => Err(ProtocolError::Parse(format!(
272            "no source parser registered for protocol {other:?}; \
273             a JSON-document schema is loaded with parse_schema_document instead"
274        ))),
275    }
276}
277
278/// The protocol names [`parse_schema_document`] accepts (canonical,
279/// hyphenated form).
280#[must_use]
281pub const fn document_parser_protocols() -> &'static [&'static str] {
282    &[
283        "amr",
284        "bead",
285        "brat",
286        "concrete",
287        "decomp",
288        "elan",
289        "folia",
290        "fovea",
291        "iso-space",
292        "laf-graf",
293        "naf",
294        "nif",
295        "paula",
296        "tei",
297        "timeml",
298        "ucca",
299        "uima-cas",
300        "web-annotation",
301        "asyncapi",
302        "jsonapi",
303        "openapi",
304        "raml",
305        "ansible",
306        "cloudformation",
307        "k8s-crd",
308        "bson",
309        "json-schema",
310        "arrow",
311        "dataframe",
312        "parquet",
313        "dynamodb",
314        "mongodb",
315        "edi-x12",
316        "fhir",
317        "geojson",
318        "rss-atom",
319        "swift-mt",
320        "vcard-ical",
321        "avro",
322        "msgpack-schema",
323        "atproto",
324        "docx",
325        "odf",
326    ]
327}
328
329/// The protocol names [`parse_schema_source`] accepts (canonical,
330/// hyphenated form).
331#[must_use]
332pub const fn source_parser_protocols() -> &'static [&'static str] {
333    &[
334        "conllu",
335        "cddl",
336        "cassandra",
337        "neo4j",
338        "redis",
339        "asn1",
340        "bond",
341        "flatbuffers",
342        "graphql",
343        "sql",
344        "protobuf",
345    ]
346}
347
348#[cfg(test)]
349#[allow(clippy::expect_used)]
350mod dispatch_tests {
351    use super::*;
352
353    #[test]
354    fn document_dispatch_routes_json_schema() {
355        let doc = serde_json::json!({
356            "type": "object",
357            "properties": { "name": { "type": "string" }, "age": { "type": "integer" } }
358        });
359        let schema = parse_schema_document("json-schema", &doc).expect("json-schema should parse");
360        assert!(schema.has_vertex("root"));
361        assert!(schema.has_vertex("root.name"));
362        assert!(schema.has_vertex("root.age"));
363    }
364
365    #[test]
366    fn document_dispatch_normalizes_underscore_to_hyphen() {
367        // The underscore registry-key spelling resolves to the same
368        // canonical hyphenated parser.
369        let doc = serde_json::json!({ "type": "object" });
370        let via_hyphen = parse_schema_document("json-schema", &doc).expect("hyphen form");
371        let via_underscore = parse_schema_document("json_schema", &doc).expect("underscore form");
372        assert_eq!(via_hyphen.vertex_count(), via_underscore.vertex_count());
373    }
374
375    #[test]
376    fn source_dispatch_routes_graphql_sql_protobuf() {
377        let g = parse_schema_source("graphql", "type Query { hello: String }")
378            .expect("graphql sdl should parse");
379        assert!(g.has_vertex("Query"));
380
381        let s = parse_schema_source("sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);")
382            .expect("sql ddl should parse");
383        assert!(s.has_vertex("users"));
384
385        let p = parse_schema_source("protobuf", "message User { string name = 1; }")
386            .expect("proto should parse");
387        assert!(p.has_vertex("User"));
388    }
389
390    #[test]
391    fn uima_is_accepted_under_both_names() {
392        // The `uima` registry key aliases the canonical `uima-cas`; both
393        // route to the parser rather than the unknown-protocol arm.
394        let doc = serde_json::json!({});
395        // A malformed doc may error, but never with the "no parser" message.
396        for name in ["uima", "uima-cas"] {
397            if let Err(ProtocolError::Parse(msg)) = parse_schema_document(name, &doc) {
398                assert!(
399                    !msg.contains("no document parser"),
400                    "{name} must route to the uima parser, got: {msg}"
401                );
402            }
403        }
404    }
405
406    #[test]
407    fn cross_category_calls_point_at_the_other_dispatch() {
408        // A text-source protocol passed to the document dispatch is told
409        // to use the source dispatch, and vice versa.
410        let doc = serde_json::json!({});
411        let err = parse_schema_document("sql", &doc).expect_err("sql is text-source");
412        assert!(err.to_string().contains("parse_schema_source"));
413
414        let err = parse_schema_source("json-schema", "{}").expect_err("json-schema is a document");
415        assert!(err.to_string().contains("parse_schema_document"));
416    }
417
418    #[test]
419    fn parser_protocol_lists_have_expected_sizes() {
420        assert_eq!(document_parser_protocols().len(), 43);
421        assert_eq!(source_parser_protocols().len(), 11);
422        assert!(document_parser_protocols().contains(&"json-schema"));
423        assert!(source_parser_protocols().contains(&"graphql"));
424        assert!(source_parser_protocols().contains(&"sql"));
425        assert!(source_parser_protocols().contains(&"protobuf"));
426    }
427}