@soda-gql/swc-transformer 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib.rs ADDED
@@ -0,0 +1,87 @@
1
+ //! SWC-based transformer for soda-gql GraphQL code generation.
2
+ //!
3
+ //! This crate provides a native Node.js module using napi-rs that transforms
4
+ //! `gql.default()` calls into `gqlRuntime.*` calls at build time.
5
+
6
+ mod transform;
7
+ mod types;
8
+
9
+ use napi::bindgen_prelude::*;
10
+ use napi_derive::napi;
11
+ use types::config::{TransformConfig, TransformInput, TransformInputRef};
12
+ use types::BuilderArtifact;
13
+
14
+ /// Transform a single source file.
15
+ ///
16
+ /// # Arguments
17
+ /// * `input_json` - JSON-serialized TransformInput containing source code, file path, artifact, and config
18
+ ///
19
+ /// # Returns
20
+ /// JSON-serialized TransformResult containing the transformed code
21
+ #[napi]
22
+ pub fn transform(input_json: String) -> Result<String> {
23
+ let input: TransformInput = serde_json::from_str(&input_json)
24
+ .map_err(|e| Error::from_reason(format!("Failed to parse input: {}", e)))?;
25
+
26
+ let result = transform::transformer::transform_source(&input)
27
+ .map_err(|e| Error::from_reason(e))?;
28
+
29
+ serde_json::to_string(&result)
30
+ .map_err(|e| Error::from_reason(format!("Failed to serialize result: {}", e)))
31
+ }
32
+
33
+ /// Stateful transformer that caches artifact and config for multiple file transformations.
34
+ ///
35
+ /// The artifact is parsed once in the constructor and reused for all subsequent
36
+ /// transform calls, avoiding repeated JSON parsing overhead.
37
+ #[napi]
38
+ pub struct SwcTransformer {
39
+ /// Pre-parsed BuilderArtifact (parsed once in constructor)
40
+ artifact: BuilderArtifact,
41
+ config: TransformConfig,
42
+ }
43
+
44
+ #[napi]
45
+ impl SwcTransformer {
46
+ /// Create a new transformer instance.
47
+ ///
48
+ /// # Arguments
49
+ /// * `artifact_json` - JSON-serialized BuilderArtifact
50
+ /// * `config_json` - JSON-serialized TransformConfig
51
+ #[napi(constructor)]
52
+ pub fn new(artifact_json: String, config_json: String) -> Result<Self> {
53
+ let config: TransformConfig = serde_json::from_str(&config_json)
54
+ .map_err(|e| Error::from_reason(format!("Failed to parse config: {}", e)))?;
55
+
56
+ // Parse artifact once in constructor to avoid repeated parsing
57
+ let artifact: BuilderArtifact = serde_json::from_str(&artifact_json)
58
+ .map_err(|e| Error::from_reason(format!("Failed to parse artifact: {}", e)))?;
59
+
60
+ Ok(SwcTransformer { artifact, config })
61
+ }
62
+
63
+ /// Transform a single source file.
64
+ ///
65
+ /// # Arguments
66
+ /// * `source_code` - The source code to transform
67
+ /// * `source_path` - The file path of the source
68
+ ///
69
+ /// # Returns
70
+ /// JSON-serialized TransformResult
71
+ #[napi]
72
+ pub fn transform(&self, source_code: String, source_path: String) -> Result<String> {
73
+ // Use pre-parsed artifact reference instead of re-parsing JSON
74
+ let input = TransformInputRef {
75
+ source_code,
76
+ source_path,
77
+ artifact: &self.artifact,
78
+ config: self.config.clone(),
79
+ };
80
+
81
+ let result = transform::transformer::transform_source_ref(&input)
82
+ .map_err(|e| Error::from_reason(e))?;
83
+
84
+ serde_json::to_string(&result)
85
+ .map_err(|e| Error::from_reason(format!("Failed to serialize result: {}", e)))
86
+ }
87
+ }
@@ -0,0 +1,42 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+
4
+ /* auto-generated by NAPI-RS */
5
+
6
+ /**
7
+ * Transform a single source file.
8
+ *
9
+ * # Arguments
10
+ * * `input_json` - JSON-serialized TransformInput containing source code, file path, artifact, and config
11
+ *
12
+ * # Returns
13
+ * JSON-serialized TransformResult containing the transformed code
14
+ */
15
+ export declare function transform(inputJson: string): string
16
+ /**
17
+ * Stateful transformer that caches artifact and config for multiple file transformations.
18
+ *
19
+ * The artifact is parsed once in the constructor and reused for all subsequent
20
+ * transform calls, avoiding repeated JSON parsing overhead.
21
+ */
22
+ export declare class SwcTransformer {
23
+ /**
24
+ * Create a new transformer instance.
25
+ *
26
+ * # Arguments
27
+ * * `artifact_json` - JSON-serialized BuilderArtifact
28
+ * * `config_json` - JSON-serialized TransformConfig
29
+ */
30
+ constructor(artifactJson: string, configJson: string)
31
+ /**
32
+ * Transform a single source file.
33
+ *
34
+ * # Arguments
35
+ * * `source_code` - The source code to transform
36
+ * * `source_path` - The file path of the source
37
+ *
38
+ * # Returns
39
+ * JSON-serialized TransformResult
40
+ */
41
+ transform(sourceCode: string, sourcePath: string): string
42
+ }
@@ -0,0 +1,316 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+ /* prettier-ignore */
4
+
5
+ /* auto-generated by NAPI-RS */
6
+
7
+ const { existsSync, readFileSync } = require('fs')
8
+ const { join } = require('path')
9
+
10
+ const { platform, arch } = process
11
+
12
+ let nativeBinding = null
13
+ let localFileExisted = false
14
+ let loadError = null
15
+
16
+ function isMusl() {
17
+ // For Node 10
18
+ if (!process.report || typeof process.report.getReport !== 'function') {
19
+ try {
20
+ const lddPath = require('child_process').execSync('which ldd').toString().trim()
21
+ return readFileSync(lddPath, 'utf8').includes('musl')
22
+ } catch (e) {
23
+ return true
24
+ }
25
+ } else {
26
+ const { glibcVersionRuntime } = process.report.getReport().header
27
+ return !glibcVersionRuntime
28
+ }
29
+ }
30
+
31
+ switch (platform) {
32
+ case 'android':
33
+ switch (arch) {
34
+ case 'arm64':
35
+ localFileExisted = existsSync(join(__dirname, 'swc-transformer.android-arm64.node'))
36
+ try {
37
+ if (localFileExisted) {
38
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'android-arm64.node')
39
+ } else {
40
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'android-arm64')
41
+ }
42
+ } catch (e) {
43
+ loadError = e
44
+ }
45
+ break
46
+ case 'arm':
47
+ localFileExisted = existsSync(join(__dirname, 'swc-transformer.android-arm-eabi.node'))
48
+ try {
49
+ if (localFileExisted) {
50
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'android-arm-eabi.node')
51
+ } else {
52
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'android-arm-eabi')
53
+ }
54
+ } catch (e) {
55
+ loadError = e
56
+ }
57
+ break
58
+ default:
59
+ throw new Error(`Unsupported architecture on Android ${arch}`)
60
+ }
61
+ break
62
+ case 'win32':
63
+ switch (arch) {
64
+ case 'x64':
65
+ localFileExisted = existsSync(
66
+ join(__dirname, 'swc-transformer.win32-x64-msvc.node')
67
+ )
68
+ try {
69
+ if (localFileExisted) {
70
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'win32-x64-msvc.node')
71
+ } else {
72
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'win32-x64-msvc')
73
+ }
74
+ } catch (e) {
75
+ loadError = e
76
+ }
77
+ break
78
+ case 'ia32':
79
+ localFileExisted = existsSync(
80
+ join(__dirname, 'swc-transformer.win32-ia32-msvc.node')
81
+ )
82
+ try {
83
+ if (localFileExisted) {
84
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'win32-ia32-msvc.node')
85
+ } else {
86
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'win32-ia32-msvc')
87
+ }
88
+ } catch (e) {
89
+ loadError = e
90
+ }
91
+ break
92
+ case 'arm64':
93
+ localFileExisted = existsSync(
94
+ join(__dirname, 'swc-transformer.win32-arm64-msvc.node')
95
+ )
96
+ try {
97
+ if (localFileExisted) {
98
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'win32-arm64-msvc.node')
99
+ } else {
100
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'win32-arm64-msvc')
101
+ }
102
+ } catch (e) {
103
+ loadError = e
104
+ }
105
+ break
106
+ default:
107
+ throw new Error(`Unsupported architecture on Windows: ${arch}`)
108
+ }
109
+ break
110
+ case 'darwin':
111
+ localFileExisted = existsSync(join(__dirname, 'swc-transformer.darwin-universal.node'))
112
+ try {
113
+ if (localFileExisted) {
114
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'darwin-universal.node')
115
+ } else {
116
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'darwin-universal')
117
+ }
118
+ break
119
+ } catch {}
120
+ switch (arch) {
121
+ case 'x64':
122
+ localFileExisted = existsSync(join(__dirname, 'swc-transformer.darwin-x64.node'))
123
+ try {
124
+ if (localFileExisted) {
125
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'darwin-x64.node')
126
+ } else {
127
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'darwin-x64')
128
+ }
129
+ } catch (e) {
130
+ loadError = e
131
+ }
132
+ break
133
+ case 'arm64':
134
+ localFileExisted = existsSync(
135
+ join(__dirname, 'swc-transformer.darwin-arm64.node')
136
+ )
137
+ try {
138
+ if (localFileExisted) {
139
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'darwin-arm64.node')
140
+ } else {
141
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'darwin-arm64')
142
+ }
143
+ } catch (e) {
144
+ loadError = e
145
+ }
146
+ break
147
+ default:
148
+ throw new Error(`Unsupported architecture on macOS: ${arch}`)
149
+ }
150
+ break
151
+ case 'freebsd':
152
+ if (arch !== 'x64') {
153
+ throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
154
+ }
155
+ localFileExisted = existsSync(join(__dirname, 'swc-transformer.freebsd-x64.node'))
156
+ try {
157
+ if (localFileExisted) {
158
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'freebsd-x64.node')
159
+ } else {
160
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'freebsd-x64')
161
+ }
162
+ } catch (e) {
163
+ loadError = e
164
+ }
165
+ break
166
+ case 'linux':
167
+ switch (arch) {
168
+ case 'x64':
169
+ if (isMusl()) {
170
+ localFileExisted = existsSync(
171
+ join(__dirname, 'swc-transformer.linux-x64-musl.node')
172
+ )
173
+ try {
174
+ if (localFileExisted) {
175
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'linux-x64-musl.node')
176
+ } else {
177
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'linux-x64-musl')
178
+ }
179
+ } catch (e) {
180
+ loadError = e
181
+ }
182
+ } else {
183
+ localFileExisted = existsSync(
184
+ join(__dirname, 'swc-transformer.linux-x64-gnu.node')
185
+ )
186
+ try {
187
+ if (localFileExisted) {
188
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'linux-x64-gnu.node')
189
+ } else {
190
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'linux-x64-gnu')
191
+ }
192
+ } catch (e) {
193
+ loadError = e
194
+ }
195
+ }
196
+ break
197
+ case 'arm64':
198
+ if (isMusl()) {
199
+ localFileExisted = existsSync(
200
+ join(__dirname, 'swc-transformer.linux-arm64-musl.node')
201
+ )
202
+ try {
203
+ if (localFileExisted) {
204
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'linux-arm64-musl.node')
205
+ } else {
206
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'linux-arm64-musl')
207
+ }
208
+ } catch (e) {
209
+ loadError = e
210
+ }
211
+ } else {
212
+ localFileExisted = existsSync(
213
+ join(__dirname, 'swc-transformer.linux-arm64-gnu.node')
214
+ )
215
+ try {
216
+ if (localFileExisted) {
217
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'linux-arm64-gnu.node')
218
+ } else {
219
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'linux-arm64-gnu')
220
+ }
221
+ } catch (e) {
222
+ loadError = e
223
+ }
224
+ }
225
+ break
226
+ case 'arm':
227
+ if (isMusl()) {
228
+ localFileExisted = existsSync(
229
+ join(__dirname, 'swc-transformer.linux-arm-musleabihf.node')
230
+ )
231
+ try {
232
+ if (localFileExisted) {
233
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'linux-arm-musleabihf.node')
234
+ } else {
235
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'linux-arm-musleabihf')
236
+ }
237
+ } catch (e) {
238
+ loadError = e
239
+ }
240
+ } else {
241
+ localFileExisted = existsSync(
242
+ join(__dirname, 'swc-transformer.linux-arm-gnueabihf.node')
243
+ )
244
+ try {
245
+ if (localFileExisted) {
246
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'linux-arm-gnueabihf.node')
247
+ } else {
248
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'linux-arm-gnueabihf')
249
+ }
250
+ } catch (e) {
251
+ loadError = e
252
+ }
253
+ }
254
+ break
255
+ case 'riscv64':
256
+ if (isMusl()) {
257
+ localFileExisted = existsSync(
258
+ join(__dirname, 'swc-transformer.linux-riscv64-musl.node')
259
+ )
260
+ try {
261
+ if (localFileExisted) {
262
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'linux-riscv64-musl.node')
263
+ } else {
264
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'linux-riscv64-musl')
265
+ }
266
+ } catch (e) {
267
+ loadError = e
268
+ }
269
+ } else {
270
+ localFileExisted = existsSync(
271
+ join(__dirname, 'swc-transformer.linux-riscv64-gnu.node')
272
+ )
273
+ try {
274
+ if (localFileExisted) {
275
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'linux-riscv64-gnu.node')
276
+ } else {
277
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'linux-riscv64-gnu')
278
+ }
279
+ } catch (e) {
280
+ loadError = e
281
+ }
282
+ }
283
+ break
284
+ case 's390x':
285
+ localFileExisted = existsSync(
286
+ join(__dirname, 'swc-transformer.linux-s390x-gnu.node')
287
+ )
288
+ try {
289
+ if (localFileExisted) {
290
+ nativeBinding = require('./swc-transformer.'.slice(0) + 'linux-s390x-gnu.node')
291
+ } else {
292
+ nativeBinding = require('@soda-gql/swc-transformer-'.slice(0) + 'linux-s390x-gnu')
293
+ }
294
+ } catch (e) {
295
+ loadError = e
296
+ }
297
+ break
298
+ default:
299
+ throw new Error(`Unsupported architecture on Linux: ${arch}`)
300
+ }
301
+ break
302
+ default:
303
+ throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
304
+ }
305
+
306
+ if (!nativeBinding) {
307
+ if (loadError) {
308
+ throw loadError
309
+ }
310
+ throw new Error(`Failed to load native binding`)
311
+ }
312
+
313
+ const { transform, SwcTransformer } = nativeBinding
314
+
315
+ module.exports.transform = transform
316
+ module.exports.SwcTransformer = SwcTransformer
@@ -0,0 +1,240 @@
1
+ //! GQL call analysis module.
2
+ //!
3
+ //! This module is responsible for:
4
+ //! - Detecting `gql.default()` call patterns
5
+ //! - Extracting the inner builder call
6
+ //! - Mapping calls to their corresponding artifacts
7
+
8
+ use std::collections::HashMap;
9
+ use swc_core::common::Span;
10
+ use swc_core::ecma::ast::*;
11
+ use swc_core::ecma::visit::{Visit, VisitWith};
12
+
13
+ use crate::types::{BuilderArtifact, BuilderArtifactElement, CanonicalId, PluginError};
14
+
15
+ use super::metadata::MetadataMap;
16
+
17
+ /// Information about a detected GQL call that needs to be transformed.
18
+ #[allow(dead_code)]
19
+ #[derive(Debug, Clone)]
20
+ pub struct GqlCallInfo {
21
+ /// The canonical ID for this call
22
+ pub canonical_id: CanonicalId,
23
+ /// The artifact element from the builder
24
+ pub artifact: BuilderArtifactElement,
25
+ /// Span of the original gql.default() call
26
+ pub call_span: Span,
27
+ /// The inner builder call (e.g., fragment.User(), query.slice())
28
+ pub builder_call_args: Vec<ExprOrSpread>,
29
+ }
30
+
31
+ /// Replacement information for a GQL call.
32
+ #[derive(Debug)]
33
+ pub struct GqlReplacement {
34
+ #[allow(dead_code)]
35
+ pub canonical_id: CanonicalId,
36
+ pub artifact: BuilderArtifactElement,
37
+ pub builder_args: Vec<ExprOrSpread>,
38
+ }
39
+
40
+ /// Finds GQL calls in the AST and prepares them for transformation.
41
+ pub struct GqlCallFinder<'a> {
42
+ artifact: &'a BuilderArtifact,
43
+ metadata: &'a MetadataMap,
44
+ source_path: &'a str,
45
+ /// Map from call span to replacement info
46
+ replacements: HashMap<Span, GqlReplacement>,
47
+ has_transforms: bool,
48
+ /// Errors encountered during analysis
49
+ errors: Vec<PluginError>,
50
+ }
51
+
52
+ impl<'a> GqlCallFinder<'a> {
53
+ pub fn new(artifact: &'a BuilderArtifact, metadata: &'a MetadataMap, source_path: &'a str) -> Self {
54
+ Self {
55
+ artifact,
56
+ metadata,
57
+ source_path,
58
+ replacements: HashMap::new(),
59
+ has_transforms: false,
60
+ errors: Vec::new(),
61
+ }
62
+ }
63
+
64
+ /// Check if any transformations were found.
65
+ pub fn has_transformations(&self) -> bool {
66
+ self.has_transforms
67
+ }
68
+
69
+ /// Get the replacement for a call expression if it should be transformed.
70
+ pub fn get_replacement(&self, call: &CallExpr) -> Option<&GqlReplacement> {
71
+ self.replacements.get(&call.span)
72
+ }
73
+
74
+ /// Take collected errors.
75
+ pub fn take_errors(&mut self) -> Vec<PluginError> {
76
+ std::mem::take(&mut self.errors)
77
+ }
78
+
79
+ /// Process a potential GQL call expression.
80
+ fn process_call(&mut self, call: &CallExpr) {
81
+ // Check if this is a gql.default() or gql.* call
82
+ if let Some(builder_call) = find_gql_builder_call(call) {
83
+ // Get metadata for this call
84
+ if let Some(meta) = self.metadata.get(&call.span) {
85
+ let canonical_id = resolve_canonical_id(self.source_path, &meta.ast_path);
86
+
87
+ // Look up the artifact
88
+ if let Some(artifact) = self.artifact.get(&canonical_id) {
89
+ self.replacements.insert(
90
+ call.span,
91
+ GqlReplacement {
92
+ canonical_id,
93
+ artifact: artifact.clone(),
94
+ builder_args: builder_call.args.clone(),
95
+ },
96
+ );
97
+ self.has_transforms = true;
98
+ } else {
99
+ let error = PluginError::artifact_not_found(self.source_path, &canonical_id);
100
+ eprintln!("[swc-transformer] {}", error.format());
101
+ self.errors.push(error);
102
+ }
103
+ } else {
104
+ let error = PluginError::metadata_not_found(self.source_path);
105
+ eprintln!("[swc-transformer] {}", error.format());
106
+ self.errors.push(error);
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ impl Visit for GqlCallFinder<'_> {
113
+ fn visit_call_expr(&mut self, call: &CallExpr) {
114
+ // First check this call
115
+ self.process_call(call);
116
+
117
+ // Then visit children
118
+ call.visit_children_with(self);
119
+ }
120
+ }
121
+
122
+ /// Find the inner builder call from a gql.default() call.
123
+ ///
124
+ /// Supports both arrow functions and function expressions:
125
+ /// - `gql.default(({ fragment }) => fragment.User(...))`
126
+ /// - `gql.default(function({ fragment }) { return fragment.User(...); })`
127
+ ///
128
+ /// Returns: The inner builder call expression (e.g., `fragment.User(...)`)
129
+ fn find_gql_builder_call(call: &CallExpr) -> Option<&CallExpr> {
130
+ // Check if callee is gql.* pattern
131
+ if !is_gql_member_expression(&call.callee) {
132
+ return None;
133
+ }
134
+
135
+ // Should have exactly one argument
136
+ if call.args.len() != 1 {
137
+ return None;
138
+ }
139
+
140
+ // The argument should be an arrow function or function expression
141
+ let arg = &call.args[0];
142
+ if arg.spread.is_some() {
143
+ return None;
144
+ }
145
+
146
+ match &*arg.expr {
147
+ Expr::Arrow(arrow) => extract_builder_call_from_arrow(arrow),
148
+ Expr::Fn(fn_expr) => extract_builder_call_from_fn(fn_expr),
149
+ _ => None,
150
+ }
151
+ }
152
+
153
+ /// Check if the callee is a gql.* member expression.
154
+ fn is_gql_member_expression(callee: &Callee) -> bool {
155
+ match callee {
156
+ Callee::Expr(expr) => {
157
+ if let Expr::Member(member) = &**expr {
158
+ is_gql_reference(&member.obj)
159
+ } else {
160
+ false
161
+ }
162
+ }
163
+ _ => false,
164
+ }
165
+ }
166
+
167
+ /// Recursively check if an expression is a reference to `gql`.
168
+ fn is_gql_reference(expr: &Expr) -> bool {
169
+ match expr {
170
+ Expr::Ident(ident) => atom_eq(&ident.sym, "gql"),
171
+ Expr::Member(member) => {
172
+ // Check if property is "gql"
173
+ if let MemberProp::Ident(ident) = &member.prop {
174
+ if atom_eq(&ident.sym, "gql") {
175
+ return true;
176
+ }
177
+ }
178
+ // Recursively check the object
179
+ is_gql_reference(&member.obj)
180
+ }
181
+ _ => false,
182
+ }
183
+ }
184
+
185
+ /// Helper to compare an atom with a string.
186
+ fn atom_eq<T: AsRef<str>>(atom: &T, s: &str) -> bool {
187
+ atom.as_ref() == s
188
+ }
189
+
190
+ /// Extract the builder call from an arrow function body.
191
+ fn extract_builder_call_from_arrow(arrow: &ArrowExpr) -> Option<&CallExpr> {
192
+ match &*arrow.body {
193
+ BlockStmtOrExpr::Expr(expr) => {
194
+ if let Expr::Call(call) = &**expr {
195
+ Some(call)
196
+ } else {
197
+ None
198
+ }
199
+ }
200
+ BlockStmtOrExpr::BlockStmt(block) => extract_call_from_block(block),
201
+ }
202
+ }
203
+
204
+ /// Extract the builder call from a function expression body.
205
+ fn extract_builder_call_from_fn(fn_expr: &FnExpr) -> Option<&CallExpr> {
206
+ // Function body is Option<BlockStmt>
207
+ fn_expr
208
+ .function
209
+ .body
210
+ .as_ref()
211
+ .and_then(|block| extract_call_from_block(block))
212
+ }
213
+
214
+ /// Extract a call expression from a block statement (shared by arrow and fn).
215
+ fn extract_call_from_block(block: &BlockStmt) -> Option<&CallExpr> {
216
+ // Look for a return statement with a call expression
217
+ for stmt in &block.stmts {
218
+ if let Stmt::Return(ret) = stmt {
219
+ if let Some(arg) = &ret.arg {
220
+ if let Expr::Call(call) = &**arg {
221
+ return Some(call);
222
+ }
223
+ }
224
+ }
225
+ }
226
+ None
227
+ }
228
+
229
+ /// Resolve a canonical ID from file path and AST path.
230
+ /// The canonical ID format is: {normalizedAbsPath}::{astPath}
231
+ ///
232
+ /// This mirrors the TypeScript implementation in @soda-gql/common:
233
+ /// - Normalizes path separators to forward slashes (cross-platform)
234
+ /// - Format matches builder artifact keys exactly
235
+ fn resolve_canonical_id(file_path: &str, ast_path: &str) -> CanonicalId {
236
+ // Normalize path separators to forward slashes for cross-platform compatibility
237
+ // This matches the TypeScript normalizePath function behavior
238
+ let normalized_path = file_path.replace('\\', "/");
239
+ format!("{}::{}", normalized_path, ast_path)
240
+ }