Skip to main content

wesley_emit_typescript/
lib.rs

1#![deny(warnings)]
2#![deny(missing_docs)]
3
4//! AST-based TypeScript declaration emitter for Wesley L1 IR.
5
6use std::collections::BTreeSet;
7use std::fmt::Write;
8use wesley_core::{
9    Field, OperationArgument, OperationType, SchemaOperation, TypeDefinition, TypeKind,
10    TypeReference, WesleyIR,
11};
12
13/// Emits TypeScript declarations for a Wesley L1 IR document.
14pub fn emit_typescript(ir: &WesleyIR) -> String {
15    let program = TsProgram::from_ir(ir);
16
17    print_program(&program)
18}
19
20/// Emits TypeScript declarations and operation bindings for a Wesley L1 IR document.
21pub 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}