1use 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 injection_tokens: Vec::new(),
102 local_type_declarations: Vec::new(),
103 public_signature_type_references: Vec::new(),
104 namespace_object_aliases: Vec::new(),
105 iconify_prefixes: Vec::new(),
106 iconify_icon_names: Vec::new(),
107 auto_import_candidates: Vec::new(),
108 directives: Vec::new(),
109 client_only_dynamic_import_spans: Vec::new(),
110 security_sinks: Vec::new(),
111 security_sinks_skipped: 0,
112 security_unresolved_callee_sites: Vec::new(),
113 tainted_bindings: Vec::new(),
114 sanitized_sink_args: Vec::new(),
115 security_control_sites: Vec::new(),
116 callee_uses: Vec::new(),
117 misplaced_directives: Vec::new(),
118 inline_server_action_exports: Vec::new(),
119 di_key_sites: Vec::new(),
120 has_dynamic_provide: false,
121 referenced_import_bindings: Vec::new(),
122 component_props: Vec::new(),
123 has_props_attrs_fallthrough: false,
124 has_define_expose: false,
125 has_define_model: false,
126 has_unharvestable_props: false,
127 component_emits: Vec::new(),
128 angular_inputs: Vec::new(),
129 angular_outputs: Vec::new(),
130 angular_component_selectors: Vec::new(),
131 registered_custom_elements: Vec::new(),
132 used_custom_element_tags: Vec::new(),
133 angular_used_selectors: Vec::new(),
134 angular_entry_component_refs: Vec::new(),
135 has_dynamic_component_render: false,
136 has_unharvestable_emits: false,
137 has_dynamic_emit: false,
138 has_emit_whole_object_use: false,
139 load_return_keys: Vec::new(),
140 has_unharvestable_load: false,
141 has_load_data_whole_use: false,
142 has_page_data_store_whole_use: false,
143 component_functions: Vec::new(),
144 react_props: Vec::new(),
145 hook_uses: Vec::new(),
146 render_edges: Vec::new(),
147 svelte_dispatched_events: Vec::new(),
148 svelte_listened_events: Vec::new(),
149 has_dynamic_dispatch: false,
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn graphql_file_extensions_are_supported() {
159 assert!(is_graphql_file(Path::new("schema.graphql")));
160 assert!(is_graphql_file(Path::new("fragment.gql")));
161 assert!(!is_graphql_file(Path::new("query.ts")));
162 }
163
164 #[test]
165 fn extracts_relative_hash_imports() {
166 let imports = extract_graphql_imports(
167 r#"
168 #import "./content.graphql"
169 # import '../shared/leaf.gql'
170 #import "package/schema.graphql"
171 fragment Story on Story { id }
172 "#,
173 );
174
175 let sources: Vec<&str> = imports
176 .iter()
177 .map(|import| import.source.as_str())
178 .collect();
179 assert_eq!(sources, vec!["../shared/leaf.gql", "./content.graphql"]);
180 assert!(
181 imports
182 .iter()
183 .all(|import| matches!(import.imported_name, ImportedName::SideEffect))
184 );
185 }
186
187 #[test]
188 fn parse_graphql_to_module_sets_imports_and_offsets() {
189 let info = parse_graphql_to_module(
190 FileId(7),
191 "#import \"./content.graphql\"\nfragment Story on Story { id }\n",
192 42,
193 );
194
195 assert_eq!(info.file_id, FileId(7));
196 assert_eq!(info.content_hash, 42);
197 assert_eq!(info.imports.len(), 1);
198 assert_eq!(info.imports[0].source, "./content.graphql");
199 assert_eq!(info.line_offsets, vec![0, 28, 59]);
200 }
201}