1use std::collections::{BTreeMap, BTreeSet};
8
9use graphql_parser::{query, schema};
10use serde::{Deserialize, Serialize};
11
12use crate::{ExtractionBudgets, ExtractionLimitExceeded, ExtractionTracker, SourceLanguage};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
16pub struct GraphqlLineRange {
17 pub start: u32,
19 pub end: u32,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum GraphqlOperationKind {
27 Query,
29 Mutation,
31 Subscription,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum GraphqlTypeKind {
39 Scalar,
41 Object,
43 Interface,
45 InputObject,
47 Enum,
49 Union,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum GraphqlLiteralKind {
57 Null,
59 Boolean,
61 Integer,
63 Float,
65 String,
67 Enum,
69 List,
71 Object,
73 Variable,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
79#[serde(tag = "kind", rename_all = "snake_case")]
80pub enum GraphqlTypeRef {
81 Named {
83 name: String,
85 non_null: bool,
87 },
88 List {
90 element: Box<GraphqlTypeRef>,
92 non_null: bool,
94 },
95}
96
97impl GraphqlTypeRef {
98 #[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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct GraphqlArgumentDefinition {
118 pub name: String,
120 pub type_ref: GraphqlTypeRef,
122 pub default_value_kind: Option<GraphqlLiteralKind>,
124 pub directives: Vec<String>,
126 pub lines: GraphqlLineRange,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
132pub struct GraphqlFieldDefinition {
133 pub coordinate: String,
135 pub name: String,
137 pub arguments: Vec<GraphqlArgumentDefinition>,
139 pub type_ref: GraphqlTypeRef,
141 pub directives: Vec<String>,
143 pub extension: bool,
145 pub lines: GraphqlLineRange,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
151pub struct GraphqlTypeDefinition {
152 pub kind: GraphqlTypeKind,
154 pub name: String,
156 pub fields: Vec<GraphqlFieldDefinition>,
158 pub implements: Vec<String>,
160 pub enum_values: Vec<String>,
162 pub union_members: Vec<String>,
164 pub directives: Vec<String>,
166 pub extension: bool,
168 pub lines: GraphqlLineRange,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
174#[serde(tag = "kind", rename_all = "snake_case")]
175pub enum GraphqlSelection {
176 Field {
178 path: String,
180 name: String,
182 alias: Option<String>,
184 lines: GraphqlLineRange,
186 },
187 FragmentSpread {
189 name: String,
191 parent_path: Option<String>,
193 lines: GraphqlLineRange,
195 },
196 InlineFragment {
198 type_condition: Option<String>,
200 parent_path: Option<String>,
202 lines: GraphqlLineRange,
204 },
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
209pub struct GraphqlOperation {
210 pub kind: GraphqlOperationKind,
212 pub name: Option<String>,
214 pub variables: Vec<GraphqlArgumentDefinition>,
216 pub selections: Vec<GraphqlSelection>,
218 pub consumed_field_paths: Vec<String>,
220 pub fragment_spreads: Vec<String>,
222 pub complete: bool,
224 pub warnings: Vec<String>,
226 pub lines: GraphqlLineRange,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
232pub struct GraphqlFragment {
233 pub name: String,
235 pub type_condition: String,
237 pub selections: Vec<GraphqlSelection>,
239 pub consumed_field_paths: Vec<String>,
241 pub fragment_spreads: Vec<String>,
243 pub lines: GraphqlLineRange,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
249pub struct GraphqlPersistedOperation {
250 pub id: String,
252 pub operation_name: Option<String>,
254 pub kind: Option<GraphqlOperationKind>,
256 pub consumed_field_paths: Vec<String>,
258 pub complete: bool,
260 pub warnings: Vec<String>,
262 pub source_path: String,
264 pub lines: GraphqlLineRange,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
270pub struct GraphqlResolver {
271 pub type_name: String,
273 pub field_name: String,
275 pub coordinate: String,
277 pub symbol: String,
279 pub language: SourceLanguage,
281 pub lines: GraphqlLineRange,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
287pub struct GraphqlFederationMetadata {
288 pub directive: String,
290 pub target: String,
292 pub arguments: BTreeMap<String, GraphqlLiteralKind>,
294 pub lines: GraphqlLineRange,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct GraphqlDocument {
301 pub source_path: String,
303 pub types: Vec<GraphqlTypeDefinition>,
305 pub operations: Vec<GraphqlOperation>,
307 pub fragments: Vec<GraphqlFragment>,
309 pub persisted_operations: Vec<GraphqlPersistedOperation>,
311 pub resolvers: Vec<GraphqlResolver>,
313 pub federation: Vec<GraphqlFederationMetadata>,
315 pub complete: bool,
317 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
339#[serde(tag = "kind", rename_all = "snake_case")]
340pub enum GraphqlExtractionError {
341 #[error("invalid GraphQL in `{source_path}`: {message}")]
343 InvalidGraphql {
344 source_path: String,
346 message: String,
348 },
349 #[error("invalid persisted-operation JSON in `{source_path}` at line {line}: {message}")]
351 InvalidJson {
352 source_path: String,
354 line: u32,
356 message: String,
358 },
359 #[error("unsupported persisted-operation manifest in `{source_path}`")]
361 UnsupportedPersistedManifest {
362 source_path: String,
364 },
365 #[error(transparent)]
367 LimitExceeded(#[from] ExtractionLimitExceeded),
368}
369
370pub 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
391pub 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
434pub 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
457pub 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
496pub 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
518pub 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("ed)
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, _)) = ¤t_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}