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> = LazyLock::new(|| {
15    regex::Regex::new(r#"(?m)^[ \t]*#\s*import\s+["']([^"'\r\n]+)["']"#).expect("valid regex")
16});
17
18pub(crate) fn is_graphql_file(path: &Path) -> bool {
19    path.extension()
20        .and_then(|e| e.to_str())
21        .is_some_and(|ext| ext == "graphql" || ext == "gql")
22}
23
24fn is_relative_graphql_import(source: &str) -> bool {
25    source.starts_with("./") || source.starts_with("../")
26}
27
28#[expect(
29    clippy::cast_possible_truncation,
30    reason = "source spans are bounded by source file size, which is practically below u32::MAX"
31)]
32fn span_from_usize(start: usize, end: usize) -> Span {
33    Span::new(start as u32, end as u32)
34}
35
36#[must_use]
37pub(crate) fn extract_graphql_imports(source: &str) -> Vec<ImportInfo> {
38    let mut imports = Vec::new();
39
40    for cap in GRAPHQL_IMPORT_RE.captures_iter(source) {
41        let Some(source_match) = cap.get(1) else {
42            continue;
43        };
44        let import_source = source_match.as_str().trim();
45        if import_source.is_empty() || !is_relative_graphql_import(import_source) {
46            continue;
47        }
48
49        imports.push(ImportInfo {
50            source: import_source.to_string(),
51            imported_name: ImportedName::SideEffect,
52            local_name: String::new(),
53            is_type_only: false,
54            from_style: false,
55            span: cap
56                .get(0)
57                .map_or_else(Span::default, |m| span_from_usize(m.start(), m.end())),
58            source_span: span_from_usize(source_match.start(), source_match.end()),
59        });
60    }
61
62    imports.sort_unstable_by(|a, b| {
63        a.source
64            .cmp(&b.source)
65            .then(a.source_span.start.cmp(&b.source_span.start))
66    });
67    imports.dedup_by(|a, b| a.source == b.source);
68    imports
69}
70
71pub(crate) fn parse_graphql_to_module(
72    file_id: FileId,
73    source: &str,
74    content_hash: u64,
75) -> ModuleInfo {
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        member_accesses: Vec::new(),
85        whole_object_uses: Vec::new(),
86        has_cjs_exports: false,
87        content_hash,
88        suppressions: crate::suppress::parse_suppressions_from_source(source),
89        unused_import_bindings: Vec::new(),
90        type_referenced_import_bindings: Vec::new(),
91        value_referenced_import_bindings: Vec::new(),
92        line_offsets: fallow_types::extract::compute_line_offsets(source),
93        complexity: Vec::new(),
94        flag_uses: Vec::new(),
95        class_heritage: Vec::new(),
96        local_type_declarations: Vec::new(),
97        public_signature_type_references: Vec::new(),
98        namespace_object_aliases: Vec::new(),
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn graphql_file_extensions_are_supported() {
108        assert!(is_graphql_file(Path::new("schema.graphql")));
109        assert!(is_graphql_file(Path::new("fragment.gql")));
110        assert!(!is_graphql_file(Path::new("query.ts")));
111    }
112
113    #[test]
114    fn extracts_relative_hash_imports() {
115        let imports = extract_graphql_imports(
116            r#"
117            #import "./content.graphql"
118            # import '../shared/leaf.gql'
119            #import "package/schema.graphql"
120            fragment Story on Story { id }
121            "#,
122        );
123
124        let sources: Vec<&str> = imports
125            .iter()
126            .map(|import| import.source.as_str())
127            .collect();
128        assert_eq!(sources, vec!["../shared/leaf.gql", "./content.graphql"]);
129        assert!(
130            imports
131                .iter()
132                .all(|import| matches!(import.imported_name, ImportedName::SideEffect))
133        );
134    }
135
136    #[test]
137    fn parse_graphql_to_module_sets_imports_and_offsets() {
138        let info = parse_graphql_to_module(
139            FileId(7),
140            "#import \"./content.graphql\"\nfragment Story on Story { id }\n",
141            42,
142        );
143
144        assert_eq!(info.file_id, FileId(7));
145        assert_eq!(info.content_hash, 42);
146        assert_eq!(info.imports.len(), 1);
147        assert_eq!(info.imports[0].source, "./content.graphql");
148        assert_eq!(info.line_offsets, vec![0, 28, 59]);
149    }
150}