1#![deny(warnings)]
2#![deny(missing_docs)]
3
4use std::collections::BTreeSet;
7use std::fmt::Write;
8use wesley_core::{
9 Field, LawEntryBodyV1, LawIrV1, OperationArgument, OperationType, ScalarRepresentationV1,
10 SchemaOperation, TypeDefinition, TypeKind, TypeReference, WesleyIR,
11};
12
13mod le_binary;
14pub use le_binary::{
15 emit_le_binary_rust, DEFAULT_CODEC_IMPORT as LE_BINARY_RUST_DEFAULT_CODEC_IMPORT,
16 LE_BINARY_GENERATOR_NAME as LE_BINARY_RUST_GENERATOR_NAME,
17};
18
19pub const GENERATOR_NAME: &str = "wesley-emit-rust";
21
22pub const GENERATOR_VERSION: &str = env!("CARGO_PKG_VERSION");
24
25pub fn emit_rust(ir: &WesleyIR) -> String {
27 let file = RustFile::from_ir(ir);
28
29 print_file(&file)
30}
31
32pub fn emit_rust_with_operations(ir: &WesleyIR, operations: &[SchemaOperation]) -> String {
34 let file = RustFile::from_ir_and_operations(ir, operations);
35
36 print_file(&file)
37}
38
39pub fn emit_rust_with_operations_and_hashes(
41 ir: &WesleyIR,
42 operations: &[SchemaOperation],
43 schema_hash: &str,
44 law_hash: &str,
45) -> String {
46 let mut file = RustFile::from_ir_and_operations(ir, operations);
47 file.provenance = Some(RustProvenanceConstants {
48 schema_hash: schema_hash.to_string(),
49 law_hash: law_hash.to_string(),
50 });
51
52 print_file(&file)
53}
54
55pub fn emit_rust_with_operations_and_law(
57 ir: &WesleyIR,
58 operations: &[SchemaOperation],
59 schema_hash: &str,
60 law_hash: &str,
61 law_ir: &LawIrV1,
62) -> String {
63 let mut file = RustFile::from_ir_and_operations(ir, operations);
64 file.provenance = Some(RustProvenanceConstants {
65 schema_hash: schema_hash.to_string(),
66 law_hash: law_hash.to_string(),
67 });
68 file.law_items = rust_law_items_from_ir(ir, law_ir);
69
70 print_file(&file)
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74struct RustFile {
75 provenance: Option<RustProvenanceConstants>,
76 items: Vec<RustItem>,
77 law_items: Vec<RustLawItem>,
78}
79
80impl RustFile {
81 fn from_ir(ir: &WesleyIR) -> Self {
82 Self::from_ir_and_operations(ir, &[])
83 }
84
85 fn from_ir_and_operations(ir: &WesleyIR, operations: &[SchemaOperation]) -> Self {
86 let mut items = Vec::new();
87 let root_type_names = operations
88 .iter()
89 .map(|operation| operation.root_type_name.as_str())
90 .collect::<BTreeSet<_>>();
91
92 for type_def in &ir.types {
93 if root_type_names.contains(type_def.name.as_str()) {
94 continue;
95 }
96
97 match type_def.kind {
98 TypeKind::Scalar if is_builtin_scalar(&type_def.name) => {}
99 TypeKind::Scalar => items.push(RustItem::TypeAlias(RustTypeAlias {
100 name: rust_type_name(&type_def.name),
101 target: RustType::String,
102 })),
103 TypeKind::Object | TypeKind::Interface | TypeKind::InputObject => {
104 items.push(RustItem::Struct(struct_from_type(type_def)));
105 }
106 TypeKind::Enum => items.push(RustItem::Enum(enum_from_type(type_def))),
107 TypeKind::Union => items.push(RustItem::Enum(union_enum_from_type(type_def))),
108 }
109 }
110
111 items.extend(
112 operations
113 .iter()
114 .map(|operation| RustItem::Operation(operation_binding_from_schema(operation))),
115 );
116
117 Self {
118 provenance: None,
119 items,
120 law_items: Vec::new(),
121 }
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126struct RustProvenanceConstants {
127 schema_hash: String,
128 law_hash: String,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132enum RustItem {
133 TypeAlias(RustTypeAlias),
134 Struct(RustStruct),
135 Enum(RustEnum),
136 Operation(RustOperationBinding),
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140enum RustLawItem {
141 ScalarValidator(RustScalarValidator),
142 VariantValidator(RustVariantValidator),
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146struct RustTypeAlias {
147 name: String,
148 target: RustType,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152struct RustStruct {
153 name: String,
154 fields: Vec<RustField>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158struct RustField {
159 source_name: String,
160 rust_name: String,
161 ty: RustType,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165struct RustEnum {
166 name: String,
167 derive_eq: bool,
168 variants: Vec<RustVariant>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172struct RustOperationBinding {
173 operation_type: &'static str,
174 field_name: String,
175 request: RustStruct,
176 response_alias: RustTypeAlias,
177 directives_json: String,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
181struct RustVariant {
182 source_name: String,
183 rust_name: String,
184 payload: Option<RustType>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188struct RustScalarValidator {
189 function_name: String,
190 scalar_name: String,
191 min_inclusive: Option<u64>,
192 max_inclusive: Option<u64>,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196struct RustVariantValidator {
197 function_name: String,
198 input_type: String,
199 discriminator_field: String,
200 enum_type: String,
201 cases: Vec<RustVariantValidatorCase>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
205struct RustVariantValidatorCase {
206 enum_variant: String,
207 case_value: String,
208 requires: Vec<String>,
209 forbids: Vec<String>,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
213enum RustType {
214 String,
215 I32,
216 F64,
217 Bool,
218 Named(String),
219 Vec(Box<RustType>),
220 Option(Box<RustType>),
221}
222
223fn rust_law_items_from_ir(ir: &WesleyIR, law_ir: &LawIrV1) -> Vec<RustLawItem> {
224 law_ir
225 .entries
226 .iter()
227 .filter_map(|entry| match &entry.body {
228 LawEntryBodyV1::ScalarSemantics(body) => {
229 scalar_validator_from_law(&entry.subject, body)
230 }
231 LawEntryBodyV1::VariantLaw(body) => {
232 variant_validator_from_law(ir, &entry.subject, body)
233 }
234 LawEntryBodyV1::FootprintLaw(_)
235 | LawEntryBodyV1::ChannelLaw(_)
236 | LawEntryBodyV1::InvariantLaw(_) => None,
237 })
238 .collect()
239}
240
241fn scalar_validator_from_law(
242 subject: &str,
243 body: &wesley_core::ScalarSemanticsLawV1,
244) -> Option<RustLawItem> {
245 if body.representation != ScalarRepresentationV1::Integer {
246 return None;
247 }
248 let scalar_name = subject.strip_prefix("scalar:")?;
249
250 Some(RustLawItem::ScalarValidator(RustScalarValidator {
251 function_name: format!("validate_{}", rust_field_name(scalar_name)),
252 scalar_name: rust_type_name(scalar_name),
253 min_inclusive: body.min_inclusive,
254 max_inclusive: body.max_inclusive,
255 }))
256}
257
258fn variant_validator_from_law(
259 ir: &WesleyIR,
260 subject: &str,
261 body: &wesley_core::VariantLawV1,
262) -> Option<RustLawItem> {
263 let input_name = subject.strip_prefix("input:")?;
264 let input = ir.types.iter().find(|definition| {
265 definition.kind == TypeKind::InputObject && definition.name == input_name
266 })?;
267 let discriminator = input
268 .fields
269 .iter()
270 .find(|field| field.name == body.discriminator.field)?;
271 let cases = body
272 .cases
273 .iter()
274 .map(|case| RustVariantValidatorCase {
275 enum_variant: rust_variant_name(&case.value),
276 case_value: case.value.clone(),
277 requires: dynamic_variant_fields(input, &case.requires),
278 forbids: dynamic_variant_fields(input, &case.forbids),
279 })
280 .collect();
281
282 Some(RustLawItem::VariantValidator(RustVariantValidator {
283 function_name: format!("validate_{}_variant", rust_field_name(input_name)),
284 input_type: rust_type_name(input_name),
285 discriminator_field: rust_field_name(&discriminator.name),
286 enum_type: rust_type_name(&body.discriminator.r#enum),
287 cases,
288 }))
289}
290
291fn dynamic_variant_fields(input: &TypeDefinition, fields: &[String]) -> Vec<String> {
292 fields
293 .iter()
294 .filter_map(|field_name| {
295 let field = input
296 .fields
297 .iter()
298 .find(|field| field.name == *field_name)?;
299 if matches!(rust_type_from_reference(&field.r#type), RustType::Option(_)) {
300 Some(rust_field_name(field_name))
301 } else {
302 None
303 }
304 })
305 .collect()
306}
307
308fn struct_from_type(type_def: &TypeDefinition) -> RustStruct {
309 RustStruct {
310 name: rust_type_name(&type_def.name),
311 fields: type_def.fields.iter().map(field_from_ir).collect(),
312 }
313}
314
315fn field_from_ir(field: &Field) -> RustField {
316 RustField {
317 source_name: field.name.clone(),
318 rust_name: rust_field_name(&field.name),
319 ty: rust_type_from_reference(&field.r#type),
320 }
321}
322
323fn enum_from_type(type_def: &TypeDefinition) -> RustEnum {
324 RustEnum {
325 name: rust_type_name(&type_def.name),
326 derive_eq: true,
327 variants: type_def
328 .enum_values
329 .iter()
330 .map(|value| RustVariant {
331 source_name: value.clone(),
332 rust_name: rust_variant_name(value),
333 payload: None,
334 })
335 .collect(),
336 }
337}
338
339fn union_enum_from_type(type_def: &TypeDefinition) -> RustEnum {
340 RustEnum {
341 name: rust_type_name(&type_def.name),
342 derive_eq: false,
343 variants: type_def
344 .union_members
345 .iter()
346 .map(|member| RustVariant {
347 source_name: member.clone(),
348 rust_name: rust_variant_name(member),
349 payload: Some(RustType::Named(rust_type_name(member))),
350 })
351 .collect(),
352 }
353}
354
355fn operation_binding_from_schema(operation: &SchemaOperation) -> RustOperationBinding {
356 let request_type_name = operation_type_name(operation, "Request");
357 let response_type_name = operation_type_name(operation, "Response");
358
359 RustOperationBinding {
360 operation_type: operation_type_literal(operation.operation_type),
361 field_name: operation.field_name.clone(),
362 request: RustStruct {
363 name: request_type_name,
364 fields: operation
365 .arguments
366 .iter()
367 .map(field_from_operation_argument)
368 .collect(),
369 },
370 response_alias: RustTypeAlias {
371 name: response_type_name,
372 target: rust_type_from_reference(&operation.result_type),
373 },
374 directives_json: serde_json::to_string(&operation.directives)
375 .expect("schema operation directives should serialize"),
376 }
377}
378
379fn field_from_operation_argument(argument: &OperationArgument) -> RustField {
380 let mut ty = rust_type_from_reference(&argument.r#type);
381 if argument.default_value.is_some() && !matches!(ty, RustType::Option(_)) {
382 ty = RustType::Option(Box::new(ty));
383 }
384
385 RustField {
386 source_name: argument.name.clone(),
387 rust_name: rust_field_name(&argument.name),
388 ty,
389 }
390}
391
392fn rust_type_from_reference(type_ref: &TypeReference) -> RustType {
393 let base = match type_ref.base.as_str() {
394 "ID" | "String" => RustType::String,
395 "Int" => RustType::I32,
396 "Float" => RustType::F64,
397 "Boolean" => RustType::Bool,
398 name => RustType::Named(rust_type_name(name)),
399 };
400
401 if !type_ref.list_wrappers.is_empty() {
402 let mut ty = if type_ref.leaf_nullable.unwrap_or(true) {
403 RustType::Option(Box::new(base))
404 } else {
405 base
406 };
407
408 for wrapper in type_ref.list_wrappers.iter().rev() {
409 ty = RustType::Vec(Box::new(ty));
410 if wrapper.nullable {
411 ty = RustType::Option(Box::new(ty));
412 }
413 }
414
415 return ty;
416 }
417
418 let mut ty = if type_ref.is_list {
419 let item = match type_ref.list_item_nullable {
420 Some(true) | None => RustType::Option(Box::new(base)),
421 Some(false) => base,
422 };
423 RustType::Vec(Box::new(item))
424 } else {
425 base
426 };
427
428 if type_ref.nullable {
429 ty = RustType::Option(Box::new(ty));
430 }
431
432 ty
433}
434
435fn print_file(file: &RustFile) -> String {
436 let mut out = String::from("// @generated by Wesley. Do not edit.\n");
437
438 if let Some(provenance) = &file.provenance {
439 out.push('\n');
440 write!(out, "pub const WESLEY_SCHEMA_HASH: &'static str = ")
441 .expect("writing to string should not fail");
442 print_rust_string_literal(&mut out, &provenance.schema_hash);
443 out.push_str(";\n");
444 write!(out, "pub const WESLAW_HASH: &'static str = ")
445 .expect("writing to string should not fail");
446 print_rust_string_literal(&mut out, &provenance.law_hash);
447 out.push_str(";\n");
448 }
449
450 for item in &file.items {
451 out.push('\n');
452 print_item(&mut out, item);
453 }
454
455 for item in &file.law_items {
456 out.push('\n');
457 print_law_item(&mut out, item);
458 }
459
460 out
461}
462
463fn print_item(out: &mut String, item: &RustItem) {
464 match item {
465 RustItem::TypeAlias(alias) => print_type_alias(out, alias),
466 RustItem::Struct(struct_item) => print_struct(out, struct_item),
467 RustItem::Enum(enum_item) => print_enum(out, enum_item),
468 RustItem::Operation(operation) => print_operation_binding(out, operation),
469 }
470}
471
472fn print_law_item(out: &mut String, item: &RustLawItem) {
473 match item {
474 RustLawItem::ScalarValidator(validator) => print_scalar_validator(out, validator),
475 RustLawItem::VariantValidator(validator) => print_variant_validator(out, validator),
476 }
477}
478
479fn print_scalar_validator(out: &mut String, validator: &RustScalarValidator) {
480 writeln!(
481 out,
482 "pub fn {}(value: u64) -> Result<u64, &'static str> {{",
483 validator.function_name
484 )
485 .expect("writing to string should not fail");
486 if let Some(min) = validator.min_inclusive {
487 writeln!(
488 out,
489 " if value < {min} {{ return Err(\"{} below minInclusive {min}\"); }}",
490 validator.scalar_name
491 )
492 .expect("writing to string should not fail");
493 }
494 if let Some(max) = validator.max_inclusive {
495 writeln!(
496 out,
497 " if value > {max} {{ return Err(\"{} above maxInclusive {max}\"); }}",
498 validator.scalar_name
499 )
500 .expect("writing to string should not fail");
501 }
502 out.push_str(" Ok(value)\n");
503 out.push_str("}\n");
504}
505
506fn print_variant_validator(out: &mut String, validator: &RustVariantValidator) {
507 writeln!(
508 out,
509 "pub fn {}(value: &{}) -> Result<(), &'static str> {{",
510 validator.function_name, validator.input_type
511 )
512 .expect("writing to string should not fail");
513 writeln!(
514 out,
515 " match value.{}.clone() {{",
516 validator.discriminator_field
517 )
518 .expect("writing to string should not fail");
519 for case in &validator.cases {
520 writeln!(
521 out,
522 " {}::{} => {{",
523 validator.enum_type, case.enum_variant
524 )
525 .expect("writing to string should not fail");
526 for field in &case.requires {
527 writeln!(
528 out,
529 " if value.{field}.is_none() {{ return Err(\"{} requires {field}\"); }}",
530 case.case_value
531 )
532 .expect("writing to string should not fail");
533 }
534 for field in &case.forbids {
535 writeln!(
536 out,
537 " if value.{field}.is_some() {{ return Err(\"{} forbids {field}\"); }}",
538 case.case_value
539 )
540 .expect("writing to string should not fail");
541 }
542 out.push_str(" }\n");
543 }
544 out.push_str(" }\n");
545 out.push_str(" Ok(())\n");
546 out.push_str("}\n");
547}
548
549fn print_type_alias(out: &mut String, alias: &RustTypeAlias) {
550 write!(out, "pub type {} = ", alias.name).expect("writing to string should not fail");
551 print_type(out, &alias.target);
552 out.push_str(";\n");
553}
554
555fn print_struct(out: &mut String, struct_item: &RustStruct) {
556 out.push_str("#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]\n");
557 writeln!(out, "pub struct {} {{", struct_item.name).expect("writing to string should not fail");
558
559 for field in &struct_item.fields {
560 if field.source_name != field.rust_name.trim_start_matches("r#") {
561 writeln!(
562 out,
563 " #[serde(rename = \"{}\")]",
564 escape_attribute(&field.source_name)
565 )
566 .expect("writing to string should not fail");
567 }
568 write!(out, " pub {}: ", field.rust_name).expect("writing to string should not fail");
569 print_type(out, &field.ty);
570 out.push_str(",\n");
571 }
572
573 out.push_str("}\n");
574}
575
576fn print_enum(out: &mut String, enum_item: &RustEnum) {
577 if enum_item.derive_eq {
578 out.push_str(
579 "#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n",
580 );
581 } else {
582 out.push_str("#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]\n");
583 }
584 writeln!(out, "pub enum {} {{", enum_item.name).expect("writing to string should not fail");
585
586 for variant in &enum_item.variants {
587 writeln!(
588 out,
589 " #[serde(rename = \"{}\")]",
590 escape_attribute(&variant.source_name)
591 )
592 .expect("writing to string should not fail");
593 write!(out, " {}", variant.rust_name).expect("writing to string should not fail");
594 if let Some(payload) = &variant.payload {
595 out.push('(');
596 print_type(out, payload);
597 out.push(')');
598 }
599 out.push_str(",\n");
600 }
601
602 out.push_str("}\n");
603}
604
605fn print_operation_binding(out: &mut String, operation: &RustOperationBinding) {
606 print_struct(out, &operation.request);
607 out.push('\n');
608 print_type_alias(out, &operation.response_alias);
609 out.push('\n');
610 writeln!(out, "impl {} {{", operation.request.name).expect("writing to string should not fail");
611 write!(out, " pub const OPERATION_TYPE: &'static str = ")
612 .expect("writing to string should not fail");
613 print_rust_string_literal(out, operation.operation_type);
614 out.push_str(";\n");
615 write!(out, " pub const FIELD_NAME: &'static str = ")
616 .expect("writing to string should not fail");
617 print_rust_string_literal(out, &operation.field_name);
618 out.push_str(";\n");
619 write!(out, " pub const DIRECTIVES_JSON: &'static str = ")
620 .expect("writing to string should not fail");
621 print_rust_string_literal(out, &operation.directives_json);
622 out.push_str(";\n");
623 out.push_str("}\n");
624}
625
626fn print_type(out: &mut String, ty: &RustType) {
627 match ty {
628 RustType::String => out.push_str("String"),
629 RustType::I32 => out.push_str("i32"),
630 RustType::F64 => out.push_str("f64"),
631 RustType::Bool => out.push_str("bool"),
632 RustType::Named(name) => out.push_str(name),
633 RustType::Vec(item) => {
634 out.push_str("Vec<");
635 print_type(out, item);
636 out.push('>');
637 }
638 RustType::Option(item) => {
639 out.push_str("Option<");
640 print_type(out, item);
641 out.push('>');
642 }
643 }
644}
645
646fn operation_type_literal(operation_type: OperationType) -> &'static str {
647 match operation_type {
648 OperationType::Query => "QUERY",
649 OperationType::Mutation => "MUTATION",
650 OperationType::Subscription => "SUBSCRIPTION",
651 }
652}
653
654fn is_builtin_scalar(name: &str) -> bool {
655 matches!(name, "ID" | "String" | "Int" | "Float" | "Boolean")
656}
657
658fn rust_type_name(name: &str) -> String {
659 let mut candidate = sanitize_pascal_identifier(name);
660 if candidate.is_empty() {
661 candidate.push_str("GeneratedType");
662 }
663 if candidate
664 .chars()
665 .next()
666 .is_some_and(|ch| ch.is_ascii_digit())
667 {
668 candidate.insert(0, '_');
669 }
670 if is_reserved_word(&candidate) {
671 candidate.push_str("Type");
672 }
673
674 candidate
675}
676
677fn operation_type_name(operation: &SchemaOperation, suffix: &str) -> String {
678 rust_type_name(&format!(
679 "{}{}{suffix}",
680 operation_scope_name(operation.operation_type),
681 rust_type_name(&operation.field_name)
682 ))
683}
684
685fn operation_scope_name(operation_type: OperationType) -> &'static str {
686 match operation_type {
687 OperationType::Query => "Query",
688 OperationType::Mutation => "Mutation",
689 OperationType::Subscription => "Subscription",
690 }
691}
692
693fn rust_variant_name(name: &str) -> String {
694 let mut candidate = if name.contains('_') || name.chars().all(|ch| !ch.is_ascii_lowercase()) {
695 to_pascal_case(name)
696 } else {
697 sanitize_pascal_identifier(name)
698 };
699 if candidate.is_empty() {
700 candidate.push_str("GeneratedVariant");
701 }
702 if candidate
703 .chars()
704 .next()
705 .is_some_and(|ch| ch.is_ascii_digit())
706 {
707 candidate.insert(0, '_');
708 }
709 if is_reserved_word(&candidate) {
710 candidate.push_str("Variant");
711 }
712
713 candidate
714}
715
716fn rust_field_name(name: &str) -> String {
717 let mut candidate = to_snake_case(name);
718 if candidate.is_empty() {
719 candidate.push_str("generated_field");
720 }
721 if candidate
722 .chars()
723 .next()
724 .is_some_and(|ch| ch.is_ascii_digit())
725 {
726 candidate.insert(0, '_');
727 }
728 if is_reserved_word(&candidate) {
729 candidate.insert_str(0, "r#");
730 }
731
732 candidate
733}
734
735fn to_pascal_case(name: &str) -> String {
736 let mut out = String::new();
737 let mut uppercase_next = true;
738
739 for ch in name.chars() {
740 if ch.is_ascii_alphanumeric() {
741 if uppercase_next {
742 out.push(ch.to_ascii_uppercase());
743 uppercase_next = false;
744 } else {
745 out.push(ch.to_ascii_lowercase());
746 }
747 } else {
748 uppercase_next = true;
749 }
750 }
751
752 out
753}
754
755fn sanitize_pascal_identifier(name: &str) -> String {
756 let mut out = String::new();
757
758 for ch in name.chars() {
759 if ch.is_ascii_alphanumeric() || ch == '_' {
760 out.push(ch);
761 } else if !out.ends_with('_') {
762 out.push('_');
763 }
764 }
765
766 let mut chars = out.chars();
767 let Some(first) = chars.next() else {
768 return String::new();
769 };
770
771 if first.is_ascii_alphabetic() || first == '_' {
772 let mut sanitized = String::new();
773 sanitized.push(first.to_ascii_uppercase());
774 sanitized.extend(chars);
775 sanitized.trim_matches('_').to_string()
776 } else {
777 format!("_{}", out.trim_matches('_'))
778 }
779}
780
781fn to_snake_case(name: &str) -> String {
782 let mut out = String::new();
783
784 for (index, ch) in name.chars().enumerate() {
785 if ch.is_ascii_uppercase() {
786 if index > 0 && !out.ends_with('_') {
787 out.push('_');
788 }
789 out.push(ch.to_ascii_lowercase());
790 } else if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
791 out.push(ch);
792 } else if !out.ends_with('_') {
793 out.push('_');
794 }
795 }
796
797 out.trim_matches('_').to_string()
798}
799
800fn is_reserved_word(name: &str) -> bool {
801 matches!(
802 name,
803 "Self"
804 | "abstract"
805 | "as"
806 | "async"
807 | "await"
808 | "become"
809 | "box"
810 | "break"
811 | "const"
812 | "continue"
813 | "crate"
814 | "do"
815 | "dyn"
816 | "else"
817 | "enum"
818 | "extern"
819 | "false"
820 | "final"
821 | "fn"
822 | "for"
823 | "if"
824 | "impl"
825 | "in"
826 | "let"
827 | "loop"
828 | "macro"
829 | "match"
830 | "mod"
831 | "move"
832 | "mut"
833 | "override"
834 | "priv"
835 | "pub"
836 | "ref"
837 | "return"
838 | "self"
839 | "static"
840 | "struct"
841 | "super"
842 | "trait"
843 | "true"
844 | "try"
845 | "type"
846 | "typeof"
847 | "union"
848 | "unsafe"
849 | "unsized"
850 | "use"
851 | "virtual"
852 | "where"
853 | "while"
854 | "yield"
855 )
856}
857
858fn escape_attribute(value: &str) -> String {
859 value.replace('\\', "\\\\").replace('"', "\\\"")
860}
861
862fn print_rust_string_literal(out: &mut String, value: &str) {
863 out.push('"');
864 for ch in value.chars() {
865 match ch {
866 '\\' => out.push_str("\\\\"),
867 '"' => out.push_str("\\\""),
868 '\n' => out.push_str("\\n"),
869 '\r' => out.push_str("\\r"),
870 '\t' => out.push_str("\\t"),
871 _ => out.push(ch),
872 }
873 }
874 out.push('"');
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use pretty_assertions::assert_eq;
881 use wesley_core::{list_schema_operations_sdl, load_weslaw_yaml, lower_schema_sdl};
882
883 #[test]
884 fn emits_rust_models_from_l1_ir() {
885 let ir = lower_schema_sdl(
886 r#"
887 scalar DateTime
888
889 enum Role {
890 ADMIN
891 READ_ONLY
892 }
893
894 type User {
895 id: ID!
896 displayName: String
897 createdAt: DateTime!
898 tags: [String]
899 }
900
901 input UserFilter {
902 role: Role
903 limit: Int!
904 }
905 "#,
906 )
907 .expect("schema should lower");
908
909 let actual = emit_rust(&ir);
910
911 syn::parse_file(&actual).expect("generated Rust should parse");
912 assert_eq!(
913 actual,
914 r#"// @generated by Wesley. Do not edit.
915
916pub type DateTime = String;
917
918#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
919pub enum Role {
920 #[serde(rename = "ADMIN")]
921 Admin,
922 #[serde(rename = "READ_ONLY")]
923 ReadOnly,
924}
925
926#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
927pub struct User {
928 pub id: String,
929 #[serde(rename = "displayName")]
930 pub display_name: Option<String>,
931 #[serde(rename = "createdAt")]
932 pub created_at: DateTime,
933 pub tags: Option<Vec<Option<String>>>,
934}
935
936#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
937pub struct UserFilter {
938 pub role: Option<Role>,
939 pub limit: i32,
940}
941"#
942 );
943 }
944
945 #[test]
946 fn emits_nested_graphql_lists_as_nested_rust_vectors() {
947 let ir = lower_schema_sdl(
948 r#"
949 type Matrix {
950 values: [[Int!]!]!
951 maybeValues: [[String]]
952 }
953 "#,
954 )
955 .expect("schema should lower");
956
957 let actual = emit_rust(&ir);
958
959 syn::parse_file(&actual).expect("generated Rust should parse");
960 assert!(actual.contains("pub values: Vec<Vec<i32>>,"));
961 assert!(actual.contains(
962 "#[serde(rename = \"maybeValues\")]\n pub maybe_values: Option<Vec<Option<Vec<Option<String>>>>>,"
963 ));
964 }
965
966 #[test]
967 fn emits_jedit_shaped_hot_text_fixture() {
968 let ir = lower_schema_sdl(include_str!(
969 "../../../test/fixtures/consumer-models/jedit-hot-text-core.graphql"
970 ))
971 .expect("jedit-shaped fixture should lower");
972
973 let actual = emit_rust(&ir);
974
975 syn::parse_file(&actual).expect("generated Rust should parse");
976 assert!(actual.contains("pub struct BufferWorldline {"));
977 assert!(actual.contains("pub enum AnchorKind {"));
978 assert!(actual.contains("pub checkpoints: Vec<Checkpoint>,"));
979 assert!(actual.contains("pub created_at_tick_id: Option<String>,"));
980 assert!(actual.contains("pub create_initial_checkpoint: Option<bool>,"));
981 }
982
983 #[test]
984 fn emits_jedit_operation_bindings() {
985 let sdl = include_str!("../../../test/fixtures/consumer-models/jedit-rope.graphql");
986 let ir = lower_schema_sdl(sdl).expect("jedit runtime fixture should lower");
987 let operations =
988 list_schema_operations_sdl(sdl).expect("jedit runtime operations should resolve");
989
990 let actual = emit_rust_with_operations(&ir, &operations);
991
992 syn::parse_file(&actual).expect("generated Rust should parse");
993 assert!(!actual.contains("pub struct Mutation {"));
994 assert!(actual.contains("pub struct MutationCreateBufferWorldlineRequest {"));
995 assert!(actual.contains("pub input: CreateBufferWorldlineInput,"));
996 assert!(actual.contains(
997 "pub type MutationCreateBufferWorldlineResponse = CreateBufferWorldlineResult;"
998 ));
999 assert!(actual.contains("pub const OPERATION_TYPE: &'static str = \"MUTATION\";"));
1000 assert!(actual.contains("pub const FIELD_NAME: &'static str = \"createBufferWorldline\";"));
1001 assert!(actual.contains("pub const DIRECTIVES_JSON: &'static str = "));
1002 assert!(actual.contains("\\\"wes_footprint\\\""));
1003 }
1004
1005 #[test]
1006 fn emits_law_backed_scalar_and_variant_validators() {
1007 let sdl = include_str!("../../../test/fixtures/weslaw/contract-bundle-shape.graphql");
1008 let law_ir = load_weslaw_yaml(include_str!(
1009 "../../../test/fixtures/weslaw/accepted/rust-validator-payoff.weslaw.yaml"
1010 ))
1011 .expect("law fixture should lower");
1012 let ir = lower_schema_sdl(sdl).expect("schema should lower");
1013 let operations = list_schema_operations_sdl(sdl).expect("operations should resolve");
1014
1015 let actual = emit_rust_with_operations_and_law(
1016 &ir,
1017 &operations,
1018 "sha256:schema",
1019 "sha256:law",
1020 &law_ir,
1021 );
1022
1023 syn::parse_file(&actual).expect("generated Rust should parse");
1024 assert!(actual.contains("pub fn validate_positive_int(value: u64)"));
1025 assert!(actual.contains("if value < 1"));
1026 assert!(actual.contains("if value > 4294967295"));
1027 assert!(actual
1028 .contains("pub fn validate_playback_mode_input_variant(value: &PlaybackModeInput)"));
1029 assert!(actual.contains("PlaybackModeKind::Seek => {"));
1030 assert!(actual.contains("if value.target.is_none()"));
1031 assert!(actual.contains("if value.then.is_none()"));
1032 assert!(actual.contains("PlaybackModeKind::Paused => {"));
1033 assert!(actual.contains("if value.target.is_some()"));
1034 }
1035
1036 #[test]
1037 fn emits_stack_witness_0001_fixture_operation_bindings() {
1038 let sdl = include_str!(
1039 "../../../test/fixtures/consumer-models/stack-witness-0001-file-history.graphql"
1040 );
1041 let ir = lower_schema_sdl(sdl).expect("stack witness fixture should lower");
1042 let operations =
1043 list_schema_operations_sdl(sdl).expect("stack witness operations should resolve");
1044
1045 let actual = emit_rust_with_operations(&ir, &operations);
1046
1047 syn::parse_file(&actual).expect("generated Rust should parse");
1048 assert!(actual.contains("pub struct MutationCreateBufferRequest {"));
1049 assert!(actual.contains("pub input: CreateBufferInput,"));
1050 assert!(actual.contains("pub type MutationCreateBufferResponse = MutationReceipt;"));
1051 assert!(actual.contains("pub struct MutationReplaceRangeRequest {"));
1052 assert!(actual.contains("pub type MutationReplaceRangeResponse = MutationReceipt;"));
1053 assert!(actual.contains("pub struct QueryTextWindowRequest {"));
1054 assert!(actual.contains("pub type QueryTextWindowResponse = TextWindowReading;"));
1055 assert!(actual.contains("pub data_base64: String,"));
1056
1057 let create_buffer = rust_operation_impl_block(&actual, "MutationCreateBufferRequest");
1058 assert!(create_buffer.contains("pub const FIELD_NAME: &'static str = \"createBuffer\";"));
1059 assert!(create_buffer.contains("\\\"artifactId\\\":\\\"fixture-file-history-v0\\\""));
1060 assert!(create_buffer.contains("\\\"opId\\\":\\\"0x53570001\\\""));
1061 assert!(create_buffer.contains("\\\"helperKind\\\":\\\"EINT\\\""));
1062 assert!(create_buffer.contains("\\\"targetCodec\\\":\\\"wesley-binary/v0\\\""));
1063 assert!(create_buffer.contains(
1064 "\\\"fixtureVarsBytes\\\":\\\"stack-witness-0001/createBuffer;name=demo.txt;artifact=fixture-file-history-v0\\\""
1065 ));
1066
1067 let replace_range = rust_operation_impl_block(&actual, "MutationReplaceRangeRequest");
1068 assert!(replace_range.contains("pub const FIELD_NAME: &'static str = \"replaceRange\";"));
1069 assert!(replace_range.contains("\\\"artifactId\\\":\\\"fixture-file-history-v0\\\""));
1070 assert!(replace_range.contains("\\\"opId\\\":\\\"0x53570002\\\""));
1071 assert!(replace_range.contains("\\\"helperKind\\\":\\\"EINT\\\""));
1072 assert!(replace_range.contains("\\\"targetCodec\\\":\\\"wesley-binary/v0\\\""));
1073 assert!(replace_range.contains(
1074 "\\\"fixtureVarsBytes\\\":\\\"stack-witness-0001/replaceRange;bufferId=demo.txt;basis=B0;coord=utf8-bytes;start=0;end=0;text=hello;artifact=fixture-file-history-v0\\\""
1075 ));
1076
1077 let text_window = rust_operation_impl_block(&actual, "QueryTextWindowRequest");
1078 assert!(text_window.contains("pub const FIELD_NAME: &'static str = \"textWindow\";"));
1079 assert!(text_window.contains("\\\"artifactId\\\":\\\"fixture-file-history-v0\\\""));
1080 assert!(text_window.contains("\\\"opId\\\":\\\"0x53571001\\\""));
1081 assert!(text_window.contains("\\\"helperKind\\\":\\\"QueryView\\\""));
1082 assert!(text_window.contains("\\\"targetCodec\\\":\\\"wesley-binary/v0\\\""));
1083 assert!(text_window.contains(
1084 "\\\"fixtureVarsBytes\\\":\\\"stack-witness-0001/textWindow;bufferId=demo.txt;basis=B1;coord=utf8-bytes;start=0;length=5;artifact=fixture-file-history-v0\\\""
1085 ));
1086 assert!(text_window.contains("\\\"payloadCodec\\\":\\\"QueryBytes\\\""));
1087 assert!(text_window.contains("\\\"envelope\\\":\\\"ReadingEnvelope\\\""));
1088 }
1089
1090 #[test]
1091 fn emits_domain_empty_fixture_without_product_nouns() {
1092 let sdl = include_str!("../../../test/fixtures/rust-emitter/domain-empty.graphql");
1093 let ir = lower_schema_sdl(sdl).expect("domain-empty fixture should lower");
1094 let operations = list_schema_operations_sdl(sdl)
1095 .expect("domain-empty fixture operations should resolve");
1096
1097 let actual = emit_rust_with_operations(&ir, &operations);
1098
1099 syn::parse_file(&actual).expect("generated Rust should parse");
1100 assert!(actual.contains("pub struct ContractRecord {"));
1101 assert!(actual.contains("pub struct MutationPublishContractRequest {"));
1102 assert!(actual.contains("pub type QueryContractResponse = Option<ContractRecord>;"));
1103 for forbidden in ["Postgres", "Supabase", "Echo", "jedit", "Jedit"] {
1104 assert!(
1105 !actual.contains(forbidden),
1106 "generic Rust output leaked product noun `{forbidden}`"
1107 );
1108 }
1109 }
1110
1111 #[test]
1112 fn operation_bindings_include_operation_scope_in_type_names() {
1113 let sdl = r#"
1114 type Query {
1115 status: Status!
1116 }
1117
1118 type Mutation {
1119 status(input: StatusInput!): Status!
1120 }
1121
1122 type Status {
1123 ok: Boolean!
1124 }
1125
1126 input StatusInput {
1127 reason: String
1128 }
1129 "#;
1130 let ir = lower_schema_sdl(sdl).expect("schema should lower");
1131 let operations = list_schema_operations_sdl(sdl).expect("operations should resolve");
1132
1133 let actual = emit_rust_with_operations(&ir, &operations);
1134
1135 syn::parse_file(&actual).expect("generated Rust should parse");
1136 assert!(actual.contains("pub struct QueryStatusRequest {"));
1137 assert!(actual.contains("pub type QueryStatusResponse = Status;"));
1138 assert!(actual.contains("pub struct MutationStatusRequest {"));
1139 assert!(actual.contains("pub type MutationStatusResponse = Status;"));
1140 }
1141
1142 #[test]
1143 fn sanitizes_reserved_field_names() {
1144 assert_eq!(rust_field_name("type"), "r#type");
1145 assert_eq!(rust_field_name("displayName"), "display_name");
1146 }
1147
1148 #[test]
1149 fn union_payload_enums_do_not_derive_eq() {
1150 let ir = lower_schema_sdl(
1151 r#"
1152 type User {
1153 id: ID!
1154 }
1155
1156 union SearchResult = User
1157 "#,
1158 )
1159 .expect("schema should lower");
1160
1161 let actual = emit_rust(&ir);
1162
1163 syn::parse_file(&actual).expect("generated Rust should parse");
1164 assert!(actual.contains(
1165 "#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]\npub enum SearchResult"
1166 ));
1167 }
1168
1169 fn rust_operation_impl_block<'a>(actual: &'a str, request_name: &str) -> &'a str {
1170 let marker = format!("impl {request_name} {{");
1171 let start = actual
1172 .find(&marker)
1173 .expect("operation impl block should exist");
1174 let tail = &actual[start..];
1175 let end = tail
1176 .find("\n}\n")
1177 .expect("operation impl block should close")
1178 + "\n}\n".len();
1179 &tail[..end]
1180 }
1181}