use std::path::Path;
use std::sync::LazyLock;
use oxc_span::Span;
use crate::{ImportInfo, ImportedName, ModuleInfo};
use fallow_types::discover::FileId;
static GRAPHQL_IMPORT_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#"(?m)^[ \t]*#\s*import\s+["']([^"'\r\n]+)["']"#).expect("valid regex")
});
pub(crate) fn is_graphql_file(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| ext == "graphql" || ext == "gql")
}
fn is_relative_graphql_import(source: &str) -> bool {
source.starts_with("./") || source.starts_with("../")
}
#[expect(
clippy::cast_possible_truncation,
reason = "source spans are bounded by source file size, which is practically below u32::MAX"
)]
fn span_from_usize(start: usize, end: usize) -> Span {
Span::new(start as u32, end as u32)
}
#[must_use]
pub(crate) fn extract_graphql_imports(source: &str) -> Vec<ImportInfo> {
let mut imports = Vec::new();
for cap in GRAPHQL_IMPORT_RE.captures_iter(source) {
let Some(source_match) = cap.get(1) else {
continue;
};
let import_source = source_match.as_str().trim();
if import_source.is_empty() || !is_relative_graphql_import(import_source) {
continue;
}
imports.push(ImportInfo {
source: import_source.to_string(),
imported_name: ImportedName::SideEffect,
local_name: String::new(),
is_type_only: false,
from_style: false,
span: cap
.get(0)
.map_or_else(Span::default, |m| span_from_usize(m.start(), m.end())),
source_span: span_from_usize(source_match.start(), source_match.end()),
});
}
imports.sort_unstable_by(|a, b| {
a.source
.cmp(&b.source)
.then(a.source_span.start.cmp(&b.source_span.start))
});
imports.dedup_by(|a, b| a.source == b.source);
imports
}
pub(crate) fn parse_graphql_to_module(
file_id: FileId,
source: &str,
content_hash: u64,
) -> ModuleInfo {
ModuleInfo {
file_id,
exports: Vec::new(),
imports: extract_graphql_imports(source),
re_exports: Vec::new(),
dynamic_imports: Vec::new(),
dynamic_import_patterns: Vec::new(),
require_calls: Vec::new(),
member_accesses: Vec::new(),
whole_object_uses: Vec::new(),
has_cjs_exports: false,
content_hash,
suppressions: crate::suppress::parse_suppressions_from_source(source),
unused_import_bindings: Vec::new(),
type_referenced_import_bindings: Vec::new(),
value_referenced_import_bindings: Vec::new(),
line_offsets: fallow_types::extract::compute_line_offsets(source),
complexity: Vec::new(),
flag_uses: Vec::new(),
class_heritage: Vec::new(),
local_type_declarations: Vec::new(),
public_signature_type_references: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn graphql_file_extensions_are_supported() {
assert!(is_graphql_file(Path::new("schema.graphql")));
assert!(is_graphql_file(Path::new("fragment.gql")));
assert!(!is_graphql_file(Path::new("query.ts")));
}
#[test]
fn extracts_relative_hash_imports() {
let imports = extract_graphql_imports(
r#"
#import "./content.graphql"
# import '../shared/leaf.gql'
#import "package/schema.graphql"
fragment Story on Story { id }
"#,
);
let sources: Vec<&str> = imports
.iter()
.map(|import| import.source.as_str())
.collect();
assert_eq!(sources, vec!["../shared/leaf.gql", "./content.graphql"]);
assert!(
imports
.iter()
.all(|import| matches!(import.imported_name, ImportedName::SideEffect))
);
}
#[test]
fn parse_graphql_to_module_sets_imports_and_offsets() {
let info = parse_graphql_to_module(
FileId(7),
"#import \"./content.graphql\"\nfragment Story on Story { id }\n",
42,
);
assert_eq!(info.file_id, FileId(7));
assert_eq!(info.content_hash, 42);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "./content.graphql");
assert_eq!(info.line_offsets, vec![0, 28, 59]);
}
}