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    let parsed_suppressions = crate::suppress::parse_suppressions_from_source(source);
77    ModuleInfo {
78        file_id,
79        exports: Vec::new(),
80        imports: extract_graphql_imports(source),
81        re_exports: Vec::new(),
82        dynamic_imports: Vec::new(),
83        dynamic_import_patterns: Vec::new(),
84        require_calls: Vec::new(),
85        member_accesses: Vec::new(),
86        whole_object_uses: Vec::new(),
87        has_cjs_exports: false,
88        has_angular_component_template_url: false,
89        content_hash,
90        suppressions: parsed_suppressions.suppressions,
91        unknown_suppression_kinds: parsed_suppressions.unknown_kinds,
92        unused_import_bindings: Vec::new(),
93        type_referenced_import_bindings: Vec::new(),
94        value_referenced_import_bindings: Vec::new(),
95        line_offsets: fallow_types::extract::compute_line_offsets(source),
96        complexity: Vec::new(),
97        flag_uses: Vec::new(),
98        class_heritage: Vec::new(),
99        local_type_declarations: Vec::new(),
100        public_signature_type_references: Vec::new(),
101        namespace_object_aliases: Vec::new(),
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn graphql_file_extensions_are_supported() {
111        assert!(is_graphql_file(Path::new("schema.graphql")));
112        assert!(is_graphql_file(Path::new("fragment.gql")));
113        assert!(!is_graphql_file(Path::new("query.ts")));
114    }
115
116    #[test]
117    fn extracts_relative_hash_imports() {
118        let imports = extract_graphql_imports(
119            r#"
120            #import "./content.graphql"
121            # import '../shared/leaf.gql'
122            #import "package/schema.graphql"
123            fragment Story on Story { id }
124            "#,
125        );
126
127        let sources: Vec<&str> = imports
128            .iter()
129            .map(|import| import.source.as_str())
130            .collect();
131        assert_eq!(sources, vec!["../shared/leaf.gql", "./content.graphql"]);
132        assert!(
133            imports
134                .iter()
135                .all(|import| matches!(import.imported_name, ImportedName::SideEffect))
136        );
137    }
138
139    #[test]
140    fn parse_graphql_to_module_sets_imports_and_offsets() {
141        let info = parse_graphql_to_module(
142            FileId(7),
143            "#import \"./content.graphql\"\nfragment Story on Story { id }\n",
144            42,
145        );
146
147        assert_eq!(info.file_id, FileId(7));
148        assert_eq!(info.content_hash, 42);
149        assert_eq!(info.imports.len(), 1);
150        assert_eq!(info.imports[0].source, "./content.graphql");
151        assert_eq!(info.line_offsets, vec![0, 28, 59]);
152    }
153}