Skip to main content

code_system_graph_core/
graphql_contracts.rs

1//! Evidence-first GraphQL contract extraction.
2//!
3//! Standalone GraphQL is parsed with `graphql-parser`. Focused source extraction only recognizes
4//! literal documents and resolver declarations whose type, field, and implementation symbol are
5//! all statically visible. Extracted values never retain source bodies.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use graphql_parser::{query, schema};
10use serde::{Deserialize, Serialize};
11
12use crate::{ExtractionBudgets, ExtractionLimitExceeded, ExtractionTracker, SourceLanguage};
13
14/// Inclusive one-based source range supporting an extracted GraphQL fact.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
16pub struct GraphqlLineRange {
17    /// First line containing direct evidence.
18    pub start: u32,
19    /// Last line containing direct evidence.
20    pub end: u32,
21}
22
23/// Executable GraphQL operation category.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum GraphqlOperationKind {
27    /// Read-only query operation.
28    Query,
29    /// Mutation operation.
30    Mutation,
31    /// Subscription operation.
32    Subscription,
33}
34
35/// GraphQL type-system definition category.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum GraphqlTypeKind {
39    /// Scalar type.
40    Scalar,
41    /// Object type.
42    Object,
43    /// Interface type.
44    Interface,
45    /// Input object type.
46    InputObject,
47    /// Enum type.
48    Enum,
49    /// Union type.
50    Union,
51}
52
53/// Non-sensitive structural category of a GraphQL literal.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum GraphqlLiteralKind {
57    /// Null literal.
58    Null,
59    /// Boolean literal.
60    Boolean,
61    /// Integer literal.
62    Integer,
63    /// Floating-point literal.
64    Float,
65    /// String literal without its contents.
66    String,
67    /// Enum literal without its member name.
68    Enum,
69    /// List literal without its members.
70    List,
71    /// Object literal without its fields or values.
72    Object,
73    /// Variable reference without its name.
74    Variable,
75}
76
77/// Fully owned GraphQL type reference preserving list and nullability structure.
78#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
79#[serde(tag = "kind", rename_all = "snake_case")]
80pub enum GraphqlTypeRef {
81    /// Named GraphQL type.
82    Named {
83        /// Type name.
84        name: String,
85        /// Whether this position is non-null.
86        non_null: bool,
87    },
88    /// GraphQL list type.
89    List {
90        /// Type of each list element.
91        element: Box<GraphqlTypeRef>,
92        /// Whether the list itself is non-null.
93        non_null: bool,
94    },
95}
96
97impl GraphqlTypeRef {
98    /// Returns canonical GraphQL type syntax.
99    #[must_use]
100    pub fn as_graphql(&self) -> String {
101        match self {
102            Self::Named { name, non_null } => {
103                format!("{name}{}", if *non_null { "!" } else { "" })
104            }
105            Self::List { element, non_null } => format!(
106                "[{}]{}",
107                element.as_graphql(),
108                if *non_null { "!" } else { "" }
109            ),
110        }
111    }
112}
113
114/// Argument, variable, or input-field definition.
115#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct GraphqlArgumentDefinition {
118    /// Argument or variable name without a leading dollar sign.
119    pub name: String,
120    /// Declared GraphQL type.
121    pub type_ref: GraphqlTypeRef,
122    /// Structural category of the default value, when declared.
123    pub default_value_kind: Option<GraphqlLiteralKind>,
124    /// Directive names attached to the definition.
125    pub directives: Vec<String>,
126    /// Source evidence for the declaration.
127    pub lines: GraphqlLineRange,
128}
129
130/// Field declared by an object, interface, or input object.
131#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
132pub struct GraphqlFieldDefinition {
133    /// Exact `Type.field` coordinate.
134    pub coordinate: String,
135    /// Field name.
136    pub name: String,
137    /// Field arguments. Input fields always have an empty argument list.
138    pub arguments: Vec<GraphqlArgumentDefinition>,
139    /// Declared return or input type.
140    pub type_ref: GraphqlTypeRef,
141    /// Directive names attached to the field.
142    pub directives: Vec<String>,
143    /// Whether this field came from an `extend` definition.
144    pub extension: bool,
145    /// Source evidence for the declaration.
146    pub lines: GraphqlLineRange,
147}
148
149/// SDL type definition or extension.
150#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
151pub struct GraphqlTypeDefinition {
152    /// Definition category.
153    pub kind: GraphqlTypeKind,
154    /// Type name.
155    pub name: String,
156    /// Object, interface, or input-object fields.
157    pub fields: Vec<GraphqlFieldDefinition>,
158    /// Interfaces implemented by this type.
159    pub implements: Vec<String>,
160    /// Enum value names.
161    pub enum_values: Vec<String>,
162    /// Union member names.
163    pub union_members: Vec<String>,
164    /// Directive names attached to the type.
165    pub directives: Vec<String>,
166    /// Whether this is an `extend` definition.
167    pub extension: bool,
168    /// Source evidence for the declaration.
169    pub lines: GraphqlLineRange,
170}
171
172/// Selection retained from an operation or fragment.
173#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
174#[serde(tag = "kind", rename_all = "snake_case")]
175pub enum GraphqlSelection {
176    /// Selected field with its canonical consumed path.
177    Field {
178        /// Canonical field path using schema field names rather than response aliases.
179        path: String,
180        /// Field name.
181        name: String,
182        /// Optional response alias.
183        alias: Option<String>,
184        /// Source evidence for the field.
185        lines: GraphqlLineRange,
186    },
187    /// Named fragment spread at a selection path.
188    FragmentSpread {
189        /// Fragment name.
190        name: String,
191        /// Parent path at which the fragment is spread.
192        parent_path: Option<String>,
193        /// Source evidence for the spread.
194        lines: GraphqlLineRange,
195    },
196    /// Inline fragment type condition.
197    InlineFragment {
198        /// Optional type condition.
199        type_condition: Option<String>,
200        /// Parent path at which the fragment applies.
201        parent_path: Option<String>,
202        /// Source evidence for the fragment.
203        lines: GraphqlLineRange,
204    },
205}
206
207/// Executable GraphQL operation.
208#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
209pub struct GraphqlOperation {
210    /// Operation category.
211    pub kind: GraphqlOperationKind,
212    /// Declared operation name, absent for shorthand or anonymous operations.
213    pub name: Option<String>,
214    /// Variable definitions.
215    pub variables: Vec<GraphqlArgumentDefinition>,
216    /// Flattened selections, including fragment-spread evidence.
217    pub selections: Vec<GraphqlSelection>,
218    /// Deterministically sorted consumed schema field paths.
219    pub consumed_field_paths: Vec<String>,
220    /// Names of directly or transitively referenced fragments.
221    pub fragment_spreads: Vec<String>,
222    /// Whether all referenced fragments were available and expandable.
223    pub complete: bool,
224    /// Machine-readable limitations for this operation.
225    pub warnings: Vec<String>,
226    /// Source evidence for the operation declaration.
227    pub lines: GraphqlLineRange,
228}
229
230/// Named executable GraphQL fragment.
231#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
232pub struct GraphqlFragment {
233    /// Fragment name.
234    pub name: String,
235    /// Type condition.
236    pub type_condition: String,
237    /// Flattened fragment selections.
238    pub selections: Vec<GraphqlSelection>,
239    /// Deterministically sorted field paths relative to the fragment root.
240    pub consumed_field_paths: Vec<String>,
241    /// Referenced fragment names.
242    pub fragment_spreads: Vec<String>,
243    /// Source evidence for the fragment declaration.
244    pub lines: GraphqlLineRange,
245}
246
247/// Persisted operation manifest entry without the persisted query body.
248#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
249pub struct GraphqlPersistedOperation {
250    /// Persisted operation identifier or hash.
251    pub id: String,
252    /// Declared or parsed operation name.
253    pub operation_name: Option<String>,
254    /// Parsed operation category, when a literal document is available.
255    pub kind: Option<GraphqlOperationKind>,
256    /// Deterministically sorted consumed field paths.
257    pub consumed_field_paths: Vec<String>,
258    /// Whether a literal operation document was parsed successfully.
259    pub complete: bool,
260    /// Machine-readable limitations for this entry.
261    pub warnings: Vec<String>,
262    /// Source manifest path.
263    pub source_path: String,
264    /// Source evidence for the manifest entry.
265    pub lines: GraphqlLineRange,
266}
267
268/// Exact resolver implementation anchor.
269#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
270pub struct GraphqlResolver {
271    /// GraphQL parent type.
272    pub type_name: String,
273    /// GraphQL field name.
274    pub field_name: String,
275    /// Exact `Type.field` coordinate.
276    pub coordinate: String,
277    /// Statically declared implementation symbol.
278    pub symbol: String,
279    /// Source language that supplied the declaration.
280    pub language: SourceLanguage,
281    /// Source evidence for the declaration.
282    pub lines: GraphqlLineRange,
283}
284
285/// Declarative Apollo federation or schema-stitching directive.
286#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
287pub struct GraphqlFederationMetadata {
288    /// Directive name without the leading at-sign.
289    pub directive: String,
290    /// Schema, type, or exact field coordinate carrying the directive.
291    pub target: String,
292    /// Structural categories of directive arguments.
293    pub arguments: BTreeMap<String, GraphqlLiteralKind>,
294    /// Source evidence for the directive.
295    pub lines: GraphqlLineRange,
296}
297
298/// Owned extraction result for one source artifact.
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct GraphqlDocument {
301    /// Repository-relative source path, or an empty string for embedded source parsing.
302    pub source_path: String,
303    /// SDL type definitions and extensions.
304    pub types: Vec<GraphqlTypeDefinition>,
305    /// Executable operations.
306    pub operations: Vec<GraphqlOperation>,
307    /// Executable fragments.
308    pub fragments: Vec<GraphqlFragment>,
309    /// Persisted operation entries.
310    pub persisted_operations: Vec<GraphqlPersistedOperation>,
311    /// Exact focused resolver declarations.
312    pub resolvers: Vec<GraphqlResolver>,
313    /// Declarative federation and stitching metadata.
314    pub federation: Vec<GraphqlFederationMetadata>,
315    /// Whether every recognized candidate was parsed exactly.
316    pub complete: bool,
317    /// Deterministically sorted machine-readable extraction limitations.
318    pub warnings: Vec<String>,
319}
320
321impl GraphqlDocument {
322    fn empty(source_path: &str) -> Self {
323        Self {
324            source_path: source_path.to_owned(),
325            types: Vec::new(),
326            operations: Vec::new(),
327            fragments: Vec::new(),
328            persisted_operations: Vec::new(),
329            resolvers: Vec::new(),
330            federation: Vec::new(),
331            complete: true,
332            warnings: Vec::new(),
333        }
334    }
335}
336
337/// Error returned for invalid standalone GraphQL or persisted-operation JSON.
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
339#[serde(tag = "kind", rename_all = "snake_case")]
340pub enum GraphqlExtractionError {
341    /// The standalone GraphQL document is syntactically invalid.
342    #[error("invalid GraphQL in `{source_path}`: {message}")]
343    InvalidGraphql {
344        /// Source artifact path.
345        source_path: String,
346        /// Parser diagnostic.
347        message: String,
348    },
349    /// The persisted-operation manifest is not valid JSON.
350    #[error("invalid persisted-operation JSON in `{source_path}` at line {line}: {message}")]
351    InvalidJson {
352        /// Source artifact path.
353        source_path: String,
354        /// One-based parser line.
355        line: u32,
356        /// JSON parser diagnostic.
357        message: String,
358    },
359    /// JSON was valid but did not contain a supported persisted-operation shape.
360    #[error("unsupported persisted-operation manifest in `{source_path}`")]
361    UnsupportedPersistedManifest {
362        /// Source artifact path.
363        source_path: String,
364    },
365    /// Extraction exceeded one configured invocation resource.
366    #[error(transparent)]
367    LimitExceeded(#[from] ExtractionLimitExceeded),
368}
369
370/// Parses a standalone SDL or executable GraphQL document.
371///
372/// The function accepts one GraphQL grammar per artifact. A file mixing SDL and executable
373/// definitions is rejected because `graphql-parser` exposes those grammars separately.
374///
375/// # Errors
376///
377/// Returns [`GraphqlExtractionError::InvalidGraphql`] when neither dedicated parser accepts the
378/// complete input.
379pub fn extract_graphql_document(
380    source_path: &str,
381    input: &str,
382) -> Result<GraphqlDocument, GraphqlExtractionError> {
383    let mut tracker = ExtractionTracker::new(
384        source_path,
385        "code-system-graph.graphql.document",
386        &ExtractionBudgets::default(),
387    );
388    extract_graphql_document_with_tracker(source_path, input, &mut tracker)
389}
390
391/// Parses a standalone GraphQL document using an existing per-invocation tracker.
392///
393/// # Errors
394///
395/// Returns [`GraphqlExtractionError`] for invalid syntax or exhausted budgets.
396pub fn extract_graphql_document_with_tracker(
397    source_path: &str,
398    input: &str,
399    tracker: &mut ExtractionTracker,
400) -> Result<GraphqlDocument, GraphqlExtractionError> {
401    tracker.check_input_bytes(u64::try_from(input.len()).unwrap_or(u64::MAX))?;
402    precheck_graphql_depth(input, tracker)?;
403    let schema_result = schema::parse_schema::<String>(input);
404    tracker.check_structured_time()?;
405
406    match schema_result {
407        Ok(document) => {
408            charge_schema_work(&document, tracker)?;
409            let mut output = GraphqlDocument::empty(source_path);
410            append_schema_document(document, &mut output);
411            finish_document(&mut output);
412            charge_graphql_document(&output, tracker, true)?;
413            tracker.check_structured_time()?;
414            Ok(output)
415        }
416        Err(_schema_error) => match query::parse_query::<String>(input) {
417            Ok(document) => {
418                let mut output = GraphqlDocument::empty(source_path);
419                append_query_document(document, &mut output, tracker)?;
420                finish_document(&mut output);
421                charge_graphql_document(&output, tracker, true)?;
422                tracker.check_structured_time()?;
423                Ok(output)
424            }
425            Err(_query_error) => Err(GraphqlExtractionError::InvalidGraphql {
426                source_path: source_path.to_owned(),
427                message: "document is neither valid GraphQL schema nor executable syntax"
428                    .to_owned(),
429            }),
430        },
431    }
432}
433
434/// Extracts common persisted-operation JSON maps and manifests.
435///
436/// Supported shapes include identifier-to-query maps, Apollo-style `operations` arrays or maps,
437/// and entries using `id`, `hash`, or `sha256Hash` plus `body`, `query`, `document`, or `text`.
438/// Query bodies are parsed for facts and then discarded.
439///
440/// # Errors
441///
442/// Returns [`GraphqlExtractionError::InvalidJson`] for invalid JSON,
443/// [`GraphqlExtractionError::UnsupportedPersistedManifest`] when no entries are recognized, or
444/// [`GraphqlExtractionError::InvalidGraphql`] when a recognized literal operation is invalid.
445pub fn extract_graphql_persisted_operations(
446    source_path: &str,
447    input: &str,
448) -> Result<Vec<GraphqlPersistedOperation>, GraphqlExtractionError> {
449    let mut tracker = ExtractionTracker::new(
450        source_path,
451        "code-system-graph.graphql.persisted",
452        &ExtractionBudgets::default(),
453    );
454    extract_graphql_persisted_operations_with_tracker(source_path, input, &mut tracker)
455}
456
457/// Extracts persisted operations using an existing per-invocation tracker.
458///
459/// # Errors
460///
461/// Returns [`GraphqlExtractionError`] for invalid input or exhausted budgets.
462pub fn extract_graphql_persisted_operations_with_tracker(
463    source_path: &str,
464    input: &str,
465    tracker: &mut ExtractionTracker,
466) -> Result<Vec<GraphqlPersistedOperation>, GraphqlExtractionError> {
467    tracker.check_input_bytes(u64::try_from(input.len()).unwrap_or(u64::MAX))?;
468    precheck_json_structure(input, tracker)?;
469    let parsed = serde_json::from_str(input);
470    tracker.check_structured_time()?;
471    let value: serde_json::Value = parsed.map_err(|error| GraphqlExtractionError::InvalidJson {
472        source_path: source_path.to_owned(),
473        line: usize_to_u32(error.line()),
474        message: error.to_string(),
475    })?;
476    let mut candidates = Vec::new();
477    collect_persisted_candidates(&value, None, &mut candidates, 1, tracker)?;
478    if candidates.is_empty() {
479        return Err(GraphqlExtractionError::UnsupportedPersistedManifest {
480            source_path: source_path.to_owned(),
481        });
482    }
483
484    let mut output = Vec::new();
485    for candidate in candidates {
486        let line = manifest_id_line(input, &candidate.id);
487        tracker.charge_work(1)?;
488        output.push(persisted_operation(source_path, candidate, line, tracker)?);
489    }
490    output.sort();
491    output.dedup();
492    tracker.check_structured_time()?;
493    Ok(output)
494}
495
496/// Extracts literal embedded GraphQL and focused exact resolver declarations.
497///
498/// Dynamic or syntactically invalid GraphQL candidates are not promoted to contracts. They make
499/// the returned document partial and add a warning. Resolver recognition is intentionally limited
500/// to popular declarative patterns with literal field coordinates and named implementation
501/// symbols.
502///
503/// # Errors
504///
505/// Returns [`GraphqlExtractionError`] when a configured extraction budget is exhausted.
506pub fn parse_graphql_source(
507    language: SourceLanguage,
508    input: &str,
509) -> Result<GraphqlDocument, GraphqlExtractionError> {
510    let mut tracker = ExtractionTracker::new(
511        "<embedded>",
512        "code-system-graph.graphql.source",
513        &ExtractionBudgets::default(),
514    );
515    parse_graphql_source_with_tracker(language, input, &mut tracker)
516}
517
518/// Extracts embedded GraphQL using an existing per-invocation tracker.
519///
520/// # Errors
521///
522/// Returns an error when a configured extraction budget is exhausted.
523pub fn parse_graphql_source_with_tracker(
524    language: SourceLanguage,
525    input: &str,
526    tracker: &mut ExtractionTracker,
527) -> Result<GraphqlDocument, GraphqlExtractionError> {
528    tracker.check_input_bytes(u64::try_from(input.len()).unwrap_or(u64::MAX))?;
529    for _ in input.lines() {
530        tracker.charge_work(1)?;
531    }
532    precheck_embedded_source_strings(input, tracker)?;
533    let mut output = GraphqlDocument::empty("");
534    let literals = embedded_graphql_literals(language, input);
535    for literal in literals {
536        if literal.dynamic {
537            output.complete = false;
538            output
539                .warnings
540                .push(format!("dynamic_graphql_literal:{}", literal.start_line));
541            continue;
542        }
543        tracker.charge_work(1)?;
544        match extract_graphql_document_with_tracker("", &literal.text, tracker) {
545            Ok(mut document) => {
546                shift_document_lines(&mut document, literal.start_line.saturating_sub(1));
547                merge_document(&mut output, document);
548            }
549            Err(GraphqlExtractionError::LimitExceeded(error)) => return Err(error.into()),
550            Err(
551                GraphqlExtractionError::InvalidGraphql { .. }
552                | GraphqlExtractionError::InvalidJson { .. }
553                | GraphqlExtractionError::UnsupportedPersistedManifest { .. },
554            ) => {
555                output.complete = false;
556                output
557                    .warnings
558                    .push(format!("invalid_embedded_graphql:{}", literal.start_line));
559            }
560        }
561    }
562    output.resolvers = extract_resolvers(language, input, tracker)?;
563    finish_document(&mut output);
564    charge_graphql_document(&output, tracker, false)?;
565    tracker.check_structured_time()?;
566    Ok(output)
567}
568
569fn precheck_embedded_source_strings(
570    input: &str,
571    tracker: &ExtractionTracker,
572) -> Result<(), ExtractionLimitExceeded> {
573    let bytes = input.as_bytes();
574    let mut cursor = 0_usize;
575    let mut accumulated = 0_u64;
576    while cursor < bytes.len() {
577        if cursor.is_multiple_of(1_024) {
578            tracker.check_structured_time()?;
579        }
580        let delimiter = bytes[cursor];
581        if !matches!(delimiter, b'"' | b'\'' | b'`') {
582            cursor = cursor.saturating_add(1);
583            continue;
584        }
585        cursor = cursor.saturating_add(1);
586        let start = cursor;
587        let mut escaped = false;
588        while cursor < bytes.len() {
589            let byte = bytes[cursor];
590            if escaped {
591                escaped = false;
592            } else if byte == b'\\' {
593                escaped = true;
594            } else if byte == delimiter {
595                break;
596            }
597            cursor = cursor.saturating_add(1);
598            if cursor.is_multiple_of(1_024) {
599                tracker.check_structured_time()?;
600            }
601        }
602        let observed = u64::try_from(cursor.saturating_sub(start)).unwrap_or(u64::MAX);
603        tracker.check_string_bytes(observed)?;
604        accumulated = accumulated.saturating_add(observed);
605        tracker.check_accumulated_string_bytes(accumulated)?;
606        cursor = cursor.saturating_add(1);
607    }
608    Ok(())
609}
610
611#[derive(Debug)]
612struct PersistedCandidate {
613    id: String,
614    operation_name: Option<String>,
615    document: Option<String>,
616}
617
618fn collect_persisted_candidates(
619    value: &serde_json::Value,
620    key_hint: Option<&str>,
621    output: &mut Vec<PersistedCandidate>,
622    depth: u64,
623    tracker: &mut ExtractionTracker,
624) -> Result<(), ExtractionLimitExceeded> {
625    tracker.check_structural_depth(depth)?;
626    tracker.charge_work(1)?;
627    match value {
628        serde_json::Value::Object(object) => {
629            if let Some(candidate) = persisted_candidate_from_object(object, key_hint, tracker)? {
630                tracker.charge_observation(1)?;
631                output.push(candidate);
632                return Ok(());
633            }
634            if let Some(operations) = object.get("operations") {
635                collect_persisted_candidates(
636                    operations,
637                    None,
638                    output,
639                    depth.saturating_add(1),
640                    tracker,
641                )?;
642                return Ok(());
643            }
644            for (key, nested) in object {
645                tracker.charge_work(1)?;
646                if is_manifest_metadata_key(key) {
647                    continue;
648                }
649                match nested {
650                    serde_json::Value::String(document) if looks_like_graphql(document) => {
651                        tracker.charge_identifier(key)?;
652                        tracker.charge_string(document)?;
653                        tracker.charge_observation(1)?;
654                        output.push(PersistedCandidate {
655                            id: key.clone(),
656                            operation_name: None,
657                            document: Some(document.clone()),
658                        });
659                    }
660                    serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
661                        collect_persisted_candidates(
662                            nested,
663                            Some(key),
664                            output,
665                            depth.saturating_add(1),
666                            tracker,
667                        )?;
668                    }
669                    _ => {}
670                }
671            }
672        }
673        serde_json::Value::Array(values) => {
674            for nested in values {
675                collect_persisted_candidates(
676                    nested,
677                    None,
678                    output,
679                    depth.saturating_add(1),
680                    tracker,
681                )?;
682            }
683        }
684        _ => {}
685    }
686    Ok(())
687}
688
689fn persisted_candidate_from_object(
690    object: &serde_json::Map<String, serde_json::Value>,
691    key_hint: Option<&str>,
692    tracker: &mut ExtractionTracker,
693) -> Result<Option<PersistedCandidate>, ExtractionLimitExceeded> {
694    let id = string_property(object, &["id", "hash", "sha256Hash"]).or(key_hint);
695    let document = string_property(object, &["body", "query", "document", "text"]);
696    let operation_name = string_property(object, &["name", "operationName"]);
697    let Some(id) = id else {
698        return Ok(None);
699    };
700    if document.is_none() && operation_name.is_none() {
701        return Ok(None);
702    }
703    tracker.charge_identifier(id)?;
704    if let Some(value) = document {
705        tracker.charge_string(value)?;
706    }
707    if let Some(value) = operation_name {
708        tracker.charge_identifier(value)?;
709    }
710    Ok(Some(PersistedCandidate {
711        id: id.to_owned(),
712        operation_name: operation_name.map(str::to_owned),
713        document: document.map(str::to_owned),
714    }))
715}
716
717fn string_property<'a>(
718    object: &'a serde_json::Map<String, serde_json::Value>,
719    keys: &[&str],
720) -> Option<&'a str> {
721    keys.iter()
722        .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
723}
724
725fn is_manifest_metadata_key(key: &str) -> bool {
726    matches!(
727        key,
728        "format" | "version" | "generatedAt" | "clientName" | "operations"
729    )
730}
731
732fn persisted_operation(
733    source_path: &str,
734    candidate: PersistedCandidate,
735    line: u32,
736    tracker: &mut ExtractionTracker,
737) -> Result<GraphqlPersistedOperation, GraphqlExtractionError> {
738    let Some(document) = candidate.document else {
739        return Ok(GraphqlPersistedOperation {
740            id: candidate.id,
741            operation_name: candidate.operation_name,
742            kind: None,
743            consumed_field_paths: Vec::new(),
744            complete: false,
745            warnings: vec!["missing_operation_document".to_owned()],
746            source_path: source_path.to_owned(),
747            lines: GraphqlLineRange {
748                start: line,
749                end: line,
750            },
751        });
752    };
753    let parsed = extract_graphql_document_with_tracker(source_path, &document, tracker)?;
754    let operation =
755        choose_persisted_operation(&parsed.operations, candidate.operation_name.as_deref());
756    let Some(operation) = operation else {
757        return Ok(GraphqlPersistedOperation {
758            id: candidate.id,
759            operation_name: candidate.operation_name,
760            kind: None,
761            consumed_field_paths: Vec::new(),
762            complete: false,
763            warnings: vec!["operation_name_not_found".to_owned()],
764            source_path: source_path.to_owned(),
765            lines: GraphqlLineRange {
766                start: line,
767                end: line,
768            },
769        });
770    };
771    Ok(GraphqlPersistedOperation {
772        id: candidate.id,
773        operation_name: operation.name.clone().or(candidate.operation_name),
774        kind: Some(operation.kind),
775        consumed_field_paths: operation.consumed_field_paths.clone(),
776        complete: operation.complete,
777        warnings: operation.warnings.clone(),
778        source_path: source_path.to_owned(),
779        lines: GraphqlLineRange {
780            start: line,
781            end: line,
782        },
783    })
784}
785
786fn manifest_id_line(input: &str, id: &str) -> u32 {
787    let quoted = serde_json::to_string(id).unwrap_or_else(|_| format!("\"{id}\""));
788    input
789        .find(&quoted)
790        .map_or(1, |offset| line_at_offset(input, offset))
791}
792
793fn choose_persisted_operation<'a>(
794    operations: &'a [GraphqlOperation],
795    requested_name: Option<&str>,
796) -> Option<&'a GraphqlOperation> {
797    requested_name.map_or_else(
798        || (operations.len() == 1).then(|| &operations[0]),
799        |name| {
800            operations
801                .iter()
802                .find(|operation| operation.name.as_deref() == Some(name))
803        },
804    )
805}
806
807fn append_schema_document(document: schema::Document<'_, String>, output: &mut GraphqlDocument) {
808    for definition in document.definitions {
809        match definition {
810            schema::Definition::SchemaDefinition(definition) => {
811                append_federation_directives(
812                    "schema",
813                    &definition.directives,
814                    definition.position.line,
815                    &mut output.federation,
816                );
817            }
818            schema::Definition::TypeDefinition(definition) => {
819                append_type_definition(definition, false, output);
820            }
821            schema::Definition::TypeExtension(definition) => {
822                append_type_extension(definition, output);
823            }
824            schema::Definition::DirectiveDefinition(_) => {}
825        }
826    }
827}
828
829#[expect(
830    clippy::too_many_lines,
831    reason = "Each graphql-parser SDL variant is converted without erasing its distinct fields"
832)]
833fn append_type_definition(
834    definition: schema::TypeDefinition<'_, String>,
835    extension: bool,
836    output: &mut GraphqlDocument,
837) {
838    match definition {
839        schema::TypeDefinition::Scalar(value) => {
840            append_simple_type(
841                GraphqlTypeKind::Scalar,
842                value.name,
843                &value.directives,
844                value.position.line,
845                extension,
846                output,
847            );
848        }
849        schema::TypeDefinition::Object(value) => {
850            let name = value.name;
851            append_composite_type(
852                GraphqlTypeKind::Object,
853                name,
854                value.fields,
855                value.implements_interfaces,
856                &value.directives,
857                value.position.line,
858                extension,
859                output,
860            );
861        }
862        schema::TypeDefinition::Interface(value) => {
863            let name = value.name;
864            append_composite_type(
865                GraphqlTypeKind::Interface,
866                name,
867                value.fields,
868                value.implements_interfaces,
869                &value.directives,
870                value.position.line,
871                extension,
872                output,
873            );
874        }
875        schema::TypeDefinition::Union(value) => {
876            let name = value.name;
877            append_federation_directives(
878                &name,
879                &value.directives,
880                value.position.line,
881                &mut output.federation,
882            );
883            output.types.push(GraphqlTypeDefinition {
884                kind: GraphqlTypeKind::Union,
885                name,
886                fields: Vec::new(),
887                implements: Vec::new(),
888                enum_values: Vec::new(),
889                union_members: sorted_strings(value.types),
890                directives: directive_names(&value.directives),
891                extension,
892                lines: line_range(value.position.line),
893            });
894        }
895        schema::TypeDefinition::Enum(value) => {
896            let name = value.name;
897            append_federation_directives(
898                &name,
899                &value.directives,
900                value.position.line,
901                &mut output.federation,
902            );
903            let mut enum_values = value
904                .values
905                .into_iter()
906                .map(|enum_value| enum_value.name)
907                .collect::<Vec<_>>();
908            enum_values.sort();
909            enum_values.dedup();
910            output.types.push(GraphqlTypeDefinition {
911                kind: GraphqlTypeKind::Enum,
912                name,
913                fields: Vec::new(),
914                implements: Vec::new(),
915                enum_values,
916                union_members: Vec::new(),
917                directives: directive_names(&value.directives),
918                extension,
919                lines: line_range(value.position.line),
920            });
921        }
922        schema::TypeDefinition::InputObject(value) => {
923            let name = value.name;
924            append_federation_directives(
925                &name,
926                &value.directives,
927                value.position.line,
928                &mut output.federation,
929            );
930            let mut fields = value
931                .fields
932                .into_iter()
933                .map(|field| input_field_definition(&name, field, extension))
934                .collect::<Vec<_>>();
935            fields.sort();
936            fields.dedup();
937            output.types.push(GraphqlTypeDefinition {
938                kind: GraphqlTypeKind::InputObject,
939                name,
940                fields,
941                implements: Vec::new(),
942                enum_values: Vec::new(),
943                union_members: Vec::new(),
944                directives: directive_names(&value.directives),
945                extension,
946                lines: line_range(value.position.line),
947            });
948        }
949    }
950}
951
952fn append_type_extension(
953    definition: schema::TypeExtension<'_, String>,
954    output: &mut GraphqlDocument,
955) {
956    match definition {
957        schema::TypeExtension::Scalar(value) => append_simple_type(
958            GraphqlTypeKind::Scalar,
959            value.name,
960            &value.directives,
961            value.position.line,
962            true,
963            output,
964        ),
965        schema::TypeExtension::Object(value) => append_composite_type(
966            GraphqlTypeKind::Object,
967            value.name,
968            value.fields,
969            value.implements_interfaces,
970            &value.directives,
971            value.position.line,
972            true,
973            output,
974        ),
975        schema::TypeExtension::Interface(value) => append_composite_type(
976            GraphqlTypeKind::Interface,
977            value.name,
978            value.fields,
979            value.implements_interfaces,
980            &value.directives,
981            value.position.line,
982            true,
983            output,
984        ),
985        schema::TypeExtension::Union(value) => {
986            let name = value.name;
987            append_federation_directives(
988                &name,
989                &value.directives,
990                value.position.line,
991                &mut output.federation,
992            );
993            output.types.push(GraphqlTypeDefinition {
994                kind: GraphqlTypeKind::Union,
995                name,
996                fields: Vec::new(),
997                implements: Vec::new(),
998                enum_values: Vec::new(),
999                union_members: sorted_strings(value.types),
1000                directives: directive_names(&value.directives),
1001                extension: true,
1002                lines: line_range(value.position.line),
1003            });
1004        }
1005        schema::TypeExtension::Enum(value) => {
1006            let name = value.name;
1007            append_federation_directives(
1008                &name,
1009                &value.directives,
1010                value.position.line,
1011                &mut output.federation,
1012            );
1013            output.types.push(GraphqlTypeDefinition {
1014                kind: GraphqlTypeKind::Enum,
1015                name,
1016                fields: Vec::new(),
1017                implements: Vec::new(),
1018                enum_values: sorted_strings(
1019                    value.values.into_iter().map(|enum_value| enum_value.name),
1020                ),
1021                union_members: Vec::new(),
1022                directives: directive_names(&value.directives),
1023                extension: true,
1024                lines: line_range(value.position.line),
1025            });
1026        }
1027        schema::TypeExtension::InputObject(value) => {
1028            let name = value.name;
1029            append_federation_directives(
1030                &name,
1031                &value.directives,
1032                value.position.line,
1033                &mut output.federation,
1034            );
1035            let mut fields = value
1036                .fields
1037                .into_iter()
1038                .map(|field| input_field_definition(&name, field, true))
1039                .collect::<Vec<_>>();
1040            fields.sort();
1041            fields.dedup();
1042            output.types.push(GraphqlTypeDefinition {
1043                kind: GraphqlTypeKind::InputObject,
1044                name,
1045                fields,
1046                implements: Vec::new(),
1047                enum_values: Vec::new(),
1048                union_members: Vec::new(),
1049                directives: directive_names(&value.directives),
1050                extension: true,
1051                lines: line_range(value.position.line),
1052            });
1053        }
1054    }
1055}
1056
1057fn append_simple_type(
1058    kind: GraphqlTypeKind,
1059    name: String,
1060    directives: &[schema::Directive<'_, String>],
1061    line: usize,
1062    extension: bool,
1063    output: &mut GraphqlDocument,
1064) {
1065    append_federation_directives(&name, directives, line, &mut output.federation);
1066    output.types.push(GraphqlTypeDefinition {
1067        kind,
1068        name,
1069        fields: Vec::new(),
1070        implements: Vec::new(),
1071        enum_values: Vec::new(),
1072        union_members: Vec::new(),
1073        directives: directive_names(directives),
1074        extension,
1075        lines: line_range(line),
1076    });
1077}
1078
1079#[expect(
1080    clippy::too_many_arguments,
1081    reason = "The parser AST exposes independent type coordinates"
1082)]
1083fn append_composite_type(
1084    kind: GraphqlTypeKind,
1085    name: String,
1086    source_fields: Vec<schema::Field<'_, String>>,
1087    implements: Vec<String>,
1088    directives: &[schema::Directive<'_, String>],
1089    line: usize,
1090    extension: bool,
1091    output: &mut GraphqlDocument,
1092) {
1093    append_federation_directives(&name, directives, line, &mut output.federation);
1094    let mut fields = Vec::new();
1095    for field in source_fields {
1096        append_federation_directives(
1097            &format!("{name}.{}", field.name),
1098            &field.directives,
1099            field.position.line,
1100            &mut output.federation,
1101        );
1102        fields.push(field_definition(&name, field, extension));
1103    }
1104    fields.sort();
1105    fields.dedup();
1106    output.types.push(GraphqlTypeDefinition {
1107        kind,
1108        name,
1109        fields,
1110        implements: sorted_strings(implements),
1111        enum_values: Vec::new(),
1112        union_members: Vec::new(),
1113        directives: directive_names(directives),
1114        extension,
1115        lines: line_range(line),
1116    });
1117}
1118
1119fn field_definition(
1120    parent: &str,
1121    field: schema::Field<'_, String>,
1122    extension: bool,
1123) -> GraphqlFieldDefinition {
1124    let mut arguments = field
1125        .arguments
1126        .into_iter()
1127        .map(argument_definition)
1128        .collect::<Vec<_>>();
1129    arguments.sort();
1130    arguments.dedup();
1131    GraphqlFieldDefinition {
1132        coordinate: format!("{parent}.{}", field.name),
1133        name: field.name,
1134        arguments,
1135        type_ref: schema_type_ref(&field.field_type),
1136        directives: directive_names(&field.directives),
1137        extension,
1138        lines: line_range(field.position.line),
1139    }
1140}
1141
1142fn input_field_definition(
1143    parent: &str,
1144    field: schema::InputValue<'_, String>,
1145    extension: bool,
1146) -> GraphqlFieldDefinition {
1147    GraphqlFieldDefinition {
1148        coordinate: format!("{parent}.{}", field.name),
1149        name: field.name,
1150        arguments: Vec::new(),
1151        type_ref: schema_type_ref(&field.value_type),
1152        directives: directive_names(&field.directives),
1153        extension,
1154        lines: line_range(field.position.line),
1155    }
1156}
1157
1158fn argument_definition(value: schema::InputValue<'_, String>) -> GraphqlArgumentDefinition {
1159    GraphqlArgumentDefinition {
1160        name: value.name,
1161        type_ref: schema_type_ref(&value.value_type),
1162        default_value_kind: value.default_value.as_ref().map(graphql_value_kind),
1163        directives: directive_names(&value.directives),
1164        lines: line_range(value.position.line),
1165    }
1166}
1167
1168fn schema_type_ref(value: &schema::Type<'_, String>) -> GraphqlTypeRef {
1169    match value {
1170        schema::Type::NamedType(name) => GraphqlTypeRef::Named {
1171            name: name.clone(),
1172            non_null: false,
1173        },
1174        schema::Type::ListType(element) => GraphqlTypeRef::List {
1175            element: Box::new(schema_type_ref(element)),
1176            non_null: false,
1177        },
1178        schema::Type::NonNullType(inner) => with_non_null(schema_type_ref(inner)),
1179    }
1180}
1181
1182fn query_type_ref(value: &query::Type<'_, String>) -> GraphqlTypeRef {
1183    match value {
1184        query::Type::NamedType(name) => GraphqlTypeRef::Named {
1185            name: name.clone(),
1186            non_null: false,
1187        },
1188        query::Type::ListType(element) => GraphqlTypeRef::List {
1189            element: Box::new(query_type_ref(element)),
1190            non_null: false,
1191        },
1192        query::Type::NonNullType(inner) => with_non_null(query_type_ref(inner)),
1193    }
1194}
1195
1196fn with_non_null(value: GraphqlTypeRef) -> GraphqlTypeRef {
1197    match value {
1198        GraphqlTypeRef::Named { name, .. } => GraphqlTypeRef::Named {
1199            name,
1200            non_null: true,
1201        },
1202        GraphqlTypeRef::List { element, .. } => GraphqlTypeRef::List {
1203            element,
1204            non_null: true,
1205        },
1206    }
1207}
1208
1209fn directive_names(directives: &[schema::Directive<'_, String>]) -> Vec<String> {
1210    let mut names = directives
1211        .iter()
1212        .map(|directive| directive.name.clone())
1213        .collect::<Vec<_>>();
1214    names.sort();
1215    names.dedup();
1216    names
1217}
1218
1219fn append_federation_directives(
1220    target: &str,
1221    directives: &[schema::Directive<'_, String>],
1222    fallback_line: usize,
1223    output: &mut Vec<GraphqlFederationMetadata>,
1224) {
1225    for directive in directives {
1226        if !is_federation_directive(&directive.name) {
1227            continue;
1228        }
1229        let arguments = directive
1230            .arguments
1231            .iter()
1232            .map(|(name, value)| (name.clone(), graphql_value_kind(value)))
1233            .collect();
1234        output.push(GraphqlFederationMetadata {
1235            directive: directive.name.clone(),
1236            target: target.to_owned(),
1237            arguments,
1238            lines: line_range(if directive.position.line == 0 {
1239                fallback_line
1240            } else {
1241                directive.position.line
1242            }),
1243        });
1244    }
1245}
1246
1247fn is_federation_directive(name: &str) -> bool {
1248    matches!(
1249        name,
1250        "key"
1251            | "external"
1252            | "requires"
1253            | "provides"
1254            | "extends"
1255            | "shareable"
1256            | "override"
1257            | "inaccessible"
1258            | "tag"
1259            | "composeDirective"
1260            | "interfaceObject"
1261            | "link"
1262            | "merge"
1263            | "canonical"
1264            | "computed"
1265    )
1266}
1267
1268fn graphql_value_kind(value: &schema::Value<'_, String>) -> GraphqlLiteralKind {
1269    match value {
1270        schema::Value::Variable(_) => GraphqlLiteralKind::Variable,
1271        schema::Value::Int(_) => GraphqlLiteralKind::Integer,
1272        schema::Value::Float(_) => GraphqlLiteralKind::Float,
1273        schema::Value::String(_) => GraphqlLiteralKind::String,
1274        schema::Value::Boolean(_) => GraphqlLiteralKind::Boolean,
1275        schema::Value::Null => GraphqlLiteralKind::Null,
1276        schema::Value::Enum(_) => GraphqlLiteralKind::Enum,
1277        schema::Value::List(_) => GraphqlLiteralKind::List,
1278        schema::Value::Object(_) => GraphqlLiteralKind::Object,
1279    }
1280}
1281
1282#[derive(Debug, Clone, Default)]
1283struct FragmentExpansion {
1284    paths: BTreeSet<String>,
1285    spreads: BTreeSet<String>,
1286    missing: BTreeSet<String>,
1287}
1288
1289#[derive(Debug, Clone)]
1290enum FragmentMemoState {
1291    Pending,
1292    InProgress,
1293    Completed(FragmentExpansion),
1294}
1295
1296fn append_query_document(
1297    document: query::Document<'_, String>,
1298    output: &mut GraphqlDocument,
1299    tracker: &mut ExtractionTracker,
1300) -> Result<(), ExtractionLimitExceeded> {
1301    let mut operation_sources = Vec::new();
1302    let mut fragment_sources = BTreeMap::new();
1303    for definition in document.definitions {
1304        tracker.charge_work(1)?;
1305        match definition {
1306            query::Definition::Operation(operation) => operation_sources.push(operation),
1307            query::Definition::Fragment(fragment) => {
1308                fragment_sources.insert(fragment.name.clone(), fragment);
1309            }
1310        }
1311    }
1312
1313    let mut memo = fragment_sources
1314        .keys()
1315        .map(|name| (name.clone(), FragmentMemoState::Pending))
1316        .collect::<BTreeMap<_, _>>();
1317    for fragment in fragment_sources.values() {
1318        output.fragments.push(fragment_definition(
1319            fragment,
1320            &fragment_sources,
1321            &mut memo,
1322            tracker,
1323        )?);
1324    }
1325    for operation in &operation_sources {
1326        output.operations.push(operation_definition(
1327            operation,
1328            &fragment_sources,
1329            &mut memo,
1330            tracker,
1331        )?);
1332    }
1333    Ok(())
1334}
1335
1336fn fragment_definition(
1337    fragment: &query::FragmentDefinition<'_, String>,
1338    fragments: &BTreeMap<String, query::FragmentDefinition<'_, String>>,
1339    memo: &mut BTreeMap<String, FragmentMemoState>,
1340    tracker: &mut ExtractionTracker,
1341) -> Result<GraphqlFragment, ExtractionLimitExceeded> {
1342    let mut selections = Vec::new();
1343    let mut spreads = Vec::new();
1344    append_selections(
1345        &fragment.selection_set,
1346        None,
1347        &mut selections,
1348        &mut spreads,
1349        1,
1350        tracker,
1351    )?;
1352    let expansion = expand_fragment(&fragment.name, fragments, memo, 1, tracker)?;
1353    let query::TypeCondition::On(type_condition) = &fragment.type_condition;
1354    Ok(GraphqlFragment {
1355        name: fragment.name.clone(),
1356        type_condition: type_condition.clone(),
1357        consumed_field_paths: expansion.paths.into_iter().collect(),
1358        selections: sorted_unique(selections),
1359        fragment_spreads: expansion.spreads.into_iter().collect(),
1360        lines: line_range(fragment.position.line),
1361    })
1362}
1363
1364fn operation_definition(
1365    operation: &query::OperationDefinition<'_, String>,
1366    fragments: &BTreeMap<String, query::FragmentDefinition<'_, String>>,
1367    memo: &mut BTreeMap<String, FragmentMemoState>,
1368    tracker: &mut ExtractionTracker,
1369) -> Result<GraphqlOperation, ExtractionLimitExceeded> {
1370    let (kind, name, variables, selection_set, line) = match operation {
1371        query::OperationDefinition::SelectionSet(selection_set) => (
1372            GraphqlOperationKind::Query,
1373            None,
1374            Vec::new(),
1375            selection_set,
1376            selection_set.span.0.line,
1377        ),
1378        query::OperationDefinition::Query(operation) => (
1379            GraphqlOperationKind::Query,
1380            operation.name.clone(),
1381            query_variables(&operation.variable_definitions),
1382            &operation.selection_set,
1383            operation.position.line,
1384        ),
1385        query::OperationDefinition::Mutation(operation) => (
1386            GraphqlOperationKind::Mutation,
1387            operation.name.clone(),
1388            query_variables(&operation.variable_definitions),
1389            &operation.selection_set,
1390            operation.position.line,
1391        ),
1392        query::OperationDefinition::Subscription(operation) => (
1393            GraphqlOperationKind::Subscription,
1394            operation.name.clone(),
1395            query_variables(&operation.variable_definitions),
1396            &operation.selection_set,
1397            operation.position.line,
1398        ),
1399    };
1400    let mut selections = Vec::new();
1401    let mut direct_spreads = Vec::new();
1402    append_selections(
1403        selection_set,
1404        None,
1405        &mut selections,
1406        &mut direct_spreads,
1407        1,
1408        tracker,
1409    )?;
1410    let expansion = expand_selection_set(selection_set, None, fragments, memo, 1, tracker)?;
1411    let warnings = expansion
1412        .missing
1413        .iter()
1414        .map(|name| format!("missing_fragment:{name}"))
1415        .collect::<Vec<_>>();
1416    Ok(GraphqlOperation {
1417        kind,
1418        name,
1419        variables,
1420        selections: sorted_unique(selections),
1421        consumed_field_paths: expansion.paths.into_iter().collect(),
1422        fragment_spreads: expansion.spreads.into_iter().collect(),
1423        complete: expansion.missing.is_empty(),
1424        warnings,
1425        lines: line_range(line),
1426    })
1427}
1428
1429fn query_variables(
1430    variables: &[query::VariableDefinition<'_, String>],
1431) -> Vec<GraphqlArgumentDefinition> {
1432    let mut output = variables
1433        .iter()
1434        .map(|variable| GraphqlArgumentDefinition {
1435            name: variable.name.clone(),
1436            type_ref: query_type_ref(&variable.var_type),
1437            default_value_kind: variable.default_value.as_ref().map(graphql_value_kind),
1438            directives: Vec::new(),
1439            lines: line_range(variable.position.line),
1440        })
1441        .collect::<Vec<_>>();
1442    output.sort();
1443    output.dedup();
1444    output
1445}
1446
1447fn append_selections(
1448    selection_set: &query::SelectionSet<'_, String>,
1449    parent: Option<&str>,
1450    output: &mut Vec<GraphqlSelection>,
1451    spreads: &mut Vec<String>,
1452    depth: u64,
1453    tracker: &mut ExtractionTracker,
1454) -> Result<(), ExtractionLimitExceeded> {
1455    tracker.check_structural_depth(depth)?;
1456    for selection in &selection_set.items {
1457        tracker.charge_work(1)?;
1458        match selection {
1459            query::Selection::Field(field) => {
1460                tracker.charge_identifier(&field.name)?;
1461                let path = join_field_path(parent, &field.name);
1462                tracker.charge_string(&path)?;
1463                output.push(GraphqlSelection::Field {
1464                    path: path.clone(),
1465                    name: field.name.clone(),
1466                    alias: field.alias.clone(),
1467                    lines: line_range(field.position.line),
1468                });
1469                append_selections(
1470                    &field.selection_set,
1471                    Some(&path),
1472                    output,
1473                    spreads,
1474                    depth.saturating_add(1),
1475                    tracker,
1476                )?;
1477            }
1478            query::Selection::FragmentSpread(spread) => {
1479                tracker.charge_identifier(&spread.fragment_name)?;
1480                spreads.push(spread.fragment_name.clone());
1481                output.push(GraphqlSelection::FragmentSpread {
1482                    name: spread.fragment_name.clone(),
1483                    parent_path: parent.map(str::to_owned),
1484                    lines: line_range(spread.position.line),
1485                });
1486            }
1487            query::Selection::InlineFragment(fragment) => {
1488                let type_condition = fragment.type_condition.as_ref().map(|condition| {
1489                    let query::TypeCondition::On(name) = condition;
1490                    name.clone()
1491                });
1492                output.push(GraphqlSelection::InlineFragment {
1493                    type_condition,
1494                    parent_path: parent.map(str::to_owned),
1495                    lines: line_range(fragment.position.line),
1496                });
1497                append_selections(
1498                    &fragment.selection_set,
1499                    parent,
1500                    output,
1501                    spreads,
1502                    depth.saturating_add(1),
1503                    tracker,
1504                )?;
1505            }
1506        }
1507    }
1508    Ok(())
1509}
1510
1511fn expand_fragment(
1512    name: &str,
1513    fragments: &BTreeMap<String, query::FragmentDefinition<'_, String>>,
1514    memo: &mut BTreeMap<String, FragmentMemoState>,
1515    depth: u64,
1516    tracker: &mut ExtractionTracker,
1517) -> Result<FragmentExpansion, ExtractionLimitExceeded> {
1518    tracker.check_structural_depth(depth)?;
1519    tracker.charge_work(1)?;
1520    match memo.get(name) {
1521        Some(FragmentMemoState::Completed(expansion)) => {
1522            let materializations = expansion
1523                .paths
1524                .len()
1525                .saturating_add(expansion.spreads.len())
1526                .saturating_add(expansion.missing.len());
1527            tracker.charge_work(u64::try_from(materializations).unwrap_or(u64::MAX))?;
1528            charge_fragment_expansion(expansion, tracker)?;
1529            return Ok(expansion.clone());
1530        }
1531        Some(FragmentMemoState::InProgress) => {
1532            return Ok(FragmentExpansion {
1533                missing: [format!("cycle:{name}")].into_iter().collect(),
1534                ..FragmentExpansion::default()
1535            });
1536        }
1537        Some(FragmentMemoState::Pending) | None => {}
1538    }
1539    let Some(fragment) = fragments.get(name) else {
1540        return Ok(FragmentExpansion {
1541            missing: [name.to_owned()].into_iter().collect(),
1542            ..FragmentExpansion::default()
1543        });
1544    };
1545    memo.insert(name.to_owned(), FragmentMemoState::InProgress);
1546    let expansion = expand_selection_set(
1547        &fragment.selection_set,
1548        None,
1549        fragments,
1550        memo,
1551        depth.saturating_add(1),
1552        tracker,
1553    )?;
1554    tracker.charge_identifier(name)?;
1555    charge_fragment_expansion(&expansion, tracker)?;
1556    memo.insert(
1557        name.to_owned(),
1558        FragmentMemoState::Completed(expansion.clone()),
1559    );
1560    Ok(expansion)
1561}
1562
1563fn charge_fragment_expansion(
1564    expansion: &FragmentExpansion,
1565    tracker: &mut ExtractionTracker,
1566) -> Result<(), ExtractionLimitExceeded> {
1567    for path in &expansion.paths {
1568        tracker.charge_string(path)?;
1569    }
1570    for name in &expansion.spreads {
1571        tracker.charge_identifier(name)?;
1572    }
1573    for warning in &expansion.missing {
1574        tracker.charge_string(warning)?;
1575    }
1576    Ok(())
1577}
1578
1579fn expand_selection_set(
1580    selection_set: &query::SelectionSet<'_, String>,
1581    parent: Option<&str>,
1582    fragments: &BTreeMap<String, query::FragmentDefinition<'_, String>>,
1583    memo: &mut BTreeMap<String, FragmentMemoState>,
1584    depth: u64,
1585    tracker: &mut ExtractionTracker,
1586) -> Result<FragmentExpansion, ExtractionLimitExceeded> {
1587    tracker.check_structural_depth(depth)?;
1588    let mut expansion = FragmentExpansion::default();
1589    for selection in &selection_set.items {
1590        tracker.charge_work(1)?;
1591        match selection {
1592            query::Selection::Field(field) => {
1593                let path = join_field_path(parent, &field.name);
1594                tracker.charge_string(&path)?;
1595                expansion.paths.insert(path.clone());
1596                let nested = expand_selection_set(
1597                    &field.selection_set,
1598                    Some(&path),
1599                    fragments,
1600                    memo,
1601                    depth.saturating_add(1),
1602                    tracker,
1603                )?;
1604                merge_expansion(&mut expansion, nested);
1605            }
1606            query::Selection::InlineFragment(fragment) => {
1607                let nested = expand_selection_set(
1608                    &fragment.selection_set,
1609                    parent,
1610                    fragments,
1611                    memo,
1612                    depth.saturating_add(1),
1613                    tracker,
1614                )?;
1615                merge_expansion(&mut expansion, nested);
1616            }
1617            query::Selection::FragmentSpread(spread) => {
1618                expansion.spreads.insert(spread.fragment_name.clone());
1619                let cached = expand_fragment(
1620                    &spread.fragment_name,
1621                    fragments,
1622                    memo,
1623                    depth.saturating_add(1),
1624                    tracker,
1625                )?;
1626                expansion.spreads.extend(cached.spreads);
1627                expansion.missing.extend(cached.missing);
1628                for relative in cached.paths {
1629                    tracker.charge_work(1)?;
1630                    let materialized =
1631                        parent.map_or(relative.clone(), |prefix| format!("{prefix}.{relative}"));
1632                    tracker.charge_string(&materialized)?;
1633                    expansion.paths.insert(materialized);
1634                }
1635            }
1636        }
1637    }
1638    Ok(expansion)
1639}
1640
1641fn merge_expansion(target: &mut FragmentExpansion, source: FragmentExpansion) {
1642    target.paths.extend(source.paths);
1643    target.spreads.extend(source.spreads);
1644    target.missing.extend(source.missing);
1645}
1646
1647fn join_field_path(parent: Option<&str>, field: &str) -> String {
1648    parent.map_or_else(|| field.to_owned(), |prefix| format!("{prefix}.{field}"))
1649}
1650
1651#[derive(Debug)]
1652struct EmbeddedLiteral {
1653    text: String,
1654    start_line: u32,
1655    dynamic: bool,
1656}
1657
1658fn embedded_graphql_literals(language: SourceLanguage, input: &str) -> Vec<EmbeddedLiteral> {
1659    let markers: &[&str] = match language {
1660        SourceLanguage::JavaScript | SourceLanguage::TypeScript | SourceLanguage::Python => {
1661            &["gql", "graphql"]
1662        }
1663        SourceLanguage::Go | SourceLanguage::Java => &["graphql", "query"],
1664        SourceLanguage::Rust => &["graphql", "gql"],
1665    };
1666    let mut output = Vec::new();
1667    for marker in markers {
1668        let mut offset = 0;
1669        while let Some(relative) = input[offset..].find(marker) {
1670            let marker_start = offset.saturating_add(relative);
1671            if !word_boundary(input, marker_start, marker.len()) {
1672                offset = marker_start.saturating_add(marker.len());
1673                continue;
1674            }
1675            if let Some(literal) =
1676                literal_after_marker(input, marker_start.saturating_add(marker.len()), language)
1677                    .filter(|literal| looks_like_graphql(&literal.text) || literal.dynamic)
1678            {
1679                output.push(literal);
1680            }
1681            offset = marker_start.saturating_add(marker.len());
1682        }
1683    }
1684    output.sort_by_key(|literal| (literal.start_line, literal.text.clone()));
1685    output.dedup_by(|left, right| {
1686        left.start_line == right.start_line
1687            && left.text == right.text
1688            && left.dynamic == right.dynamic
1689    });
1690    output
1691}
1692
1693fn literal_after_marker(
1694    input: &str,
1695    mut cursor: usize,
1696    language: SourceLanguage,
1697) -> Option<EmbeddedLiteral> {
1698    cursor = skip_ascii_whitespace(input, cursor);
1699    let mut call_like = false;
1700    while matches!(
1701        input.as_bytes().get(cursor),
1702        Some(b'(' | b'!' | b':' | b'=')
1703    ) {
1704        call_like |= matches!(input.as_bytes().get(cursor), Some(b'(' | b'!'));
1705        cursor = cursor.saturating_add(1);
1706        cursor = skip_ascii_whitespace(input, cursor);
1707    }
1708    if language == SourceLanguage::Rust && input.as_bytes().get(cursor) == Some(&b'r') {
1709        return rust_raw_literal(input, cursor);
1710    }
1711    let bytes = input.as_bytes();
1712    if bytes.get(cursor..cursor.saturating_add(3)) == Some(b"\"\"\"")
1713        || bytes.get(cursor..cursor.saturating_add(3)) == Some(b"'''")
1714    {
1715        return quoted_literal(input, cursor, 3);
1716    }
1717    match bytes.get(cursor) {
1718        Some(b'`' | b'"' | b'\'') => quoted_literal(input, cursor, 1),
1719        _ if call_like => Some(EmbeddedLiteral {
1720            text: String::new(),
1721            start_line: line_at_offset(input, cursor),
1722            dynamic: true,
1723        }),
1724        _ => None,
1725    }
1726}
1727
1728fn quoted_literal(input: &str, start: usize, delimiter_len: usize) -> Option<EmbeddedLiteral> {
1729    let delimiter = input.get(start..start.saturating_add(delimiter_len))?;
1730    let content_start = start.saturating_add(delimiter_len);
1731    let mut cursor = content_start;
1732    let bytes = input.as_bytes();
1733    while cursor.saturating_add(delimiter_len) <= input.len() {
1734        if input.get(cursor..cursor.saturating_add(delimiter_len)) == Some(delimiter)
1735            && !is_escaped(bytes, cursor)
1736        {
1737            let text = input.get(content_start..cursor)?.to_owned();
1738            return Some(EmbeddedLiteral {
1739                dynamic: delimiter == "`" && text.contains("${"),
1740                text,
1741                start_line: line_at_offset(input, content_start),
1742            });
1743        }
1744        cursor = cursor.saturating_add(1);
1745    }
1746    None
1747}
1748
1749fn rust_raw_literal(input: &str, start: usize) -> Option<EmbeddedLiteral> {
1750    let bytes = input.as_bytes();
1751    let mut cursor = start.saturating_add(1);
1752    let mut hashes = 0;
1753    while bytes.get(cursor) == Some(&b'#') {
1754        hashes += 1;
1755        cursor = cursor.saturating_add(1);
1756    }
1757    if bytes.get(cursor) != Some(&b'"') {
1758        return None;
1759    }
1760    let content_start = cursor.saturating_add(1);
1761    let terminator = format!("\"{}", "#".repeat(hashes));
1762    let relative_end = input.get(content_start..)?.find(&terminator)?;
1763    let end = content_start.saturating_add(relative_end);
1764    Some(EmbeddedLiteral {
1765        text: input.get(content_start..end)?.to_owned(),
1766        start_line: line_at_offset(input, content_start),
1767        dynamic: false,
1768    })
1769}
1770
1771fn is_escaped(bytes: &[u8], index: usize) -> bool {
1772    let mut cursor = index;
1773    let mut slashes = 0;
1774    while cursor > 0 && bytes.get(cursor - 1) == Some(&b'\\') {
1775        slashes += 1;
1776        cursor -= 1;
1777    }
1778    slashes % 2 == 1
1779}
1780
1781fn skip_ascii_whitespace(input: &str, mut cursor: usize) -> usize {
1782    while input
1783        .as_bytes()
1784        .get(cursor)
1785        .is_some_and(u8::is_ascii_whitespace)
1786    {
1787        cursor = cursor.saturating_add(1);
1788    }
1789    cursor
1790}
1791
1792fn word_boundary(input: &str, start: usize, len: usize) -> bool {
1793    let bytes = input.as_bytes();
1794    let before = start.checked_sub(1).and_then(|index| bytes.get(index));
1795    let after = bytes.get(start.saturating_add(len));
1796    before.is_none_or(|byte| !is_identifier_byte(*byte))
1797        && after.is_none_or(|byte| !is_identifier_byte(*byte))
1798}
1799
1800fn is_identifier_byte(byte: u8) -> bool {
1801    byte.is_ascii_alphanumeric() || byte == b'_'
1802}
1803
1804fn looks_like_graphql(value: &str) -> bool {
1805    let trimmed = value.trim_start_matches(|character: char| character.is_whitespace());
1806    [
1807        "query",
1808        "mutation",
1809        "subscription",
1810        "fragment",
1811        "type",
1812        "interface",
1813        "input",
1814        "enum",
1815        "union",
1816        "scalar",
1817        "schema",
1818        "extend",
1819        "{",
1820    ]
1821    .iter()
1822    .any(|prefix| trimmed.starts_with(prefix))
1823}
1824
1825#[expect(
1826    clippy::too_many_lines,
1827    reason = "one lexical pass keeps depth, values, work, time, and observation reservation aligned"
1828)]
1829fn precheck_graphql_depth(
1830    input: &str,
1831    tracker: &mut ExtractionTracker,
1832) -> Result<(), ExtractionLimitExceeded> {
1833    let bytes = input.as_bytes();
1834    let mut cursor = 0;
1835    let mut depth = 0_u64;
1836    let mut quoted = false;
1837    let mut block_quoted = false;
1838    let mut escaped = false;
1839    let mut comment = false;
1840    let mut string_start = None;
1841    let mut accumulated_string_bytes = 0_u64;
1842    let mut prospective_observations = 0_u64;
1843    let mut pending_top_level_definition = false;
1844    while cursor < bytes.len() {
1845        if cursor.is_multiple_of(1_024) {
1846            tracker.check_structured_time()?;
1847        }
1848        let byte = bytes[cursor];
1849        if comment {
1850            comment = byte != b'\n';
1851            cursor += 1;
1852            continue;
1853        }
1854        if block_quoted {
1855            if bytes.get(cursor..cursor.saturating_add(3)) == Some(b"\"\"\"") {
1856                let observed = cursor.saturating_sub(string_start.unwrap_or(cursor));
1857                check_graphql_lexical_value(
1858                    observed,
1859                    &mut accumulated_string_bytes,
1860                    tracker,
1861                    false,
1862                )?;
1863                block_quoted = false;
1864                string_start = None;
1865                cursor += 3;
1866            } else {
1867                cursor += 1;
1868            }
1869            continue;
1870        }
1871        if quoted {
1872            if escaped {
1873                escaped = false;
1874            } else if byte == b'\\' {
1875                escaped = true;
1876            } else if byte == b'"' {
1877                let observed = cursor.saturating_sub(string_start.unwrap_or(cursor));
1878                check_graphql_lexical_value(
1879                    observed,
1880                    &mut accumulated_string_bytes,
1881                    tracker,
1882                    false,
1883                )?;
1884                quoted = false;
1885                string_start = None;
1886            }
1887            cursor += 1;
1888            continue;
1889        }
1890        if byte == b'#' {
1891            comment = true;
1892        } else if bytes.get(cursor..cursor.saturating_add(3)) == Some(b"\"\"\"") {
1893            tracker.charge_work(1)?;
1894            block_quoted = true;
1895            string_start = Some(cursor.saturating_add(3));
1896            cursor += 3;
1897            continue;
1898        } else if byte == b'"' {
1899            tracker.charge_work(1)?;
1900            quoted = true;
1901            string_start = Some(cursor.saturating_add(1));
1902        } else if matches!(byte, b'{' | b'[' | b'(') {
1903            tracker.charge_work(1)?;
1904            if byte == b'{' && depth == 0 {
1905                if !pending_top_level_definition {
1906                    prospective_observations = prospective_observations.saturating_add(1);
1907                    tracker.check_observations(prospective_observations)?;
1908                }
1909                pending_top_level_definition = false;
1910            }
1911            depth = depth.saturating_add(1);
1912            tracker.check_structural_depth(depth)?;
1913        } else if matches!(byte, b'}' | b']' | b')') {
1914            tracker.charge_work(1)?;
1915            depth = depth.saturating_sub(1);
1916        } else if byte.is_ascii_alphabetic() || byte == b'_' {
1917            let start = cursor;
1918            cursor += 1;
1919            while bytes
1920                .get(cursor)
1921                .is_some_and(|value| value.is_ascii_alphanumeric() || *value == b'_')
1922            {
1923                cursor += 1;
1924            }
1925            check_graphql_lexical_value(
1926                cursor.saturating_sub(start),
1927                &mut accumulated_string_bytes,
1928                tracker,
1929                true,
1930            )?;
1931            tracker.charge_work(1)?;
1932            if depth == 0
1933                && matches!(
1934                    &input[start..cursor],
1935                    "type"
1936                        | "interface"
1937                        | "input"
1938                        | "enum"
1939                        | "union"
1940                        | "scalar"
1941                        | "schema"
1942                        | "directive"
1943                        | "query"
1944                        | "mutation"
1945                        | "subscription"
1946                        | "fragment"
1947                )
1948            {
1949                prospective_observations = prospective_observations.saturating_add(1);
1950                tracker.check_observations(prospective_observations)?;
1951                pending_top_level_definition = true;
1952            }
1953            continue;
1954        } else if byte == b'@' {
1955            tracker.charge_work(1)?;
1956            prospective_observations = prospective_observations.saturating_add(1);
1957            tracker.check_observations(prospective_observations)?;
1958        } else if !byte.is_ascii_whitespace() {
1959            tracker.charge_work(1)?;
1960        }
1961        cursor += 1;
1962    }
1963    Ok(())
1964}
1965
1966fn check_graphql_lexical_value(
1967    observed: usize,
1968    accumulated: &mut u64,
1969    tracker: &ExtractionTracker,
1970    identifier: bool,
1971) -> Result<(), ExtractionLimitExceeded> {
1972    let observed = u64::try_from(observed).unwrap_or(u64::MAX);
1973    if identifier {
1974        tracker.check_identifier_bytes(observed)?;
1975    } else {
1976        tracker.check_string_bytes(observed)?;
1977    }
1978    *accumulated = accumulated.saturating_add(observed);
1979    tracker.check_accumulated_string_bytes(*accumulated)
1980}
1981
1982pub(crate) fn precheck_json_structure(
1983    input: &str,
1984    tracker: &mut ExtractionTracker,
1985) -> Result<(), ExtractionLimitExceeded> {
1986    let bytes = input.as_bytes();
1987    let mut cursor = 0_usize;
1988    let mut depth = 0_u64;
1989    let mut accumulated = 0_u64;
1990    while cursor < bytes.len() {
1991        if cursor.is_multiple_of(1_024) {
1992            tracker.check_structured_time()?;
1993        }
1994        match bytes[cursor] {
1995            b'{' | b'[' => {
1996                tracker.charge_work(1)?;
1997                depth = depth.saturating_add(1);
1998                tracker.check_structural_depth(depth)?;
1999                cursor += 1;
2000            }
2001            b'}' | b']' => {
2002                tracker.charge_work(1)?;
2003                depth = depth.saturating_sub(1);
2004                cursor += 1;
2005            }
2006            b'"' => {
2007                tracker.charge_work(1)?;
2008                cursor += 1;
2009                let start = cursor;
2010                let mut escaped = false;
2011                while cursor < bytes.len() {
2012                    let byte = bytes[cursor];
2013                    if escaped {
2014                        escaped = false;
2015                    } else if byte == b'\\' {
2016                        escaped = true;
2017                    } else if byte == b'"' {
2018                        break;
2019                    }
2020                    cursor += 1;
2021                    if cursor.is_multiple_of(1_024) {
2022                        tracker.check_structured_time()?;
2023                    }
2024                }
2025                let observed = json_decoded_string_bytes(&bytes[start..cursor]);
2026                tracker.check_string_bytes(observed)?;
2027                accumulated = accumulated.saturating_add(observed);
2028                tracker.check_accumulated_string_bytes(accumulated)?;
2029                cursor = cursor.saturating_add(1);
2030            }
2031            byte if byte.is_ascii_whitespace() || matches!(byte, b',' | b':') => cursor += 1,
2032            _ => {
2033                tracker.charge_work(1)?;
2034                cursor += 1;
2035                while cursor < bytes.len()
2036                    && !bytes[cursor].is_ascii_whitespace()
2037                    && !matches!(bytes[cursor], b',' | b':' | b'}' | b']')
2038                {
2039                    cursor += 1;
2040                }
2041            }
2042        }
2043    }
2044    Ok(())
2045}
2046
2047fn json_decoded_string_bytes(bytes: &[u8]) -> u64 {
2048    let mut cursor = 0_usize;
2049    let mut decoded = 0_u64;
2050    while cursor < bytes.len() {
2051        if bytes[cursor] != b'\\' {
2052            decoded = decoded.saturating_add(1);
2053            cursor += 1;
2054            continue;
2055        }
2056        let Some(escape) = bytes.get(cursor.saturating_add(1)).copied() else {
2057            return u64::try_from(bytes.len()).unwrap_or(u64::MAX);
2058        };
2059        if escape != b'u' {
2060            decoded = decoded.saturating_add(1);
2061            cursor = cursor.saturating_add(2);
2062            continue;
2063        }
2064        let Some(first) = parse_json_hex_quad(bytes.get(cursor.saturating_add(2)..)) else {
2065            return u64::try_from(bytes.len()).unwrap_or(u64::MAX);
2066        };
2067        cursor = cursor.saturating_add(6);
2068        let scalar = if (0xD800..=0xDBFF).contains(&first)
2069            && bytes.get(cursor..cursor.saturating_add(2)) == Some(b"\\u")
2070        {
2071            let Some(second) = parse_json_hex_quad(bytes.get(cursor.saturating_add(2)..)) else {
2072                return u64::try_from(bytes.len()).unwrap_or(u64::MAX);
2073            };
2074            if !(0xDC00..=0xDFFF).contains(&second) {
2075                return u64::try_from(bytes.len()).unwrap_or(u64::MAX);
2076            }
2077            cursor = cursor.saturating_add(6);
2078            0x1_0000 + ((u32::from(first) - 0xD800) << 10) + (u32::from(second) - 0xDC00)
2079        } else {
2080            u32::from(first)
2081        };
2082        let Some(character) = char::from_u32(scalar) else {
2083            return u64::try_from(bytes.len()).unwrap_or(u64::MAX);
2084        };
2085        decoded = decoded.saturating_add(u64::try_from(character.len_utf8()).unwrap_or(u64::MAX));
2086    }
2087    decoded
2088}
2089
2090fn parse_json_hex_quad(bytes: Option<&[u8]>) -> Option<u16> {
2091    let bytes = bytes?.get(..4)?;
2092    let mut value = 0_u16;
2093    for byte in bytes {
2094        value = value.checked_mul(16)?;
2095        value = value.checked_add(u16::from(match byte {
2096            b'0'..=b'9' => byte - b'0',
2097            b'a'..=b'f' => byte - b'a' + 10,
2098            b'A'..=b'F' => byte - b'A' + 10,
2099            _ => return None,
2100        }))?;
2101    }
2102    Some(value)
2103}
2104
2105fn charge_schema_work(
2106    document: &schema::Document<'_, String>,
2107    tracker: &mut ExtractionTracker,
2108) -> Result<(), ExtractionLimitExceeded> {
2109    let units = document
2110        .definitions
2111        .iter()
2112        .fold(0_u64, |total, definition| {
2113            total.saturating_add(schema_definition_work_units(definition))
2114        });
2115    let mut remaining = units;
2116    while remaining > 0 {
2117        let chunk = remaining.min(1_024);
2118        tracker.charge_work(chunk)?;
2119        remaining -= chunk;
2120    }
2121    Ok(())
2122}
2123
2124fn schema_definition_work_units(definition: &schema::Definition<'_, String>) -> u64 {
2125    let nested = match definition {
2126        schema::Definition::SchemaDefinition(value) => directives_work_units(&value.directives),
2127        schema::Definition::TypeDefinition(value) => type_definition_work_units(value),
2128        schema::Definition::TypeExtension(value) => type_extension_work_units(value),
2129        schema::Definition::DirectiveDefinition(value) => input_values_work_units(&value.arguments),
2130    };
2131    1_u64.saturating_add(nested)
2132}
2133
2134fn type_definition_work_units(definition: &schema::TypeDefinition<'_, String>) -> u64 {
2135    match definition {
2136        schema::TypeDefinition::Scalar(value) => directives_work_units(&value.directives),
2137        schema::TypeDefinition::Object(value) => composite_work_units(
2138            &value.directives,
2139            value.implements_interfaces.len(),
2140            &value.fields,
2141        ),
2142        schema::TypeDefinition::Interface(value) => composite_work_units(
2143            &value.directives,
2144            value.implements_interfaces.len(),
2145            &value.fields,
2146        ),
2147        schema::TypeDefinition::Union(value) => {
2148            directives_work_units(&value.directives).saturating_add(usize_to_u64(value.types.len()))
2149        }
2150        schema::TypeDefinition::Enum(value) => directives_work_units(&value.directives)
2151            .saturating_add(value.values.iter().fold(0_u64, |total, enum_value| {
2152                total
2153                    .saturating_add(1)
2154                    .saturating_add(directives_work_units(&enum_value.directives))
2155            })),
2156        schema::TypeDefinition::InputObject(value) => directives_work_units(&value.directives)
2157            .saturating_add(input_values_work_units(&value.fields)),
2158    }
2159}
2160
2161fn type_extension_work_units(extension: &schema::TypeExtension<'_, String>) -> u64 {
2162    match extension {
2163        schema::TypeExtension::Scalar(value) => directives_work_units(&value.directives),
2164        schema::TypeExtension::Object(value) => composite_work_units(
2165            &value.directives,
2166            value.implements_interfaces.len(),
2167            &value.fields,
2168        ),
2169        schema::TypeExtension::Interface(value) => composite_work_units(
2170            &value.directives,
2171            value.implements_interfaces.len(),
2172            &value.fields,
2173        ),
2174        schema::TypeExtension::Union(value) => {
2175            directives_work_units(&value.directives).saturating_add(usize_to_u64(value.types.len()))
2176        }
2177        schema::TypeExtension::Enum(value) => directives_work_units(&value.directives)
2178            .saturating_add(value.values.iter().fold(0_u64, |total, enum_value| {
2179                total
2180                    .saturating_add(1)
2181                    .saturating_add(directives_work_units(&enum_value.directives))
2182            })),
2183        schema::TypeExtension::InputObject(value) => directives_work_units(&value.directives)
2184            .saturating_add(input_values_work_units(&value.fields)),
2185    }
2186}
2187
2188fn composite_work_units(
2189    directives: &[schema::Directive<'_, String>],
2190    implements: usize,
2191    fields: &[schema::Field<'_, String>],
2192) -> u64 {
2193    directives_work_units(directives)
2194        .saturating_add(usize_to_u64(implements))
2195        .saturating_add(fields.iter().fold(0_u64, |total, field| {
2196            total
2197                .saturating_add(1)
2198                .saturating_add(input_values_work_units(&field.arguments))
2199                .saturating_add(directives_work_units(&field.directives))
2200        }))
2201}
2202
2203fn input_values_work_units(values: &[schema::InputValue<'_, String>]) -> u64 {
2204    values.iter().fold(0_u64, |total, value| {
2205        total
2206            .saturating_add(1)
2207            .saturating_add(directives_work_units(&value.directives))
2208            .saturating_add(
2209                value
2210                    .default_value
2211                    .as_ref()
2212                    .map_or(0, schema_value_work_units),
2213            )
2214    })
2215}
2216
2217fn directives_work_units(directives: &[schema::Directive<'_, String>]) -> u64 {
2218    directives.iter().fold(0_u64, |total, directive| {
2219        total
2220            .saturating_add(1)
2221            .saturating_add(
2222                directive
2223                    .arguments
2224                    .iter()
2225                    .fold(0_u64, |arguments, (_, value)| {
2226                        arguments
2227                            .saturating_add(1)
2228                            .saturating_add(schema_value_work_units(value))
2229                    }),
2230            )
2231    })
2232}
2233
2234fn schema_value_work_units(value: &schema::Value<'_, String>) -> u64 {
2235    let nested = match value {
2236        schema::Value::List(values) => values.iter().fold(0_u64, |total, value| {
2237            total.saturating_add(schema_value_work_units(value))
2238        }),
2239        schema::Value::Object(values) => values.values().fold(0_u64, |total, value| {
2240            total
2241                .saturating_add(1)
2242                .saturating_add(schema_value_work_units(value))
2243        }),
2244        schema::Value::Variable(_)
2245        | schema::Value::Int(_)
2246        | schema::Value::Float(_)
2247        | schema::Value::String(_)
2248        | schema::Value::Boolean(_)
2249        | schema::Value::Null
2250        | schema::Value::Enum(_) => 0,
2251    };
2252    1_u64.saturating_add(nested)
2253}
2254
2255fn usize_to_u64(value: usize) -> u64 {
2256    u64::try_from(value).unwrap_or(u64::MAX)
2257}
2258
2259fn charge_graphql_document(
2260    document: &GraphqlDocument,
2261    tracker: &mut ExtractionTracker,
2262    include_resolvers: bool,
2263) -> Result<(), ExtractionLimitExceeded> {
2264    tracker.charge_portable_path(&document.source_path)?;
2265    let observations = document
2266        .types
2267        .len()
2268        .saturating_add(document.operations.len())
2269        .saturating_add(document.fragments.len())
2270        .saturating_add(document.persisted_operations.len())
2271        .saturating_add(if include_resolvers {
2272            document.resolvers.len()
2273        } else {
2274            0
2275        })
2276        .saturating_add(document.federation.len());
2277    tracker.charge_observation(u64::try_from(observations).unwrap_or(u64::MAX))?;
2278    for definition in &document.types {
2279        tracker.charge_identifier(&definition.name)?;
2280        charge_identifiers(&definition.implements, tracker)?;
2281        charge_identifiers(&definition.enum_values, tracker)?;
2282        charge_identifiers(&definition.union_members, tracker)?;
2283        charge_identifiers(&definition.directives, tracker)?;
2284        for field in &definition.fields {
2285            tracker.charge_identifier(&field.coordinate)?;
2286            tracker.charge_identifier(&field.name)?;
2287            charge_type_ref(&field.type_ref, tracker)?;
2288            charge_identifiers(&field.directives, tracker)?;
2289            for argument in &field.arguments {
2290                charge_argument(argument, tracker)?;
2291            }
2292        }
2293    }
2294    for operation in &document.operations {
2295        if let Some(name) = &operation.name {
2296            tracker.charge_identifier(name)?;
2297        }
2298        for variable in &operation.variables {
2299            charge_argument(variable, tracker)?;
2300        }
2301        charge_selections(&operation.selections, tracker)?;
2302        charge_strings(&operation.consumed_field_paths, tracker)?;
2303        charge_identifiers(&operation.fragment_spreads, tracker)?;
2304        charge_strings(&operation.warnings, tracker)?;
2305    }
2306    for fragment in &document.fragments {
2307        tracker.charge_identifier(&fragment.name)?;
2308        tracker.charge_identifier(&fragment.type_condition)?;
2309        charge_selections(&fragment.selections, tracker)?;
2310        charge_strings(&fragment.consumed_field_paths, tracker)?;
2311        charge_identifiers(&fragment.fragment_spreads, tracker)?;
2312    }
2313    for persisted in &document.persisted_operations {
2314        tracker.charge_identifier(&persisted.id)?;
2315        if let Some(name) = &persisted.operation_name {
2316            tracker.charge_identifier(name)?;
2317        }
2318        charge_strings(&persisted.consumed_field_paths, tracker)?;
2319        charge_strings(&persisted.warnings, tracker)?;
2320        tracker.charge_portable_path(&persisted.source_path)?;
2321    }
2322    if include_resolvers {
2323        for resolver in &document.resolvers {
2324            tracker.charge_identifier(&resolver.type_name)?;
2325            tracker.charge_identifier(&resolver.field_name)?;
2326            tracker.charge_identifier(&resolver.coordinate)?;
2327            tracker.charge_identifier(&resolver.symbol)?;
2328        }
2329    }
2330    for metadata in &document.federation {
2331        tracker.charge_identifier(&metadata.directive)?;
2332        tracker.charge_identifier(&metadata.target)?;
2333        for name in metadata.arguments.keys() {
2334            tracker.charge_identifier(name)?;
2335        }
2336    }
2337    charge_strings(&document.warnings, tracker)?;
2338    Ok(())
2339}
2340
2341fn charge_argument(
2342    argument: &GraphqlArgumentDefinition,
2343    tracker: &mut ExtractionTracker,
2344) -> Result<(), ExtractionLimitExceeded> {
2345    tracker.charge_identifier(&argument.name)?;
2346    charge_type_ref(&argument.type_ref, tracker)?;
2347    charge_identifiers(&argument.directives, tracker)
2348}
2349
2350fn charge_type_ref(
2351    type_ref: &GraphqlTypeRef,
2352    tracker: &mut ExtractionTracker,
2353) -> Result<(), ExtractionLimitExceeded> {
2354    match type_ref {
2355        GraphqlTypeRef::Named { name, .. } => tracker.charge_identifier(name),
2356        GraphqlTypeRef::List { element, .. } => charge_type_ref(element, tracker),
2357    }
2358}
2359
2360fn charge_selections(
2361    selections: &[GraphqlSelection],
2362    tracker: &mut ExtractionTracker,
2363) -> Result<(), ExtractionLimitExceeded> {
2364    for selection in selections {
2365        match selection {
2366            GraphqlSelection::Field {
2367                path, name, alias, ..
2368            } => {
2369                tracker.charge_string(path)?;
2370                tracker.charge_identifier(name)?;
2371                if let Some(alias) = alias {
2372                    tracker.charge_identifier(alias)?;
2373                }
2374            }
2375            GraphqlSelection::FragmentSpread {
2376                name, parent_path, ..
2377            } => {
2378                tracker.charge_identifier(name)?;
2379                if let Some(path) = parent_path {
2380                    tracker.charge_string(path)?;
2381                }
2382            }
2383            GraphqlSelection::InlineFragment {
2384                type_condition,
2385                parent_path,
2386                ..
2387            } => {
2388                if let Some(name) = type_condition {
2389                    tracker.charge_identifier(name)?;
2390                }
2391                if let Some(path) = parent_path {
2392                    tracker.charge_string(path)?;
2393                }
2394            }
2395        }
2396    }
2397    Ok(())
2398}
2399
2400fn charge_identifiers(
2401    values: &[String],
2402    tracker: &mut ExtractionTracker,
2403) -> Result<(), ExtractionLimitExceeded> {
2404    for value in values {
2405        tracker.charge_identifier(value)?;
2406    }
2407    Ok(())
2408}
2409
2410fn charge_strings(
2411    values: &[String],
2412    tracker: &mut ExtractionTracker,
2413) -> Result<(), ExtractionLimitExceeded> {
2414    for value in values {
2415        tracker.charge_string(value)?;
2416    }
2417    Ok(())
2418}
2419
2420fn extract_resolvers(
2421    language: SourceLanguage,
2422    input: &str,
2423    tracker: &mut ExtractionTracker,
2424) -> Result<Vec<GraphqlResolver>, ExtractionLimitExceeded> {
2425    let mut output = match language {
2426        SourceLanguage::JavaScript | SourceLanguage::TypeScript => {
2427            extract_ecmascript_resolvers(language, input, tracker)
2428        }
2429        SourceLanguage::Python => extract_python_resolvers(input, tracker),
2430        SourceLanguage::Go => extract_go_resolvers(input, tracker),
2431        SourceLanguage::Java => extract_java_resolvers(input, tracker),
2432        SourceLanguage::Rust => extract_rust_resolvers(input, tracker),
2433    }?;
2434    output.sort();
2435    output.dedup();
2436    Ok(output)
2437}
2438
2439fn extract_ecmascript_resolvers(
2440    language: SourceLanguage,
2441    input: &str,
2442    tracker: &mut ExtractionTracker,
2443) -> Result<Vec<GraphqlResolver>, ExtractionLimitExceeded> {
2444    let mut output = Vec::new();
2445    let mut in_resolvers = false;
2446    let mut current_type: Option<(String, i32)> = None;
2447    let mut depth = 0_i32;
2448    for (index, line) in input.lines().enumerate() {
2449        let trimmed = line.trim();
2450        if !in_resolvers && resolver_object_start(trimmed) {
2451            in_resolvers = true;
2452            depth = brace_delta(trimmed);
2453            continue;
2454        }
2455        if !in_resolvers {
2456            continue;
2457        }
2458        let previous_depth = depth;
2459        depth += brace_delta(trimmed);
2460        if let Some((name, _)) = &current_type {
2461            if let Some((field, symbol)) = object_symbol_mapping(trimmed) {
2462                push_resolver(
2463                    &mut output,
2464                    name,
2465                    &field,
2466                    &symbol,
2467                    language,
2468                    index.saturating_add(1),
2469                    tracker,
2470                )?;
2471            }
2472        } else if let Some(type_name) = object_type_header(trimmed) {
2473            current_type = Some((type_name, previous_depth));
2474        }
2475        if current_type
2476            .as_ref()
2477            .is_some_and(|(_, type_depth)| depth <= *type_depth)
2478        {
2479            current_type = None;
2480        }
2481        if depth <= 0 {
2482            in_resolvers = false;
2483            current_type = None;
2484        }
2485    }
2486    Ok(output)
2487}
2488
2489fn resolver_object_start(line: &str) -> bool {
2490    if line.contains("resolvers: {") {
2491        return true;
2492    }
2493    let Some((declaration, value)) = line.split_once('=') else {
2494        return false;
2495    };
2496    declaration
2497        .split(|character: char| !(character.is_ascii_alphanumeric() || character == '_'))
2498        .any(|word| word == "resolvers")
2499        && value.trim_start().starts_with('{')
2500}
2501
2502fn object_type_header(line: &str) -> Option<String> {
2503    let (name, rest) = line.split_once(':')?;
2504    let name = name
2505        .trim()
2506        .trim_matches(|character| character == '\'' || character == '"');
2507    let rest = rest.trim_start();
2508    (valid_graphql_name(name) && rest.starts_with('{')).then(|| name.to_owned())
2509}
2510
2511fn object_symbol_mapping(line: &str) -> Option<(String, String)> {
2512    let (field, rest) = line.split_once(':')?;
2513    let field = field
2514        .trim()
2515        .trim_matches(|character| character == '\'' || character == '"');
2516    let symbol = rest
2517        .trim()
2518        .trim_end_matches(',')
2519        .split_whitespace()
2520        .next()
2521        .unwrap_or_default();
2522    (valid_graphql_name(field)
2523        && valid_symbol(symbol)
2524        && !matches!(symbol, "function" | "async")
2525        && !rest.contains("=>"))
2526    .then(|| (field.to_owned(), symbol.to_owned()))
2527}
2528
2529fn extract_python_resolvers(
2530    input: &str,
2531    tracker: &mut ExtractionTracker,
2532) -> Result<Vec<GraphqlResolver>, ExtractionLimitExceeded> {
2533    let mut output = Vec::new();
2534    let mut pending_ariadne: Option<(String, String, usize)> = None;
2535    let mut pending_strawberry = false;
2536    let mut pending_strawberry_type = false;
2537    let mut class_type: Option<(String, usize)> = None;
2538    for (index, line) in input.lines().enumerate() {
2539        let trimmed = line.trim();
2540        if let Some((receiver, field)) = python_field_decorator(trimmed) {
2541            pending_ariadne = Some((receiver_to_type(&receiver), field, index.saturating_add(1)));
2542            continue;
2543        }
2544        if matches!(trimmed, "@strawberry.field" | "@strawberry.mutation") {
2545            pending_strawberry = true;
2546            continue;
2547        }
2548        if trimmed == "@strawberry.type" {
2549            pending_strawberry_type = true;
2550            continue;
2551        }
2552        if let Some(name) = python_graphql_class(trimmed, pending_strawberry_type) {
2553            class_type = Some((name, indentation(line)));
2554            pending_strawberry_type = false;
2555            continue;
2556        }
2557        if !trimmed.starts_with('@') && !trimmed.is_empty() {
2558            pending_strawberry_type = false;
2559        }
2560        if let Some((name, indent)) = &class_type {
2561            if !trimmed.is_empty() && indentation(line) <= *indent && !trimmed.starts_with('@') {
2562                class_type = None;
2563            } else if let Some(symbol) = python_function_name(trimmed) {
2564                if pending_strawberry {
2565                    push_resolver(
2566                        &mut output,
2567                        name,
2568                        &symbol,
2569                        &format!("{name}.{symbol}"),
2570                        SourceLanguage::Python,
2571                        index.saturating_add(1),
2572                        tracker,
2573                    )?;
2574                    pending_strawberry = false;
2575                } else if let Some(field) = symbol.strip_prefix("resolve_") {
2576                    push_resolver(
2577                        &mut output,
2578                        name,
2579                        field,
2580                        &format!("{name}.{symbol}"),
2581                        SourceLanguage::Python,
2582                        index.saturating_add(1),
2583                        tracker,
2584                    )?;
2585                }
2586            }
2587        }
2588        if let Some(symbol) = python_function_name(trimmed) {
2589            if let Some((type_name, field, line_number)) = pending_ariadne.take() {
2590                push_resolver(
2591                    &mut output,
2592                    &type_name,
2593                    &field,
2594                    &symbol,
2595                    SourceLanguage::Python,
2596                    line_number,
2597                    tracker,
2598                )?;
2599            }
2600        } else if !trimmed.starts_with('@') && !trimmed.is_empty() {
2601            pending_ariadne = None;
2602        }
2603    }
2604    Ok(output)
2605}
2606
2607fn python_field_decorator(line: &str) -> Option<(String, String)> {
2608    let value = line.strip_prefix('@')?;
2609    let (receiver, rest) = value.split_once(".field(")?;
2610    let field = first_quoted_value(rest)?;
2611    (valid_symbol(receiver) && valid_graphql_name(&field)).then(|| (receiver.to_owned(), field))
2612}
2613
2614fn python_graphql_class(line: &str, strawberry_type: bool) -> Option<String> {
2615    let rest = line.strip_prefix("class ")?;
2616    let name = rest.split(['(', ':']).next()?.trim();
2617    let supported = strawberry_type
2618        || rest.contains("graphene.ObjectType")
2619        || rest.contains("ObjectType")
2620        || matches!(name, "Query" | "Mutation" | "Subscription");
2621    (supported && valid_graphql_name(name)).then(|| name.to_owned())
2622}
2623
2624fn python_function_name(line: &str) -> Option<String> {
2625    let rest = line
2626        .strip_prefix("def ")
2627        .or_else(|| line.strip_prefix("async def "))?;
2628    let name = rest.split('(').next()?.trim();
2629    valid_graphql_name(name).then(|| name.to_owned())
2630}
2631
2632fn extract_go_resolvers(
2633    input: &str,
2634    tracker: &mut ExtractionTracker,
2635) -> Result<Vec<GraphqlResolver>, ExtractionLimitExceeded> {
2636    let mut output = Vec::new();
2637    for (index, line) in input.lines().enumerate() {
2638        let trimmed = line.trim();
2639        let Some(rest) = trimmed.strip_prefix("func (") else {
2640            continue;
2641        };
2642        let Some((receiver, method_part)) = rest.split_once(") ") else {
2643            continue;
2644        };
2645        let Some(receiver_type) = receiver.split_whitespace().last() else {
2646            continue;
2647        };
2648        let receiver_type = receiver_type.trim_start_matches('*');
2649        let Some(base) = receiver_type.strip_suffix("Resolver") else {
2650            continue;
2651        };
2652        let Some(method) = method_part.split('(').next() else {
2653            continue;
2654        };
2655        if !valid_graphql_name(method) || !valid_graphql_name(base) {
2656            continue;
2657        }
2658        let type_name = upper_first(base);
2659        push_resolver(
2660            &mut output,
2661            &type_name,
2662            &lower_first(method),
2663            &format!("{receiver_type}.{method}"),
2664            SourceLanguage::Go,
2665            index.saturating_add(1),
2666            tracker,
2667        )?;
2668    }
2669    Ok(output)
2670}
2671
2672fn extract_java_resolvers(
2673    input: &str,
2674    tracker: &mut ExtractionTracker,
2675) -> Result<Vec<GraphqlResolver>, ExtractionLimitExceeded> {
2676    let mut output = Vec::new();
2677    let mut pending: Option<(String, Option<String>, usize)> = None;
2678    let mut class_name = String::new();
2679    for (index, line) in input.lines().enumerate() {
2680        let trimmed = line.trim();
2681        if let Some(name) = java_class_name(trimmed) {
2682            class_name = name;
2683        }
2684        if trimmed.starts_with("@QueryMapping") {
2685            pending = Some((
2686                "Query".to_owned(),
2687                annotation_value(trimmed),
2688                index.saturating_add(1),
2689            ));
2690            continue;
2691        }
2692        if trimmed.starts_with("@MutationMapping") {
2693            pending = Some((
2694                "Mutation".to_owned(),
2695                annotation_value(trimmed),
2696                index.saturating_add(1),
2697            ));
2698            continue;
2699        }
2700        if trimmed.starts_with("@SubscriptionMapping") {
2701            pending = Some((
2702                "Subscription".to_owned(),
2703                annotation_value(trimmed),
2704                index.saturating_add(1),
2705            ));
2706            continue;
2707        }
2708        if trimmed.starts_with("@SchemaMapping") {
2709            if let Some((type_name, field)) = schema_mapping_arguments(trimmed) {
2710                pending = Some((type_name, field, index.saturating_add(1)));
2711            }
2712            continue;
2713        }
2714        if trimmed.starts_with("@DgsData") {
2715            if let (Some(type_name), Some(field)) = (
2716                named_annotation_value(trimmed, "parentType"),
2717                named_annotation_value(trimmed, "field"),
2718            ) {
2719                pending = Some((type_name, Some(field), index.saturating_add(1)));
2720            }
2721            continue;
2722        }
2723        if trimmed.starts_with("@DgsQuery") {
2724            pending = Some((
2725                "Query".to_owned(),
2726                named_annotation_value(trimmed, "field"),
2727                index.saturating_add(1),
2728            ));
2729            continue;
2730        }
2731        if trimmed.starts_with("@DgsMutation") {
2732            pending = Some((
2733                "Mutation".to_owned(),
2734                named_annotation_value(trimmed, "field"),
2735                index.saturating_add(1),
2736            ));
2737            continue;
2738        }
2739        let Some((type_name, field_override, evidence_line)) = pending.take() else {
2740            continue;
2741        };
2742        let Some(method) = java_method_name(trimmed) else {
2743            if trimmed.starts_with('@') || trimmed.is_empty() {
2744                pending = Some((type_name, field_override, evidence_line));
2745            }
2746            continue;
2747        };
2748        let field = field_override.unwrap_or_else(|| method.clone());
2749        let symbol = if class_name.is_empty() {
2750            method.clone()
2751        } else {
2752            format!("{class_name}.{method}")
2753        };
2754        push_resolver(
2755            &mut output,
2756            &type_name,
2757            &field,
2758            &symbol,
2759            SourceLanguage::Java,
2760            evidence_line,
2761            tracker,
2762        )?;
2763    }
2764    Ok(output)
2765}
2766
2767fn java_class_name(line: &str) -> Option<String> {
2768    let (_, rest) = line.split_once("class ")?;
2769    let name = rest
2770        .split(|character: char| character.is_whitespace() || character == '{')
2771        .next()?;
2772    valid_symbol(name).then(|| name.to_owned())
2773}
2774
2775fn java_method_name(line: &str) -> Option<String> {
2776    if !line.contains('(')
2777        || matches!(
2778            line.split_whitespace().next(),
2779            Some("if" | "for" | "while" | "switch")
2780        )
2781    {
2782        return None;
2783    }
2784    let prefix = line.split('(').next()?.trim();
2785    let name = prefix.split_whitespace().last()?;
2786    valid_symbol(name).then(|| name.to_owned())
2787}
2788
2789fn schema_mapping_arguments(line: &str) -> Option<(String, Option<String>)> {
2790    let type_name = named_annotation_value(line, "typeName")?;
2791    let field = named_annotation_value(line, "field");
2792    Some((type_name, field))
2793}
2794
2795fn annotation_value(line: &str) -> Option<String> {
2796    first_quoted_value(line)
2797}
2798
2799fn named_annotation_value(line: &str, name: &str) -> Option<String> {
2800    let (_, rest) = line.split_once(name)?;
2801    let (_, value) = rest.split_once('=')?;
2802    first_quoted_value(value)
2803}
2804
2805fn extract_rust_resolvers(
2806    input: &str,
2807    tracker: &mut ExtractionTracker,
2808) -> Result<Vec<GraphqlResolver>, ExtractionLimitExceeded> {
2809    let mut output = Vec::new();
2810    let mut graphql_attribute = false;
2811    let mut active_impl: Option<(String, i32)> = None;
2812    let mut depth = 0_i32;
2813    for (index, line) in input.lines().enumerate() {
2814        let trimmed = line.trim();
2815        if matches!(
2816            trimmed,
2817            "#[Object]"
2818                | "#[graphql_object]"
2819                | "#[Subscription]"
2820                | "#[ComplexObject]"
2821                | "#[juniper::graphql_object]"
2822        ) {
2823            graphql_attribute = true;
2824            continue;
2825        }
2826        let previous_depth = depth;
2827        depth += brace_delta(trimmed);
2828        if graphql_attribute {
2829            if let Some(type_name) = rust_impl_type(trimmed) {
2830                active_impl = Some((type_name, previous_depth));
2831                graphql_attribute = false;
2832                continue;
2833            }
2834            if !trimmed.starts_with('#') && !trimmed.is_empty() {
2835                graphql_attribute = false;
2836            }
2837        }
2838        if let Some((type_name, impl_depth)) = &active_impl {
2839            if let Some(method) = rust_function_name(trimmed) {
2840                push_resolver(
2841                    &mut output,
2842                    type_name,
2843                    &method,
2844                    &format!("{type_name}::{method}"),
2845                    SourceLanguage::Rust,
2846                    index.saturating_add(1),
2847                    tracker,
2848                )?;
2849            }
2850            if depth <= *impl_depth {
2851                active_impl = None;
2852            }
2853        }
2854    }
2855    Ok(output)
2856}
2857
2858fn rust_impl_type(line: &str) -> Option<String> {
2859    let rest = line.strip_prefix("impl ")?;
2860    let before_brace = rest.split('{').next()?.trim();
2861    let type_name = before_brace
2862        .split(" for ")
2863        .last()?
2864        .split('<')
2865        .next()?
2866        .trim();
2867    valid_symbol(type_name).then(|| type_name.to_owned())
2868}
2869
2870fn rust_function_name(line: &str) -> Option<String> {
2871    let position = line.find("fn ")?;
2872    let rest = line.get(position.saturating_add(3)..)?;
2873    let name = rest.split('(').next()?.trim();
2874    valid_graphql_name(name).then(|| name.to_owned())
2875}
2876
2877fn push_resolver(
2878    output: &mut Vec<GraphqlResolver>,
2879    type_name: &str,
2880    field_name: &str,
2881    symbol: &str,
2882    language: SourceLanguage,
2883    line: usize,
2884    tracker: &mut ExtractionTracker,
2885) -> Result<(), ExtractionLimitExceeded> {
2886    tracker.charge_observation(1)?;
2887    tracker.charge_identifier(type_name)?;
2888    tracker.charge_identifier(field_name)?;
2889    let coordinate_bytes = u64::try_from(type_name.len())
2890        .unwrap_or(u64::MAX)
2891        .saturating_add(1)
2892        .saturating_add(u64::try_from(field_name.len()).unwrap_or(u64::MAX));
2893    tracker.charge_identifier_bytes(coordinate_bytes)?;
2894    tracker.charge_identifier(symbol)?;
2895    output.push(resolver(type_name, field_name, symbol, language, line));
2896    Ok(())
2897}
2898
2899fn resolver(
2900    type_name: &str,
2901    field_name: &str,
2902    symbol: &str,
2903    language: SourceLanguage,
2904    line: usize,
2905) -> GraphqlResolver {
2906    GraphqlResolver {
2907        type_name: type_name.to_owned(),
2908        field_name: field_name.to_owned(),
2909        coordinate: format!("{type_name}.{field_name}"),
2910        symbol: symbol.to_owned(),
2911        language,
2912        lines: line_range(line),
2913    }
2914}
2915
2916fn receiver_to_type(receiver: &str) -> String {
2917    let trimmed = receiver
2918        .trim_end_matches("_type")
2919        .trim_end_matches("Type")
2920        .trim_end_matches("_resolver");
2921    upper_first(trimmed)
2922}
2923
2924fn first_quoted_value(value: &str) -> Option<String> {
2925    let quote_index = value.find(['\'', '"'])?;
2926    let quote = *value.as_bytes().get(quote_index)?;
2927    let rest = value.get(quote_index.saturating_add(1)..)?;
2928    let end = rest.as_bytes().iter().position(|byte| *byte == quote)?;
2929    rest.get(..end).map(str::to_owned)
2930}
2931
2932fn valid_graphql_name(value: &str) -> bool {
2933    let mut bytes = value.bytes();
2934    bytes
2935        .next()
2936        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
2937        && bytes.all(is_identifier_byte)
2938}
2939
2940fn valid_symbol(value: &str) -> bool {
2941    !value.is_empty()
2942        && value
2943            .split(['.', ':'])
2944            .filter(|part| !part.is_empty())
2945            .all(valid_graphql_name)
2946}
2947
2948fn upper_first(value: &str) -> String {
2949    let mut characters = value.chars();
2950    characters.next().map_or_else(String::new, |first| {
2951        first.to_uppercase().collect::<String>() + characters.as_str()
2952    })
2953}
2954
2955fn lower_first(value: &str) -> String {
2956    let mut characters = value.chars();
2957    characters.next().map_or_else(String::new, |first| {
2958        first.to_lowercase().collect::<String>() + characters.as_str()
2959    })
2960}
2961
2962fn indentation(line: &str) -> usize {
2963    line.len().saturating_sub(line.trim_start().len())
2964}
2965
2966fn brace_delta(line: &str) -> i32 {
2967    line.bytes().fold(0, |delta, byte| match byte {
2968        b'{' => delta.saturating_add(1),
2969        b'}' => delta.saturating_sub(1),
2970        _ => delta,
2971    })
2972}
2973
2974fn merge_document(target: &mut GraphqlDocument, mut source: GraphqlDocument) {
2975    target.types.append(&mut source.types);
2976    target.operations.append(&mut source.operations);
2977    target.fragments.append(&mut source.fragments);
2978    target
2979        .persisted_operations
2980        .append(&mut source.persisted_operations);
2981    target.resolvers.append(&mut source.resolvers);
2982    target.federation.append(&mut source.federation);
2983    target.complete &= source.complete;
2984    target.warnings.append(&mut source.warnings);
2985}
2986
2987fn shift_document_lines(document: &mut GraphqlDocument, offset: u32) {
2988    for type_definition in &mut document.types {
2989        shift_lines(&mut type_definition.lines, offset);
2990        for field in &mut type_definition.fields {
2991            shift_lines(&mut field.lines, offset);
2992            for argument in &mut field.arguments {
2993                shift_lines(&mut argument.lines, offset);
2994            }
2995        }
2996    }
2997    for operation in &mut document.operations {
2998        shift_lines(&mut operation.lines, offset);
2999        for variable in &mut operation.variables {
3000            shift_lines(&mut variable.lines, offset);
3001        }
3002        shift_selection_lines(&mut operation.selections, offset);
3003    }
3004    for fragment in &mut document.fragments {
3005        shift_lines(&mut fragment.lines, offset);
3006        shift_selection_lines(&mut fragment.selections, offset);
3007    }
3008    for metadata in &mut document.federation {
3009        shift_lines(&mut metadata.lines, offset);
3010    }
3011}
3012
3013fn shift_selection_lines(selections: &mut [GraphqlSelection], offset: u32) {
3014    for selection in selections {
3015        match selection {
3016            GraphqlSelection::Field { lines, .. }
3017            | GraphqlSelection::FragmentSpread { lines, .. }
3018            | GraphqlSelection::InlineFragment { lines, .. } => shift_lines(lines, offset),
3019        }
3020    }
3021}
3022
3023fn shift_lines(lines: &mut GraphqlLineRange, offset: u32) {
3024    lines.start = lines.start.saturating_add(offset);
3025    lines.end = lines.end.saturating_add(offset);
3026}
3027
3028fn finish_document(document: &mut GraphqlDocument) {
3029    document.types.sort();
3030    document.types.dedup();
3031    document.operations.sort();
3032    document.operations.dedup();
3033    document.fragments.sort();
3034    document.fragments.dedup();
3035    document.persisted_operations.sort();
3036    document.persisted_operations.dedup();
3037    document.resolvers.sort();
3038    document.resolvers.dedup();
3039    document.federation.sort();
3040    document.federation.dedup();
3041    document.warnings.sort();
3042    document.warnings.dedup();
3043}
3044
3045fn sorted_strings<I>(values: I) -> Vec<String>
3046where
3047    I: IntoIterator<Item = String>,
3048{
3049    let mut output = values.into_iter().collect::<Vec<_>>();
3050    output.sort();
3051    output.dedup();
3052    output
3053}
3054
3055fn sorted_unique<T>(mut values: Vec<T>) -> Vec<T>
3056where
3057    T: Ord,
3058{
3059    values.sort();
3060    values.dedup();
3061    values
3062}
3063
3064fn line_range(line: usize) -> GraphqlLineRange {
3065    let line = usize_to_u32(line.max(1));
3066    GraphqlLineRange {
3067        start: line,
3068        end: line,
3069    }
3070}
3071
3072fn line_at_offset(input: &str, offset: usize) -> u32 {
3073    usize_to_u32(
3074        input
3075            .as_bytes()
3076            .iter()
3077            .take(offset)
3078            .filter(|byte| **byte == b'\n')
3079            .count()
3080            .saturating_add(1),
3081    )
3082}
3083
3084fn usize_to_u32(value: usize) -> u32 {
3085    u32::try_from(value).unwrap_or(u32::MAX)
3086}
3087
3088#[cfg(test)]
3089mod tests {
3090    use graphql_parser::query;
3091
3092    use super::*;
3093    use crate::ExtractionResource;
3094
3095    #[test]
3096    fn extracts_complete_sdl_type_shapes_and_federation() {
3097        let input = r#"
3098            scalar DateTime
3099            interface Node { id: ID! }
3100            type User implements Node @key(fields: "id") {
3101              id: ID!
3102              friends(limit: Int = 10): [User!]!
3103              secret: String @requires(fields: "id")
3104            }
3105            input UserFilter { active: Boolean! }
3106            enum Role { ADMIN USER }
3107            union SearchResult = User
3108        "#;
3109
3110        let document = extract_graphql_document("schema.graphql", input)
3111            .expect("valid SDL should be extracted");
3112
3113        assert_eq!(document.types.len(), 6);
3114        let user = document
3115            .types
3116            .iter()
3117            .find(|definition| definition.name == "User")
3118            .expect("User type should exist");
3119        let friends = user
3120            .fields
3121            .iter()
3122            .find(|field| field.name == "friends")
3123            .expect("friends field should exist");
3124        assert_eq!(friends.type_ref.as_graphql(), "[User!]!");
3125        assert_eq!(
3126            friends.arguments[0].default_value_kind,
3127            Some(GraphqlLiteralKind::Integer)
3128        );
3129        assert_eq!(document.federation.len(), 2);
3130    }
3131
3132    #[test]
3133    fn extracts_operations_fragments_and_expanded_consumed_paths() {
3134        let input = r"
3135            query GetUser($id: ID!) {
3136              user(id: $id) {
3137                ...UserFields
3138              }
3139            }
3140            fragment UserFields on User {
3141              id
3142              profile { name }
3143            }
3144        ";
3145
3146        let document = extract_graphql_document("operation.graphql", input)
3147            .expect("valid query should be extracted");
3148
3149        assert_eq!(
3150            document.operations[0].consumed_field_paths,
3151            vec!["user", "user.id", "user.profile", "user.profile.name"]
3152        );
3153        assert_eq!(document.operations[0].fragment_spreads, vec!["UserFields"]);
3154    }
3155
3156    #[test]
3157    fn repeated_fragment_dag_should_memoize_and_deduplicate_transitive_paths() {
3158        let input = r"
3159            query Viewer {
3160              viewer { ...Identity ...Profile ...Identity }
3161            }
3162            fragment Identity on User { id ...Shared }
3163            fragment Profile on User { profile { name } ...Shared }
3164            fragment Shared on User { tenant { id } }
3165        ";
3166
3167        let document = extract_graphql_document("dag.graphql", input)
3168            .expect("acyclic repeated spreads should remain bounded and valid");
3169
3170        assert_eq!(
3171            document.operations[0].consumed_field_paths,
3172            vec![
3173                "viewer",
3174                "viewer.id",
3175                "viewer.profile",
3176                "viewer.profile.name",
3177                "viewer.tenant",
3178                "viewer.tenant.id",
3179            ]
3180        );
3181        assert_eq!(
3182            document.operations[0].fragment_spreads,
3183            vec!["Identity", "Profile", "Shared"]
3184        );
3185    }
3186
3187    #[test]
3188    fn fragment_cycles_and_missing_fragments_should_be_preserved_as_incomplete() {
3189        let input = r"
3190            query Viewer { viewer { ...A ...Missing } }
3191            fragment A on User { id ...B }
3192            fragment B on User { name ...A }
3193        ";
3194
3195        let document = extract_graphql_document("cycles.graphql", input)
3196            .expect("cycles are represented as incomplete facts, not recursive failure");
3197        let operation = &document.operations[0];
3198
3199        assert!(!operation.complete);
3200        assert!(
3201            operation
3202                .warnings
3203                .iter()
3204                .any(|warning| warning.contains("Missing"))
3205        );
3206        assert!(
3207            operation
3208                .warnings
3209                .iter()
3210                .any(|warning| warning.contains("cycle:A"))
3211        );
3212        assert_eq!(
3213            operation.consumed_field_paths,
3214            vec!["viewer", "viewer.id", "viewer.name"]
3215        );
3216    }
3217
3218    #[test]
3219    fn legacy_default_value_field_should_be_rejected_on_deserialize() {
3220        let payload = r#"{
3221            "name": "id",
3222            "type_ref": { "kind": "named", "name": "ID", "non_null": true },
3223            "default_value": "legacy-secret",
3224            "directives": [],
3225            "lines": { "start": 1, "end": 1 }
3226        }"#;
3227
3228        let result = serde_json::from_str::<GraphqlArgumentDefinition>(payload);
3229
3230        assert!(
3231            result.is_err(),
3232            "legacy default_value must not be ignored by Serde"
3233        );
3234    }
3235
3236    #[test]
3237    fn memoized_fragment_expansion_should_match_reference_algorithm_on_golden_inputs() {
3238        let cases: &[(&str, &str, &[&str])] = &[
3239            (
3240                "dag",
3241                r"
3242                    query Viewer {
3243                      viewer { ...Identity ...Profile ...Identity }
3244                    }
3245                    fragment Identity on User { id ...Shared }
3246                    fragment Profile on User { profile { name } ...Shared }
3247                    fragment Shared on User { tenant { id } }
3248                ",
3249                &[
3250                    "viewer",
3251                    "viewer.id",
3252                    "viewer.profile",
3253                    "viewer.profile.name",
3254                    "viewer.tenant",
3255                    "viewer.tenant.id",
3256                ],
3257            ),
3258            (
3259                "cycles",
3260                r"
3261                    query Viewer { viewer { ...A ...Missing } }
3262                    fragment A on User { id ...B }
3263                    fragment B on User { name ...A }
3264                ",
3265                &["viewer", "viewer.id", "viewer.name"],
3266            ),
3267            (
3268                "missing",
3269                r"
3270                    query Viewer { viewer { ...Absent } }
3271                ",
3272                &["viewer"],
3273            ),
3274        ];
3275
3276        for (name, input, expected_paths) in cases {
3277            let document = extract_graphql_document(&format!("{name}.graphql"), input)
3278                .expect("golden GraphQL input should extract");
3279            let reference = reference_consumed_paths(input).expect("reference expansion");
3280            let memoized = document.operations[0].consumed_field_paths.clone();
3281
3282            assert_eq!(
3283                memoized, reference,
3284                "memoized expansion diverged from the reference algorithm for {name}"
3285            );
3286            let expected = expected_paths
3287                .iter()
3288                .map(ToString::to_string)
3289                .collect::<Vec<_>>();
3290            assert_eq!(memoized, expected, "golden paths diverged for {name}");
3291        }
3292    }
3293
3294    fn reference_consumed_paths(input: &str) -> Result<Vec<String>, String> {
3295        use graphql_parser::query;
3296
3297        let document = query::parse_query::<String>(input).map_err(|error| error.to_string())?;
3298        let mut fragments = BTreeMap::new();
3299        let mut selection_set = None;
3300        for definition in document.definitions {
3301            match definition {
3302                query::Definition::Fragment(fragment) => {
3303                    fragments.insert(fragment.name.clone(), fragment);
3304                }
3305                query::Definition::Operation(operation) => {
3306                    selection_set = Some(match operation {
3307                        query::OperationDefinition::SelectionSet(set) => set,
3308                        query::OperationDefinition::Query(query) => query.selection_set,
3309                        query::OperationDefinition::Mutation(mutation) => mutation.selection_set,
3310                        query::OperationDefinition::Subscription(subscription) => {
3311                            subscription.selection_set
3312                        }
3313                    });
3314                }
3315            }
3316        }
3317        let selection_set = selection_set.expect("golden input must declare one operation");
3318        let expansion =
3319            reference_expand_selection_set(&selection_set, None, &fragments, &mut BTreeSet::new());
3320        Ok(expansion.paths.into_iter().collect())
3321    }
3322
3323    #[derive(Debug, Clone, Default)]
3324    struct ReferenceExpansion {
3325        paths: BTreeSet<String>,
3326        spreads: BTreeSet<String>,
3327        missing: BTreeSet<String>,
3328    }
3329
3330    fn reference_expand_fragment(
3331        name: &str,
3332        fragments: &BTreeMap<String, query::FragmentDefinition<'_, String>>,
3333        visiting: &mut BTreeSet<String>,
3334    ) -> ReferenceExpansion {
3335        if visiting.contains(name) {
3336            return ReferenceExpansion {
3337                missing: [format!("cycle:{name}")].into_iter().collect(),
3338                ..ReferenceExpansion::default()
3339            };
3340        }
3341        let Some(fragment) = fragments.get(name) else {
3342            return ReferenceExpansion {
3343                missing: [name.to_owned()].into_iter().collect(),
3344                ..ReferenceExpansion::default()
3345            };
3346        };
3347        visiting.insert(name.to_owned());
3348        let expansion =
3349            reference_expand_selection_set(&fragment.selection_set, None, fragments, visiting);
3350        visiting.remove(name);
3351        expansion
3352    }
3353
3354    fn reference_expand_selection_set(
3355        selection_set: &query::SelectionSet<'_, String>,
3356        parent: Option<&str>,
3357        fragments: &BTreeMap<String, query::FragmentDefinition<'_, String>>,
3358        visiting: &mut BTreeSet<String>,
3359    ) -> ReferenceExpansion {
3360        let mut expansion = ReferenceExpansion::default();
3361        for selection in &selection_set.items {
3362            match selection {
3363                query::Selection::Field(field) => {
3364                    let path = join_field_path(parent, &field.name);
3365                    expansion.paths.insert(path.clone());
3366                    let nested = reference_expand_selection_set(
3367                        &field.selection_set,
3368                        Some(&path),
3369                        fragments,
3370                        visiting,
3371                    );
3372                    merge_reference_expansion(&mut expansion, nested);
3373                }
3374                query::Selection::InlineFragment(fragment) => {
3375                    let nested = reference_expand_selection_set(
3376                        &fragment.selection_set,
3377                        parent,
3378                        fragments,
3379                        visiting,
3380                    );
3381                    merge_reference_expansion(&mut expansion, nested);
3382                }
3383                query::Selection::FragmentSpread(spread) => {
3384                    expansion.spreads.insert(spread.fragment_name.clone());
3385                    let nested =
3386                        reference_expand_fragment(&spread.fragment_name, fragments, visiting);
3387                    expansion.spreads.extend(nested.spreads);
3388                    expansion.missing.extend(nested.missing);
3389                    for relative in nested.paths {
3390                        let materialized = parent
3391                            .map_or(relative.clone(), |prefix| format!("{prefix}.{relative}"));
3392                        expansion.paths.insert(materialized);
3393                    }
3394                }
3395            }
3396        }
3397        expansion
3398    }
3399
3400    fn merge_reference_expansion(target: &mut ReferenceExpansion, source: ReferenceExpansion) {
3401        target.paths.extend(source.paths);
3402        target.spreads.extend(source.spreads);
3403        target.missing.extend(source.missing);
3404    }
3405
3406    #[test]
3407    fn literal_values_should_never_appear_in_serialized_graphql_payloads() {
3408        let input = r#"
3409            type User @key(fields: "top-secret-federation-field") {
3410              lookup(token: String = "top-secret-default", count: Int = 8675309): String
3411            }
3412            input Filter { enabled: Boolean = true }
3413        "#;
3414
3415        let document = extract_graphql_document("schema.graphql", input)
3416            .expect("valid SDL should be extracted");
3417        let serialized = serde_json::to_string(&document).expect("document should serialize");
3418
3419        assert!(!serialized.contains("top-secret-federation-field"));
3420        assert!(!serialized.contains("top-secret-default"));
3421        assert!(!serialized.contains("8675309"));
3422        assert!(serialized.contains("default_value_kind"));
3423        assert!(serialized.contains("string"));
3424        assert!(serialized.contains("integer"));
3425    }
3426
3427    #[test]
3428    fn rejects_invalid_standalone_graphql() {
3429        let error = extract_graphql_document("broken.graphql", "query Broken { user(")
3430            .expect_err("invalid GraphQL should fail");
3431
3432        assert!(matches!(
3433            error,
3434            GraphqlExtractionError::InvalidGraphql { .. }
3435        ));
3436    }
3437
3438    #[test]
3439    fn invalid_graphql_errors_should_not_retain_parser_literals() {
3440        let secret = "TOP_SECRET_LITERAL_71A9";
3441        let input = format!("query Q {{ field }} \"{secret}\" query");
3442        let error = extract_graphql_document("broken.graphql", &input)
3443            .expect_err("invalid GraphQL should fail");
3444        let serialized = serde_json::to_string(&error).expect("error should serialize");
3445        let displayed = error.to_string();
3446        let debugged = format!("{error:?}");
3447
3448        for representation in [serialized, displayed, debugged] {
3449            assert!(!representation.contains(secret));
3450        }
3451        assert!(matches!(
3452            error,
3453            GraphqlExtractionError::InvalidGraphql { message, .. }
3454                if message == "document is neither valid GraphQL schema nor executable syntax"
3455        ));
3456    }
3457
3458    #[test]
3459    fn extracts_identifier_to_query_persisted_map_without_body() {
3460        let input = r#"{
3461          "a1b2": "query Viewer { viewer { id } }",
3462          "c3d4": "mutation Rename { renameUser { id } }"
3463        }"#;
3464
3465        let operations = extract_graphql_persisted_operations("manifest.json", input)
3466            .expect("valid manifest should be extracted");
3467        let serialized =
3468            serde_json::to_string(&operations).expect("persisted operations should serialize");
3469
3470        assert_eq!(operations.len(), 2);
3471        assert!(!serialized.contains("query Viewer"));
3472        assert_eq!(operations[0].id, "a1b2");
3473    }
3474
3475    #[test]
3476    fn extracts_apollo_operations_array() {
3477        let input = r#"{
3478          "format": "apollo-persisted-query-manifest",
3479          "version": 1,
3480          "operations": [
3481            {
3482              "id": "sha256",
3483              "name": "Viewer",
3484              "body": "query Viewer { viewer { id } }"
3485            }
3486          ]
3487        }"#;
3488
3489        let operations = extract_graphql_persisted_operations("persisted.json", input)
3490            .expect("Apollo manifest should be extracted");
3491
3492        assert_eq!(operations[0].operation_name.as_deref(), Some("Viewer"));
3493        assert_eq!(
3494            operations[0].consumed_field_paths,
3495            vec!["viewer", "viewer.id"]
3496        );
3497    }
3498
3499    #[test]
3500    fn retains_incomplete_manifest_entry_without_document() {
3501        let input = r#"{"operations":[{"id":"known","name":"Viewer"}]}"#;
3502
3503        let operations = extract_graphql_persisted_operations("persisted.json", input)
3504            .expect("metadata-only operation should be retained");
3505
3506        assert!(!operations[0].complete);
3507        assert_eq!(operations[0].warnings, vec!["missing_operation_document"]);
3508    }
3509
3510    #[test]
3511    fn preserves_persisted_identifier_line_evidence() {
3512        let input = "{\n  \"operations\": [\n    {\"id\":\"known\",\"name\":\"Viewer\"}\n  ]\n}";
3513
3514        let operations = extract_graphql_persisted_operations("persisted.json", input)
3515            .expect("manifest entry should be extracted");
3516
3517        assert_eq!(operations[0].lines.start, 3);
3518    }
3519
3520    #[test]
3521    fn rejects_invalid_persisted_json() {
3522        let error = extract_graphql_persisted_operations("manifest.json", "{")
3523            .expect_err("invalid JSON should fail");
3524
3525        assert!(matches!(error, GraphqlExtractionError::InvalidJson { .. }));
3526    }
3527
3528    #[test]
3529    fn extracts_javascript_literal_and_named_resolver() {
3530        let input = r"
3531            const operation = gql`
3532              query Viewer { viewer { id } }
3533            `;
3534            const resolvers: Resolvers = {
3535              Query: {
3536                viewer: resolveViewer,
3537                dynamic: () => loadViewer(),
3538              },
3539            };
3540        ";
3541
3542        let document = parse_graphql_source(SourceLanguage::JavaScript, input)
3543            .expect("bounded JavaScript extraction should succeed");
3544
3545        assert_eq!(document.operations.len(), 1);
3546        assert_eq!(document.resolvers.len(), 1);
3547        assert_eq!(document.resolvers[0].coordinate, "Query.viewer");
3548        assert_eq!(document.resolvers[0].symbol, "resolveViewer");
3549    }
3550
3551    #[test]
3552    fn marks_dynamic_javascript_graphql_literal_incomplete() {
3553        let input = "const operation = gql`query Viewer { viewer(id: ${id}) { id } }`;";
3554
3555        let document = parse_graphql_source(SourceLanguage::TypeScript, input)
3556            .expect("bounded TypeScript extraction should succeed");
3557
3558        assert!(!document.complete);
3559        assert_eq!(document.operations.len(), 0);
3560        assert_eq!(document.warnings, vec!["dynamic_graphql_literal:1"]);
3561    }
3562
3563    #[test]
3564    fn marks_nonliteral_graphql_call_incomplete() {
3565        let input = "const operation = gql(buildOperation());";
3566
3567        let document = parse_graphql_source(SourceLanguage::JavaScript, input)
3568            .expect("bounded JavaScript extraction should succeed");
3569
3570        assert!(!document.complete);
3571        assert_eq!(document.warnings, vec!["dynamic_graphql_literal:1"]);
3572    }
3573
3574    #[test]
3575    fn extracts_go_raw_string_graphql_assignment() {
3576        let input = "const query = `query Viewer { viewer { id } }`";
3577
3578        let document = parse_graphql_source(SourceLanguage::Go, input)
3579            .expect("bounded Go extraction should succeed");
3580
3581        assert_eq!(document.operations.len(), 1);
3582        assert_eq!(
3583            document.operations[0].consumed_field_paths,
3584            vec!["viewer", "viewer.id"]
3585        );
3586    }
3587
3588    #[test]
3589    fn marks_invalid_embedded_literal_incomplete() {
3590        let input = "operation = gql(\"query Broken { viewer(\")";
3591
3592        let document = parse_graphql_source(SourceLanguage::Python, input)
3593            .expect("bounded Python extraction should succeed");
3594
3595        assert!(!document.complete);
3596        assert_eq!(document.warnings, vec!["invalid_embedded_graphql:1"]);
3597    }
3598
3599    #[test]
3600    fn embedded_graphql_should_propagate_budget_failures() {
3601        let budgets = ExtractionBudgets {
3602            max_work_units_per_artifact: 1,
3603            ..ExtractionBudgets::default()
3604        };
3605        let mut tracker = ExtractionTracker::new("source.js", "graphql-source", &budgets);
3606        let result = parse_graphql_source_with_tracker(
3607            SourceLanguage::JavaScript,
3608            "const operation = gql`query Viewer { viewer { id } }`;",
3609            &mut tracker,
3610        );
3611
3612        assert!(matches!(
3613            result,
3614            Err(GraphqlExtractionError::LimitExceeded(
3615                ExtractionLimitExceeded {
3616                    resource: ExtractionResource::WorkUnits,
3617                    ..
3618                }
3619            ))
3620        ));
3621    }
3622
3623    #[test]
3624    fn schema_fields_should_enforce_identifier_bytes() {
3625        let budgets = ExtractionBudgets {
3626            max_identifier_bytes_per_value: 5,
3627            ..ExtractionBudgets::default()
3628        };
3629        let mut tracker = ExtractionTracker::new("schema.graphql", "graphql", &budgets);
3630        let result = extract_graphql_document_with_tracker(
3631            "schema.graphql",
3632            "type Query { identifierFarAboveConfiguredMaximum: String }",
3633            &mut tracker,
3634        );
3635
3636        assert!(matches!(
3637            result,
3638            Err(GraphqlExtractionError::LimitExceeded(error))
3639                if error.resource == ExtractionResource::IdentifierBytesPerValue
3640                    && error.maximum == 5
3641        ));
3642    }
3643
3644    #[test]
3645    fn graphql_preflight_should_reject_observation_above_maximum_before_ast() {
3646        let budgets = ExtractionBudgets {
3647            max_observations_per_artifact: 1,
3648            ..ExtractionBudgets::default()
3649        };
3650        let mut exact = ExtractionTracker::new("schema.graphql", "graphql", &budgets);
3651        assert!(
3652            extract_graphql_document_with_tracker(
3653                "schema.graphql",
3654                "type Query { value: String }",
3655                &mut exact,
3656            )
3657            .is_ok()
3658        );
3659
3660        let mut above = ExtractionTracker::new("schema.graphql", "graphql", &budgets);
3661        assert!(matches!(
3662            extract_graphql_document_with_tracker(
3663                "schema.graphql",
3664                "type Query { value: String } type Mutation { update: Boolean }",
3665                &mut above,
3666            ),
3667            Err(GraphqlExtractionError::LimitExceeded(error))
3668                if error.resource == ExtractionResource::Observations
3669                    && error.observed == 2
3670                    && error.maximum == 1
3671        ));
3672    }
3673
3674    #[test]
3675    fn persisted_json_precheck_should_measure_decoded_strings_before_dom_parse() {
3676        let budgets = ExtractionBudgets {
3677            max_string_bytes_per_value: 2,
3678            ..ExtractionBudgets::default()
3679        };
3680        let mut tracker = ExtractionTracker::new("operations.json", "graphql", &budgets);
3681        assert_eq!(
3682            precheck_json_structure(r#"{"k":"a\n"}"#, &mut tracker),
3683            Ok(())
3684        );
3685
3686        let budgets = ExtractionBudgets {
3687            max_string_bytes_per_value: 1,
3688            ..ExtractionBudgets::default()
3689        };
3690        let mut tracker = ExtractionTracker::new("operations.json", "graphql", &budgets);
3691        assert!(matches!(
3692            precheck_json_structure(r#"{"k":"a\n"}"#, &mut tracker),
3693            Err(error) if error.resource == ExtractionResource::StringBytesPerValue
3694                && error.observed == 2
3695                && error.maximum == 1
3696        ));
3697    }
3698
3699    #[test]
3700    fn extracts_python_ariadne_and_strawberry_resolvers() {
3701        let input = r#"
3702            @query.field("viewer")
3703            def resolve_viewer(_, info):
3704                return info.context.viewer
3705
3706            @strawberry.type
3707            class User:
3708                @strawberry.field
3709                def display_name(self) -> str:
3710                    return self.name
3711        "#;
3712
3713        let document = parse_graphql_source(SourceLanguage::Python, input)
3714            .expect("bounded Python extraction should succeed");
3715
3716        assert_eq!(document.resolvers.len(), 2);
3717        assert_eq!(document.resolvers[0].coordinate, "Query.viewer");
3718        assert_eq!(document.resolvers[1].coordinate, "User.display_name");
3719    }
3720
3721    #[test]
3722    fn extracts_go_gqlgen_resolver() {
3723        let input = r"
3724            func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
3725                return r.service.User(ctx, id)
3726            }
3727        ";
3728
3729        let document = parse_graphql_source(SourceLanguage::Go, input)
3730            .expect("bounded Go extraction should succeed");
3731
3732        assert_eq!(document.resolvers[0].coordinate, "Query.user");
3733        assert_eq!(document.resolvers[0].symbol, "queryResolver.User");
3734    }
3735
3736    #[test]
3737    fn extracts_java_spring_graphql_resolver() {
3738        let input = r#"
3739            class UserController {
3740              @SchemaMapping(typeName = "User", field = "displayName")
3741              public String displayName(User user) { return user.name(); }
3742            }
3743        "#;
3744
3745        let document = parse_graphql_source(SourceLanguage::Java, input)
3746            .expect("bounded Java extraction should succeed");
3747
3748        assert_eq!(document.resolvers[0].coordinate, "User.displayName");
3749        assert_eq!(document.resolvers[0].symbol, "UserController.displayName");
3750    }
3751
3752    #[test]
3753    fn extracts_java_dgs_resolver() {
3754        let input = r#"
3755            class ViewerFetcher {
3756              @DgsData(parentType = "Query", field = "viewer")
3757              public User loadViewer() { return service.viewer(); }
3758            }
3759        "#;
3760
3761        let document = parse_graphql_source(SourceLanguage::Java, input)
3762            .expect("bounded Java extraction should succeed");
3763
3764        assert_eq!(document.resolvers[0].coordinate, "Query.viewer");
3765        assert_eq!(document.resolvers[0].symbol, "ViewerFetcher.loadViewer");
3766    }
3767
3768    #[test]
3769    fn extracts_rust_async_graphql_resolver() {
3770        let input = r"
3771            #[Object]
3772            impl Query {
3773                async fn viewer(&self) -> User {
3774                    self.viewer.clone()
3775                }
3776            }
3777        ";
3778
3779        let document = parse_graphql_source(SourceLanguage::Rust, input)
3780            .expect("bounded Rust extraction should succeed");
3781
3782        assert_eq!(document.resolvers[0].coordinate, "Query.viewer");
3783        assert_eq!(document.resolvers[0].symbol, "Query::viewer");
3784    }
3785
3786    #[test]
3787    fn ignores_dynamic_and_inline_resolver_symbols() {
3788        let input = r"
3789            const resolvers = {
3790              Query: {
3791                viewer: makeResolver(config),
3792                inline: (_, args) => args.id,
3793              },
3794            };
3795        ";
3796
3797        let document = parse_graphql_source(SourceLanguage::TypeScript, input)
3798            .expect("bounded TypeScript extraction should succeed");
3799
3800        assert!(document.resolvers.is_empty());
3801    }
3802
3803    #[test]
3804    fn serializes_owned_document_after_input_is_dropped() {
3805        let document = {
3806            let input = String::from("query Viewer { viewer { id } }");
3807            extract_graphql_document("viewer.graphql", &input)
3808                .expect("valid query should produce an owned document")
3809        };
3810
3811        assert!(serde_json::to_string(&document).is_ok());
3812    }
3813
3814    #[test]
3815    fn schema_ast_work_should_be_charged_before_materialization() {
3816        let budgets = ExtractionBudgets {
3817            max_work_units_per_artifact: 1,
3818            ..ExtractionBudgets::default()
3819        };
3820        let mut tracker = ExtractionTracker::new(
3821            "schema.graphql",
3822            "code-system-graph.graphql.document",
3823            &budgets,
3824        );
3825
3826        let error = extract_graphql_document_with_tracker(
3827            "schema.graphql",
3828            "type Query { viewer: String }",
3829            &mut tracker,
3830        )
3831        .expect_err("the schema definition and field should exceed one work unit");
3832
3833        assert!(matches!(
3834            error,
3835            GraphqlExtractionError::LimitExceeded(ExtractionLimitExceeded {
3836                resource: crate::ExtractionResource::WorkUnits,
3837                observed: 2,
3838                maximum: 1,
3839                ..
3840            })
3841        ));
3842    }
3843}