Skip to main content

fallow_extract/
graphql.rs

1//! GraphQL document parsing.
2//!
3//! Supports the widely-used `#import "./fragment.graphql"` convention by
4//! turning relative document imports into side-effect module edges.
5
6use std::path::Path;
7use std::sync::LazyLock;
8
9use oxc_span::Span;
10
11use crate::{ImportInfo, ImportedName, ModuleInfo};
12use fallow_types::discover::FileId;
13
14static GRAPHQL_IMPORT_RE: LazyLock<regex::Regex> =
15    LazyLock::new(|| crate::static_regex(r#"(?m)^[ \t]*#\s*import\s+["']([^"'\r\n]+)["']"#));
16
17pub(crate) fn is_graphql_file(path: &Path) -> bool {
18    path.extension()
19        .and_then(|e| e.to_str())
20        .is_some_and(|ext| ext == "graphql" || ext == "gql")
21}
22
23fn is_relative_graphql_import(source: &str) -> bool {
24    source.starts_with("./") || source.starts_with("../")
25}
26
27#[expect(
28    clippy::cast_possible_truncation,
29    reason = "source spans are bounded by source file size, which is practically below u32::MAX"
30)]
31fn span_from_usize(start: usize, end: usize) -> Span {
32    Span::new(start as u32, end as u32)
33}
34
35#[must_use]
36pub(crate) fn extract_graphql_imports(source: &str) -> Vec<ImportInfo> {
37    let mut imports = Vec::new();
38
39    for cap in GRAPHQL_IMPORT_RE.captures_iter(source) {
40        let Some(source_match) = cap.get(1) else {
41            continue;
42        };
43        let import_source = source_match.as_str().trim();
44        if import_source.is_empty() || !is_relative_graphql_import(import_source) {
45            continue;
46        }
47
48        imports.push(ImportInfo {
49            source: import_source.to_string(),
50            imported_name: ImportedName::SideEffect,
51            local_name: String::new(),
52            is_type_only: false,
53            from_style: false,
54            span: cap
55                .get(0)
56                .map_or_else(Span::default, |m| span_from_usize(m.start(), m.end())),
57            source_span: span_from_usize(source_match.start(), source_match.end()),
58        });
59    }
60
61    imports.sort_unstable_by(|a, b| {
62        a.source
63            .cmp(&b.source)
64            .then(a.source_span.start.cmp(&b.source_span.start))
65    });
66    imports.dedup_by(|a, b| a.source == b.source);
67    imports
68}
69
70pub(crate) fn parse_graphql_to_module(
71    file_id: FileId,
72    source: &str,
73    content_hash: u64,
74) -> ModuleInfo {
75    let parsed_suppressions = crate::suppress::parse_suppressions_from_source(source);
76    ModuleInfo {
77        file_id,
78        exports: Vec::new(),
79        imports: extract_graphql_imports(source),
80        re_exports: Vec::new(),
81        dynamic_imports: Vec::new(),
82        dynamic_import_patterns: Vec::new(),
83        require_calls: Vec::new(),
84        package_path_references: Box::default(),
85        member_accesses: Vec::new(),
86        semantic_facts: Box::default(),
87        whole_object_uses: Box::default(),
88        has_cjs_exports: false,
89        has_angular_component_template_url: false,
90        content_hash,
91        suppressions: parsed_suppressions.suppressions,
92        unknown_suppression_kinds: parsed_suppressions.unknown_kinds,
93        unused_import_bindings: Vec::new(),
94        type_referenced_import_bindings: Vec::new(),
95        value_referenced_import_bindings: Vec::new(),
96        line_offsets: fallow_types::extract::compute_line_offsets(source),
97        complexity: Vec::new(),
98        flag_uses: Vec::new(),
99        class_heritage: Vec::new(),
100        exported_factory_returns: Box::default(),
101        type_member_types: Box::default(),
102        injection_tokens: Vec::new(),
103        local_type_declarations: Vec::new(),
104        public_signature_type_references: Vec::new(),
105        namespace_object_aliases: Vec::new(),
106        iconify_prefixes: Vec::new(),
107        iconify_icon_names: Vec::new(),
108        auto_import_candidates: Vec::new(),
109        directives: Vec::new(),
110        client_only_dynamic_import_spans: Vec::new(),
111        security_sinks: Vec::new(),
112        security_sinks_skipped: 0,
113        security_unresolved_callee_sites: Vec::new(),
114        tainted_bindings: Vec::new(),
115        sanitized_sink_args: Vec::new(),
116        security_control_sites: Vec::new(),
117        callee_uses: Vec::new(),
118        misplaced_directives: Vec::new(),
119        inline_server_action_exports: Vec::new(),
120        di_key_sites: Vec::new(),
121        has_dynamic_provide: false,
122        referenced_import_bindings: Vec::new(),
123        component_props: Vec::new(),
124        has_props_attrs_fallthrough: false,
125        has_define_expose: false,
126        has_define_model: false,
127        has_unharvestable_props: false,
128        component_emits: Vec::new(),
129        angular_inputs: Vec::new(),
130        angular_outputs: Vec::new(),
131        angular_component_selectors: Vec::new(),
132        registered_custom_elements: Vec::new(),
133        used_custom_element_tags: Vec::new(),
134        angular_used_selectors: Vec::new(),
135        angular_entry_component_refs: Vec::new(),
136        has_dynamic_component_render: false,
137        has_unharvestable_emits: false,
138        has_dynamic_emit: false,
139        has_emit_whole_object_use: false,
140        load_return_keys: Vec::new(),
141        has_unharvestable_load: false,
142        has_load_data_whole_use: false,
143        has_page_data_store_whole_use: false,
144        has_route_loader_data_whole_use: false,
145        component_functions: Vec::new(),
146        react_props: Vec::new(),
147        hook_uses: Vec::new(),
148        render_edges: Vec::new(),
149        svelte_dispatched_events: Vec::new(),
150        svelte_listened_events: Vec::new(),
151        has_dynamic_dispatch: false,
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn graphql_file_extensions_are_supported() {
161        assert!(is_graphql_file(Path::new("schema.graphql")));
162        assert!(is_graphql_file(Path::new("fragment.gql")));
163        assert!(!is_graphql_file(Path::new("query.ts")));
164    }
165
166    #[test]
167    fn extracts_relative_hash_imports() {
168        let imports = extract_graphql_imports(
169            r#"
170            #import "./content.graphql"
171            # import '../shared/leaf.gql'
172            #import "package/schema.graphql"
173            fragment Story on Story { id }
174            "#,
175        );
176
177        let sources: Vec<&str> = imports
178            .iter()
179            .map(|import| import.source.as_str())
180            .collect();
181        assert_eq!(sources, vec!["../shared/leaf.gql", "./content.graphql"]);
182        assert!(
183            imports
184                .iter()
185                .all(|import| matches!(import.imported_name, ImportedName::SideEffect))
186        );
187    }
188
189    #[test]
190    fn parse_graphql_to_module_sets_imports_and_offsets() {
191        let info = parse_graphql_to_module(
192            FileId(7),
193            "#import \"./content.graphql\"\nfragment Story on Story { id }\n",
194            42,
195        );
196
197        assert_eq!(info.file_id, FileId(7));
198        assert_eq!(info.content_hash, 42);
199        assert_eq!(info.imports.len(), 1);
200        assert_eq!(info.imports[0].source, "./content.graphql");
201        assert_eq!(info.line_offsets, vec![0, 28, 59]);
202    }
203}