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    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn graphql_file_extensions_are_supported() {
107        assert!(is_graphql_file(Path::new("schema.graphql")));
108        assert!(is_graphql_file(Path::new("fragment.gql")));
109        assert!(!is_graphql_file(Path::new("query.ts")));
110    }
111
112    #[test]
113    fn extracts_relative_hash_imports() {
114        let imports = extract_graphql_imports(
115            r#"
116            #import "./content.graphql"
117            # import '../shared/leaf.gql'
118            #import "package/schema.graphql"
119            fragment Story on Story { id }
120            "#,
121        );
122
123        let sources: Vec<&str> = imports
124            .iter()
125            .map(|import| import.source.as_str())
126            .collect();
127        assert_eq!(sources, vec!["../shared/leaf.gql", "./content.graphql"]);
128        assert!(
129            imports
130                .iter()
131                .all(|import| matches!(import.imported_name, ImportedName::SideEffect))
132        );
133    }
134
135    #[test]
136    fn parse_graphql_to_module_sets_imports_and_offsets() {
137        let info = parse_graphql_to_module(
138            FileId(7),
139            "#import \"./content.graphql\"\nfragment Story on Story { id }\n",
140            42,
141        );
142
143        assert_eq!(info.file_id, FileId(7));
144        assert_eq!(info.content_hash, 42);
145        assert_eq!(info.imports.len(), 1);
146        assert_eq!(info.imports[0].source, "./content.graphql");
147        assert_eq!(info.line_offsets, vec![0, 28, 59]);
148    }
149}