1#![deny(warnings)]
2#![deny(missing_docs)]
3
4use std::collections::BTreeSet;
7use std::fmt::Write;
8use wesley_core::{
9 Field, OperationArgument, OperationType, SchemaOperation, TypeDefinition, TypeKind,
10 TypeReference, WesleyIR,
11};
12
13pub fn emit_typescript(ir: &WesleyIR) -> String {
15 let program = TsProgram::from_ir(ir);
16
17 print_program(&program)
18}
19
20pub fn emit_typescript_with_operations(ir: &WesleyIR, operations: &[SchemaOperation]) -> String {
22 let program = TsProgram::from_ir_and_operations(ir, operations);
23
24 print_program(&program)
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28struct TsProgram {
29 declarations: Vec<TsDeclaration>,
30}
31
32impl TsProgram {
33 fn from_ir(ir: &WesleyIR) -> Self {
34 Self::from_ir_and_operations(ir, &[])
35 }
36
37 fn from_ir_and_operations(ir: &WesleyIR, operations: &[SchemaOperation]) -> Self {
38 let mut declarations = Vec::new();
39 let root_type_names = operations
40 .iter()
41 .map(|operation| operation.root_type_name.as_str())
42 .collect::<BTreeSet<_>>();
43
44 for type_def in &ir.types {
45 if root_type_names.contains(type_def.name.as_str()) {
46 continue;
47 }
48
49 match type_def.kind {
50 TypeKind::Scalar if is_builtin_scalar(&type_def.name) => {}
51 TypeKind::Scalar => declarations.push(TsDeclaration::TypeAlias(TsTypeAlias {
52 name: ts_type_name(&type_def.name),
53 type_expr: TsTypeExpr::Unknown,
54 })),
55 TypeKind::Object | TypeKind::Interface | TypeKind::InputObject => {
56 declarations.push(TsDeclaration::Interface(interface_from_type(type_def)));
57 }
58 TypeKind::Enum => declarations.push(TsDeclaration::TypeAlias(TsTypeAlias {
59 name: ts_type_name(&type_def.name),
60 type_expr: string_literal_union(&type_def.enum_values),
61 })),
62 TypeKind::Union => declarations.push(TsDeclaration::TypeAlias(TsTypeAlias {
63 name: ts_type_name(&type_def.name),
64 type_expr: type_reference_union(&type_def.union_members),
65 })),
66 }
67 }
68
69 declarations.extend(
70 operations.iter().map(|operation| {
71 TsDeclaration::Operation(operation_binding_from_schema(operation))
72 }),
73 );
74
75 Self { declarations }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80enum TsDeclaration {
81 Interface(TsInterface),
82 TypeAlias(TsTypeAlias),
83 Operation(TsOperationBinding),
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87struct TsInterface {
88 name: String,
89 extends: Vec<String>,
90 properties: Vec<TsProperty>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94struct TsTypeAlias {
95 name: String,
96 type_expr: TsTypeExpr,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100struct TsOperationBinding {
101 operation_type: &'static str,
102 field_name: String,
103 const_name: String,
104 request: TsInterface,
105 response_alias: TsTypeAlias,
106 operation_alias: TsTypeAlias,
107 directives_json: String,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111struct TsProperty {
112 name: String,
113 optional: bool,
114 type_expr: TsTypeExpr,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118struct TsObjectMember {
119 name: String,
120 readonly: bool,
121 type_expr: TsTypeExpr,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125enum TsTypeExpr {
126 String,
127 Number,
128 Boolean,
129 Unknown,
130 Never,
131 Null,
132 Reference(String),
133 Typeof(String),
134 StringLiteral(String),
135 Object(Vec<TsObjectMember>),
136 Array(Box<TsTypeExpr>),
137 Union(Vec<TsTypeExpr>),
138}
139
140impl TsTypeExpr {
141 fn union(types: Vec<Self>) -> Self {
142 let mut unique = Vec::new();
143
144 for type_expr in types {
145 if !unique.contains(&type_expr) {
146 unique.push(type_expr);
147 }
148 }
149
150 match unique.len() {
151 0 => Self::Never,
152 1 => unique.into_iter().next().expect("union has one item"),
153 _ => Self::Union(unique),
154 }
155 }
156}
157
158fn interface_from_type(type_def: &TypeDefinition) -> TsInterface {
159 let is_input = type_def.kind == TypeKind::InputObject;
160
161 TsInterface {
162 name: ts_type_name(&type_def.name),
163 extends: if is_input {
164 Vec::new()
165 } else {
166 type_def
167 .implements
168 .iter()
169 .map(|interface| ts_type_name(interface))
170 .collect()
171 },
172 properties: type_def
173 .fields
174 .iter()
175 .map(|field| property_from_field(field, is_input))
176 .collect(),
177 }
178}
179
180fn property_from_field(field: &Field, is_input: bool) -> TsProperty {
181 TsProperty {
182 name: field.name.clone(),
183 optional: is_input && field.r#type.nullable,
184 type_expr: type_expr_from_reference(&field.r#type),
185 }
186}
187
188fn operation_binding_from_schema(operation: &SchemaOperation) -> TsOperationBinding {
189 let request_type_name = operation_type_name(operation, "Request");
190 let response_type_name = operation_type_name(operation, "Response");
191 let const_name = operation_const_name(operation);
192
193 TsOperationBinding {
194 operation_type: operation_type_literal(operation.operation_type),
195 field_name: operation.field_name.clone(),
196 const_name: const_name.clone(),
197 request: TsInterface {
198 name: request_type_name.clone(),
199 extends: Vec::new(),
200 properties: operation
201 .arguments
202 .iter()
203 .map(property_from_operation_argument)
204 .collect(),
205 },
206 response_alias: TsTypeAlias {
207 name: response_type_name.clone(),
208 type_expr: type_expr_from_reference(&operation.result_type),
209 },
210 operation_alias: TsTypeAlias {
211 name: operation_type_name(operation, "Operation"),
212 type_expr: TsTypeExpr::Object(vec![
213 TsObjectMember {
214 name: "request".to_string(),
215 readonly: false,
216 type_expr: TsTypeExpr::Reference(request_type_name),
217 },
218 TsObjectMember {
219 name: "response".to_string(),
220 readonly: false,
221 type_expr: TsTypeExpr::Reference(response_type_name),
222 },
223 TsObjectMember {
224 name: "metadata".to_string(),
225 readonly: false,
226 type_expr: TsTypeExpr::Typeof(const_name),
227 },
228 ]),
229 },
230 directives_json: serde_json::to_string(&operation.directives)
231 .expect("schema operation directives should serialize"),
232 }
233}
234
235fn property_from_operation_argument(argument: &OperationArgument) -> TsProperty {
236 TsProperty {
237 name: argument.name.clone(),
238 optional: argument.r#type.nullable || argument.default_value.is_some(),
239 type_expr: type_expr_from_reference(&argument.r#type),
240 }
241}
242
243fn type_expr_from_reference(type_ref: &TypeReference) -> TsTypeExpr {
244 let base = match type_ref.base.as_str() {
245 "ID" | "String" => TsTypeExpr::String,
246 "Int" | "Float" => TsTypeExpr::Number,
247 "Boolean" => TsTypeExpr::Boolean,
248 name => TsTypeExpr::Reference(ts_type_name(name)),
249 };
250
251 if !type_ref.list_wrappers.is_empty() {
252 let mut type_expr = if type_ref.leaf_nullable.unwrap_or(true) {
253 TsTypeExpr::union(vec![base, TsTypeExpr::Null])
254 } else {
255 base
256 };
257
258 for wrapper in type_ref.list_wrappers.iter().rev() {
259 type_expr = TsTypeExpr::Array(Box::new(type_expr));
260 if wrapper.nullable {
261 type_expr = TsTypeExpr::union(vec![type_expr, TsTypeExpr::Null]);
262 }
263 }
264
265 return type_expr;
266 }
267
268 let mut type_expr = if type_ref.is_list {
269 let item = match type_ref.list_item_nullable {
270 Some(true) | None => TsTypeExpr::union(vec![base, TsTypeExpr::Null]),
271 Some(false) => base,
272 };
273 TsTypeExpr::Array(Box::new(item))
274 } else {
275 base
276 };
277
278 if type_ref.nullable {
279 type_expr = TsTypeExpr::union(vec![type_expr, TsTypeExpr::Null]);
280 }
281
282 type_expr
283}
284
285fn string_literal_union(values: &[String]) -> TsTypeExpr {
286 TsTypeExpr::union(
287 values
288 .iter()
289 .map(|value| TsTypeExpr::StringLiteral(value.clone()))
290 .collect(),
291 )
292}
293
294fn type_reference_union(values: &[String]) -> TsTypeExpr {
295 TsTypeExpr::union(
296 values
297 .iter()
298 .map(|value| TsTypeExpr::Reference(ts_type_name(value)))
299 .collect(),
300 )
301}
302
303fn is_builtin_scalar(name: &str) -> bool {
304 matches!(name, "ID" | "String" | "Int" | "Float" | "Boolean")
305}
306
307fn print_program(program: &TsProgram) -> String {
308 let mut out = String::from("/* @generated by Wesley. Do not edit. */\n");
309
310 for declaration in &program.declarations {
311 out.push('\n');
312 print_declaration(&mut out, declaration);
313 }
314
315 out
316}
317
318fn print_declaration(out: &mut String, declaration: &TsDeclaration) {
319 match declaration {
320 TsDeclaration::Interface(interface) => print_interface(out, interface),
321 TsDeclaration::TypeAlias(type_alias) => print_type_alias(out, type_alias),
322 TsDeclaration::Operation(operation) => print_operation_binding(out, operation),
323 }
324}
325
326fn print_interface(out: &mut String, interface: &TsInterface) {
327 let extends = if interface.extends.is_empty() {
328 String::new()
329 } else {
330 format!(" extends {}", interface.extends.join(", "))
331 };
332
333 writeln!(out, "export interface {}{} {{", interface.name, extends)
334 .expect("writing to string should not fail");
335
336 for property in &interface.properties {
337 write!(out, " {}", property_name(&property.name))
338 .expect("writing to string should not fail");
339 if property.optional {
340 out.push('?');
341 }
342 out.push_str(": ");
343 print_type_expr(out, &property.type_expr, false);
344 out.push_str(";\n");
345 }
346
347 out.push_str("}\n");
348}
349
350fn print_type_alias(out: &mut String, type_alias: &TsTypeAlias) {
351 write!(out, "export type {} = ", type_alias.name).expect("writing to string should not fail");
352 print_type_expr(out, &type_alias.type_expr, false);
353 out.push_str(";\n");
354}
355
356fn print_operation_binding(out: &mut String, operation: &TsOperationBinding) {
357 print_interface(out, &operation.request);
358 out.push('\n');
359 print_type_alias(out, &operation.response_alias);
360 out.push('\n');
361 writeln!(out, "export const {} = {{", operation.const_name)
362 .expect("writing to string should not fail");
363 write!(out, " operationType: ").expect("writing to string should not fail");
364 print_string_literal(out, operation.operation_type);
365 out.push_str(",\n");
366 write!(out, " fieldName: ").expect("writing to string should not fail");
367 print_string_literal(out, &operation.field_name);
368 out.push_str(",\n");
369 out.push_str(" directives: ");
370 out.push_str(&operation.directives_json);
371 out.push_str(",\n");
372 out.push_str("} as const;\n\n");
373 print_type_alias(out, &operation.operation_alias);
374}
375
376fn print_type_expr(out: &mut String, type_expr: &TsTypeExpr, parenthesize_union: bool) {
377 match type_expr {
378 TsTypeExpr::String => out.push_str("string"),
379 TsTypeExpr::Number => out.push_str("number"),
380 TsTypeExpr::Boolean => out.push_str("boolean"),
381 TsTypeExpr::Unknown => out.push_str("unknown"),
382 TsTypeExpr::Never => out.push_str("never"),
383 TsTypeExpr::Null => out.push_str("null"),
384 TsTypeExpr::Reference(name) => out.push_str(name),
385 TsTypeExpr::Typeof(name) => {
386 out.push_str("typeof ");
387 out.push_str(name);
388 }
389 TsTypeExpr::StringLiteral(value) => print_string_literal(out, value),
390 TsTypeExpr::Object(members) => print_object_type_expr(out, members),
391 TsTypeExpr::Array(item) => {
392 print_type_expr(out, item, true);
393 out.push_str("[]");
394 }
395 TsTypeExpr::Union(types) => {
396 if parenthesize_union {
397 out.push('(');
398 }
399 for (index, nested) in types.iter().enumerate() {
400 if index > 0 {
401 out.push_str(" | ");
402 }
403 print_type_expr(out, nested, false);
404 }
405 if parenthesize_union {
406 out.push(')');
407 }
408 }
409 }
410}
411
412fn print_object_type_expr(out: &mut String, members: &[TsObjectMember]) {
413 if members.is_empty() {
414 out.push_str("{}");
415 return;
416 }
417
418 out.push_str("{\n");
419 for member in members {
420 out.push_str(" ");
421 if member.readonly {
422 out.push_str("readonly ");
423 }
424 out.push_str(&property_name(&member.name));
425 out.push_str(": ");
426 print_type_expr(out, &member.type_expr, false);
427 out.push_str(";\n");
428 }
429 out.push('}');
430}
431
432fn print_string_literal(out: &mut String, value: &str) {
433 out.push('"');
434 for ch in value.chars() {
435 match ch {
436 '\\' => out.push_str("\\\\"),
437 '"' => out.push_str("\\\""),
438 '\n' => out.push_str("\\n"),
439 '\r' => out.push_str("\\r"),
440 '\t' => out.push_str("\\t"),
441 _ => out.push(ch),
442 }
443 }
444 out.push('"');
445}
446
447fn property_name(name: &str) -> String {
448 if is_identifier_name(name) {
449 name.to_string()
450 } else {
451 let mut out = String::new();
452 print_string_literal(&mut out, name);
453 out
454 }
455}
456
457fn ts_type_name(name: &str) -> String {
458 if is_identifier_name(name) && !is_reserved_type_name(name) {
459 return name.to_string();
460 }
461
462 let mut sanitized = String::from("_");
463 for (index, ch) in name.chars().enumerate() {
464 if (index == 0 && is_identifier_start(ch)) || (index > 0 && is_identifier_continue(ch)) {
465 sanitized.push(ch);
466 } else {
467 sanitized.push('_');
468 }
469 }
470
471 sanitized
472}
473
474fn operation_type_name(operation: &SchemaOperation, suffix: &str) -> String {
475 ts_type_name(&format!(
476 "{}{}{suffix}",
477 operation_scope_type_name(operation.operation_type),
478 upper_first(&operation.field_name)
479 ))
480}
481
482fn operation_const_name(operation: &SchemaOperation) -> String {
483 let candidate = format!(
484 "{}{}Operation",
485 operation_scope_const_prefix(operation.operation_type),
486 upper_first(&operation.field_name)
487 );
488 if is_identifier_name(&candidate) && !is_reserved_type_name(&candidate) {
489 return candidate;
490 }
491
492 ts_type_name(&candidate)
493}
494
495fn operation_scope_type_name(operation_type: OperationType) -> &'static str {
496 match operation_type {
497 OperationType::Query => "Query",
498 OperationType::Mutation => "Mutation",
499 OperationType::Subscription => "Subscription",
500 }
501}
502
503fn operation_scope_const_prefix(operation_type: OperationType) -> &'static str {
504 match operation_type {
505 OperationType::Query => "query",
506 OperationType::Mutation => "mutation",
507 OperationType::Subscription => "subscription",
508 }
509}
510
511fn upper_first(value: &str) -> String {
512 let mut chars = value.chars();
513 let Some(first) = chars.next() else {
514 return String::new();
515 };
516
517 let mut out = String::new();
518 out.push(first.to_ascii_uppercase());
519 out.extend(chars);
520 out
521}
522
523fn operation_type_literal(operation_type: OperationType) -> &'static str {
524 match operation_type {
525 OperationType::Query => "QUERY",
526 OperationType::Mutation => "MUTATION",
527 OperationType::Subscription => "SUBSCRIPTION",
528 }
529}
530
531fn is_identifier_name(name: &str) -> bool {
532 let mut chars = name.chars();
533 let Some(first) = chars.next() else {
534 return false;
535 };
536
537 is_identifier_start(first) && chars.all(is_identifier_continue)
538}
539
540fn is_identifier_start(ch: char) -> bool {
541 ch == '_' || ch == '$' || ch.is_ascii_alphabetic()
542}
543
544fn is_identifier_continue(ch: char) -> bool {
545 is_identifier_start(ch) || ch.is_ascii_digit()
546}
547
548fn is_reserved_type_name(name: &str) -> bool {
549 matches!(
550 name,
551 "any"
552 | "as"
553 | "boolean"
554 | "break"
555 | "case"
556 | "catch"
557 | "class"
558 | "const"
559 | "continue"
560 | "debugger"
561 | "declare"
562 | "default"
563 | "delete"
564 | "do"
565 | "else"
566 | "enum"
567 | "export"
568 | "extends"
569 | "false"
570 | "finally"
571 | "for"
572 | "from"
573 | "function"
574 | "if"
575 | "implements"
576 | "import"
577 | "in"
578 | "infer"
579 | "instanceof"
580 | "interface"
581 | "keyof"
582 | "let"
583 | "module"
584 | "namespace"
585 | "never"
586 | "new"
587 | "null"
588 | "number"
589 | "object"
590 | "package"
591 | "private"
592 | "protected"
593 | "public"
594 | "readonly"
595 | "require"
596 | "return"
597 | "satisfies"
598 | "static"
599 | "string"
600 | "super"
601 | "switch"
602 | "symbol"
603 | "this"
604 | "throw"
605 | "true"
606 | "try"
607 | "type"
608 | "typeof"
609 | "undefined"
610 | "unique"
611 | "unknown"
612 | "var"
613 | "void"
614 | "while"
615 | "with"
616 | "yield"
617 )
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623 use pretty_assertions::assert_eq;
624 use wesley_core::{list_schema_operations_sdl, lower_schema_sdl};
625
626 #[test]
627 fn emits_typescript_declarations_from_l1_ir() {
628 let ir = lower_schema_sdl(
629 r#"
630 scalar DateTime
631
632 interface Node {
633 id: ID!
634 }
635
636 type User implements Node {
637 id: ID!
638 name: String
639 createdAt: DateTime!
640 tags: [String]
641 }
642
643 union SearchResult = User
644
645 enum Role {
646 ADMIN
647 MEMBER
648 }
649
650 input UserFilter {
651 ids: [ID!]!
652 role: Role
653 active: Boolean
654 }
655 "#,
656 )
657 .expect("schema should lower");
658
659 let actual = emit_typescript(&ir);
660
661 assert_eq!(
662 actual,
663 r#"/* @generated by Wesley. Do not edit. */
664
665export type DateTime = unknown;
666
667export interface Node {
668 id: string;
669}
670
671export type Role = "ADMIN" | "MEMBER";
672
673export type SearchResult = User;
674
675export interface User extends Node {
676 id: string;
677 name: string | null;
678 createdAt: DateTime;
679 tags: (string | null)[] | null;
680}
681
682export interface UserFilter {
683 ids: string[];
684 role?: Role | null;
685 active?: boolean | null;
686}
687"#
688 );
689 }
690
691 #[test]
692 fn quotes_non_identifier_property_names_in_the_ast_printer() {
693 assert_eq!(property_name("normalName"), "normalName");
694 assert_eq!(property_name("not-normal"), "\"not-normal\"");
695 }
696
697 #[test]
698 fn emits_nested_graphql_lists_as_nested_typescript_arrays() {
699 let ir = lower_schema_sdl(
700 r#"
701 type Matrix {
702 values: [[Int!]!]!
703 maybeValues: [[String]]
704 }
705 "#,
706 )
707 .expect("schema should lower");
708
709 let actual = emit_typescript(&ir);
710
711 assert!(actual.contains(" values: number[][];"));
712 assert!(actual.contains(" maybeValues: ((string | null)[] | null)[] | null;"));
713 }
714
715 #[test]
716 fn emits_jedit_shaped_hot_text_fixture() {
717 let ir = lower_schema_sdl(include_str!(
718 "../../../test/fixtures/consumer-models/jedit-hot-text-core.graphql"
719 ))
720 .expect("jedit-shaped fixture should lower");
721
722 let actual = emit_typescript(&ir);
723
724 assert!(actual.contains("export interface BufferWorldline {"));
725 assert!(actual.contains("export type AnchorKind = "));
726 assert!(actual.contains("checkpoints: Checkpoint[];"));
727 assert!(actual.contains("createdAtTickId: string | null;"));
728 assert!(actual.contains("createInitialCheckpoint?: boolean | null;"));
729 }
730
731 #[test]
732 fn emits_jedit_operation_bindings() {
733 let sdl =
734 include_str!("../../../test/fixtures/consumer-models/jedit-hot-text-runtime.graphql");
735 let ir = lower_schema_sdl(sdl).expect("jedit runtime fixture should lower");
736 let operations =
737 list_schema_operations_sdl(sdl).expect("jedit runtime operations should resolve");
738
739 let actual = emit_typescript_with_operations(&ir, &operations);
740
741 assert!(!actual.contains("export interface Mutation {"));
742 assert!(actual.contains("export interface MutationCreateBufferWorldlineRequest {"));
743 assert!(actual.contains(" input: CreateBufferWorldlineInput;"));
744 assert!(actual.contains(
745 "export type MutationCreateBufferWorldlineResponse = CreateBufferWorldlineResult;"
746 ));
747 assert!(actual.contains("export const mutationCreateBufferWorldlineOperation = {"));
748 assert!(actual.contains(" operationType: \"MUTATION\","));
749 assert!(actual.contains(" fieldName: \"createBufferWorldline\","));
750 assert!(actual.contains("\"wes_footprint\""));
751 assert!(actual.contains("export type MutationCreateBufferWorldlineOperation = {\n"));
752 assert!(actual.contains(" metadata: typeof mutationCreateBufferWorldlineOperation;"));
753 }
754
755 #[test]
756 fn emits_stack_witness_0001_fixture_operation_bindings() {
757 let sdl = include_str!(
758 "../../../test/fixtures/consumer-models/stack-witness-0001-file-history.graphql"
759 );
760 let ir = lower_schema_sdl(sdl).expect("stack witness fixture should lower");
761 let operations =
762 list_schema_operations_sdl(sdl).expect("stack witness operations should resolve");
763
764 let actual = emit_typescript_with_operations(&ir, &operations);
765
766 assert!(actual.contains("export interface MutationCreateBufferRequest {"));
767 assert!(actual.contains(" input: CreateBufferInput;"));
768 assert!(actual.contains("export type MutationCreateBufferResponse = MutationReceipt;"));
769 assert!(actual.contains("export const mutationCreateBufferOperation = {"));
770 assert!(actual.contains("export interface MutationReplaceRangeRequest {"));
771 assert!(actual.contains("export type MutationReplaceRangeResponse = MutationReceipt;"));
772 assert!(actual.contains("export const mutationReplaceRangeOperation = {"));
773 assert!(actual.contains("export interface QueryTextWindowRequest {"));
774 assert!(actual.contains("export type QueryTextWindowResponse = TextWindowReading;"));
775 assert!(actual.contains("export const queryTextWindowOperation = {"));
776 assert!(actual.contains("dataBase64: string;"));
777
778 let create_buffer = ts_operation_metadata_block(&actual, "mutationCreateBufferOperation");
779 assert!(create_buffer.contains(" fieldName: \"createBuffer\","));
780 assert!(create_buffer.contains("\"artifactId\":\"fixture-file-history-v0\""));
781 assert!(create_buffer.contains("\"opId\":\"0x53570001\""));
782 assert!(create_buffer.contains("\"helperKind\":\"EINT\""));
783 assert!(create_buffer.contains("\"targetCodec\":\"wesley-binary/v0\""));
784 assert!(create_buffer.contains(
785 "\"fixtureVarsBytes\":\"stack-witness-0001/createBuffer;name=demo.txt;artifact=fixture-file-history-v0\""
786 ));
787
788 let replace_range = ts_operation_metadata_block(&actual, "mutationReplaceRangeOperation");
789 assert!(replace_range.contains(" fieldName: \"replaceRange\","));
790 assert!(replace_range.contains("\"artifactId\":\"fixture-file-history-v0\""));
791 assert!(replace_range.contains("\"opId\":\"0x53570002\""));
792 assert!(replace_range.contains("\"helperKind\":\"EINT\""));
793 assert!(replace_range.contains("\"targetCodec\":\"wesley-binary/v0\""));
794 assert!(replace_range.contains(
795 "\"fixtureVarsBytes\":\"stack-witness-0001/replaceRange;bufferId=demo.txt;basis=B0;coord=utf8-bytes;start=0;end=0;text=hello;artifact=fixture-file-history-v0\""
796 ));
797
798 let text_window = ts_operation_metadata_block(&actual, "queryTextWindowOperation");
799 assert!(text_window.contains(" fieldName: \"textWindow\","));
800 assert!(text_window.contains("\"artifactId\":\"fixture-file-history-v0\""));
801 assert!(text_window.contains("\"opId\":\"0x53571001\""));
802 assert!(text_window.contains("\"helperKind\":\"QueryView\""));
803 assert!(text_window.contains("\"targetCodec\":\"wesley-binary/v0\""));
804 assert!(text_window.contains(
805 "\"fixtureVarsBytes\":\"stack-witness-0001/textWindow;bufferId=demo.txt;basis=B1;coord=utf8-bytes;start=0;length=5;artifact=fixture-file-history-v0\""
806 ));
807 assert!(text_window.contains("\"payloadCodec\":\"QueryBytes\""));
808 assert!(text_window.contains("\"envelope\":\"ReadingEnvelope\""));
809 assert!(actual.contains("export type QueryTextWindowOperation = {\n"));
810 assert!(actual.contains(" metadata: typeof queryTextWindowOperation;"));
811 }
812
813 #[test]
814 fn operation_bindings_include_operation_scope_in_symbol_names() {
815 let sdl = r#"
816 type Query {
817 status: Status!
818 }
819
820 type Mutation {
821 status(input: StatusInput!): Status!
822 }
823
824 type Status {
825 ok: Boolean!
826 }
827
828 input StatusInput {
829 reason: String
830 }
831 "#;
832 let ir = lower_schema_sdl(sdl).expect("schema should lower");
833 let operations = list_schema_operations_sdl(sdl).expect("operations should resolve");
834
835 let actual = emit_typescript_with_operations(&ir, &operations);
836
837 assert!(actual.contains("export interface QueryStatusRequest {"));
838 assert!(actual.contains("export type QueryStatusResponse = Status;"));
839 assert!(actual.contains("export const queryStatusOperation = {"));
840 assert!(actual.contains("export type QueryStatusOperation = {\n"));
841 assert!(actual.contains(" metadata: typeof queryStatusOperation;"));
842 assert!(actual.contains("export interface MutationStatusRequest {"));
843 assert!(actual.contains("export type MutationStatusResponse = Status;"));
844 assert!(actual.contains("export const mutationStatusOperation = {"));
845 assert!(actual.contains("export type MutationStatusOperation = {\n"));
846 assert!(actual.contains(" metadata: typeof mutationStatusOperation;"));
847 }
848
849 fn ts_operation_metadata_block<'a>(actual: &'a str, const_name: &str) -> &'a str {
850 let marker = format!("export const {const_name} = {{");
851 let start = actual
852 .find(&marker)
853 .expect("operation metadata block should exist");
854 let tail = &actual[start..];
855 let end = tail
856 .find("} as const;")
857 .expect("operation metadata block should close")
858 + "} as const;".len();
859 &tail[..end]
860 }
861}