@clayroach/unplugin 0.1.0-source-trace.3 → 0.1.0-source-trace.5

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.
Files changed (34) hide show
  1. package/dist/cjs/index.js +12 -3
  2. package/dist/cjs/index.js.map +1 -1
  3. package/dist/cjs/transformers/annotateEffects.js +141 -0
  4. package/dist/cjs/transformers/annotateEffects.js.map +1 -0
  5. package/dist/cjs/transformers/sourceTrace.js +56 -6
  6. package/dist/cjs/transformers/sourceTrace.js.map +1 -1
  7. package/dist/cjs/transformers/withSpanTrace.js +54 -2
  8. package/dist/cjs/transformers/withSpanTrace.js.map +1 -1
  9. package/dist/cjs/utils/effectDetection.js +54 -0
  10. package/dist/cjs/utils/effectDetection.js.map +1 -1
  11. package/dist/dts/index.d.ts.map +1 -1
  12. package/dist/dts/transformers/annotateEffects.d.ts +41 -0
  13. package/dist/dts/transformers/annotateEffects.d.ts.map +1 -0
  14. package/dist/dts/transformers/sourceTrace.d.ts +11 -2
  15. package/dist/dts/transformers/sourceTrace.d.ts.map +1 -1
  16. package/dist/dts/transformers/withSpanTrace.d.ts.map +1 -1
  17. package/dist/dts/utils/effectDetection.d.ts +15 -1
  18. package/dist/dts/utils/effectDetection.d.ts.map +1 -1
  19. package/dist/esm/index.js +10 -3
  20. package/dist/esm/index.js.map +1 -1
  21. package/dist/esm/transformers/annotateEffects.js +140 -0
  22. package/dist/esm/transformers/annotateEffects.js.map +1 -0
  23. package/dist/esm/transformers/sourceTrace.js +66 -7
  24. package/dist/esm/transformers/sourceTrace.js.map +1 -1
  25. package/dist/esm/transformers/withSpanTrace.js +56 -2
  26. package/dist/esm/transformers/withSpanTrace.js.map +1 -1
  27. package/dist/esm/utils/effectDetection.js +54 -0
  28. package/dist/esm/utils/effectDetection.js.map +1 -1
  29. package/package.json +2 -5
  30. package/src/index.ts +16 -3
  31. package/src/transformers/annotateEffects.ts +197 -0
  32. package/src/transformers/sourceTrace.ts +91 -9
  33. package/src/transformers/withSpanTrace.ts +74 -2
  34. package/src/utils/effectDetection.ts +49 -1
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Pure call annotation transformer.
3
+ *
4
+ * This transformer adds `#__PURE__` comments to call expressions for better tree-shaking.
5
+ * It replicates the behavior of `babel-plugin-annotate-pure-calls` but integrated into
6
+ * the Effect unplugin.
7
+ *
8
+ * The `#__PURE__` annotation tells bundlers (Webpack, Rollup, esbuild) that a function call
9
+ * has no side effects and can be safely removed if the result is unused.
10
+ *
11
+ * @since 0.1.0
12
+ */
13
+ import type { NodePath, Visitor } from "@babel/traverse"
14
+ import * as t from "@babel/types"
15
+
16
+ /**
17
+ * Options for the annotate effects transformer.
18
+ */
19
+ export interface AnnotateEffectsOptions {
20
+ /**
21
+ * Filter function to determine if a file should be transformed.
22
+ */
23
+ readonly filter?: (filename: string) => boolean
24
+ }
25
+
26
+ /**
27
+ * State passed through the transformer.
28
+ */
29
+ interface TransformState {
30
+ filename: string
31
+ }
32
+
33
+ const PURE_ANNOTATION = "#__PURE__"
34
+
35
+ /**
36
+ * Checks if a node already has a PURE annotation.
37
+ */
38
+ function isPureAnnotated(node: t.Node): boolean {
39
+ const leadingComments = node.leadingComments
40
+ if (!leadingComments) {
41
+ return false
42
+ }
43
+ return leadingComments.some((comment) => /[@#]__PURE__/.test(comment.value))
44
+ }
45
+
46
+ /**
47
+ * Adds a PURE annotation comment to a node.
48
+ */
49
+ function annotateAsPure(path: NodePath<t.CallExpression | t.NewExpression>): void {
50
+ if (isPureAnnotated(path.node)) {
51
+ return
52
+ }
53
+ path.addComment("leading", PURE_ANNOTATION)
54
+ }
55
+
56
+ /**
57
+ * Checks if the parent is a CallExpression or NewExpression.
58
+ */
59
+ function hasCallableParent(path: NodePath): boolean {
60
+ const parentPath = path.parentPath
61
+ if (!parentPath) return false
62
+ return parentPath.isCallExpression() || parentPath.isNewExpression()
63
+ }
64
+
65
+ /**
66
+ * Checks if this node is used as a callee (e.g., `foo()` where foo is the callee).
67
+ */
68
+ function isUsedAsCallee(path: NodePath): boolean {
69
+ if (!hasCallableParent(path)) {
70
+ return false
71
+ }
72
+ const parentPath = path.parentPath as NodePath<t.CallExpression | t.NewExpression>
73
+ return parentPath.get("callee") === path
74
+ }
75
+
76
+ /**
77
+ * Checks if this node is inside a callee chain (e.g., `foo()()` or `foo.bar()`).
78
+ */
79
+ function isInCallee(path: NodePath): boolean {
80
+ let current: NodePath | null = path
81
+ do {
82
+ current = current.parentPath
83
+ if (!current) return false
84
+ if (isUsedAsCallee(current)) {
85
+ return true
86
+ }
87
+ } while (!current.isStatement() && !current.isFunction())
88
+ return false
89
+ }
90
+
91
+ /**
92
+ * Checks if this expression is executed during module initialization
93
+ * (not inside a function that isn't immediately invoked).
94
+ */
95
+ function isExecutedDuringInitialization(path: NodePath): boolean {
96
+ let functionParent = path.getFunctionParent()
97
+ while (functionParent) {
98
+ if (!isUsedAsCallee(functionParent)) {
99
+ return false
100
+ }
101
+ functionParent = functionParent.getFunctionParent()
102
+ }
103
+ return true
104
+ }
105
+
106
+ /**
107
+ * Checks if this expression is in an assignment context
108
+ * (variable declaration, assignment expression, or class).
109
+ */
110
+ function isInAssignmentContext(path: NodePath): boolean {
111
+ const statement = path.getStatementParent()
112
+ if (!statement) return false
113
+
114
+ let currentPath: NodePath | null = path
115
+ do {
116
+ currentPath = currentPath.parentPath
117
+ if (!currentPath) return false
118
+
119
+ if (
120
+ currentPath.isVariableDeclaration() ||
121
+ currentPath.isAssignmentExpression() ||
122
+ currentPath.isClass()
123
+ ) {
124
+ return true
125
+ }
126
+ } while (currentPath !== statement)
127
+
128
+ return false
129
+ }
130
+
131
+ /**
132
+ * Visitor function for CallExpression and NewExpression nodes.
133
+ */
134
+ function callableExpressionVisitor(
135
+ path: NodePath<t.CallExpression | t.NewExpression>,
136
+ _state: TransformState
137
+ ): void {
138
+ // Skip if this is used as a callee (e.g., foo()())
139
+ if (isUsedAsCallee(path)) {
140
+ return
141
+ }
142
+
143
+ // Skip if this is inside a callee chain
144
+ if (isInCallee(path)) {
145
+ return
146
+ }
147
+
148
+ // Skip if not executed during initialization
149
+ if (!isExecutedDuringInitialization(path)) {
150
+ return
151
+ }
152
+
153
+ // Must be in assignment context or export default
154
+ const statement = path.getStatementParent()
155
+ if (!isInAssignmentContext(path) && !statement?.isExportDefaultDeclaration()) {
156
+ return
157
+ }
158
+
159
+ annotateAsPure(path)
160
+ }
161
+
162
+ /**
163
+ * Creates a Babel visitor that adds PURE annotations to call expressions.
164
+ */
165
+ export function createAnnotateEffectsVisitor(
166
+ filename: string,
167
+ _options?: AnnotateEffectsOptions
168
+ ): Visitor<TransformState> {
169
+ return {
170
+ Program: {
171
+ enter(_path, state) {
172
+ state.filename = filename
173
+ }
174
+ },
175
+
176
+ CallExpression(path: NodePath<t.CallExpression>, state) {
177
+ callableExpressionVisitor(path, state)
178
+ },
179
+
180
+ NewExpression(path: NodePath<t.NewExpression>, state) {
181
+ callableExpressionVisitor(path, state)
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Creates the annotate effects transformer plugin.
188
+ */
189
+ export function annotateEffectsTransformer(options?: AnnotateEffectsOptions): {
190
+ visitor: Visitor<TransformState>
191
+ name: string
192
+ } {
193
+ return {
194
+ name: "effect-annotate-pure-calls",
195
+ visitor: createAnnotateEffectsVisitor("", options)
196
+ }
197
+ }
@@ -2,14 +2,23 @@
2
2
  * Source location trace transformer.
3
3
  *
4
4
  * This transformer injects source location metadata into Effect.gen yield expressions.
5
- * It transforms `yield* _(effect)` into `yield* _(effect, trace)` where trace is a
6
- * hoisted SourceLocation object.
5
+ * It supports both patterns:
6
+ * - Legacy adapter: `yield* _(effect)` → `yield* _(effect, trace)`
7
+ * - Modern pattern: `yield* effect` → `yield* $_adapter(effect, trace)`
8
+ *
9
+ * For the modern pattern, the transformer adds an adapter parameter to the generator
10
+ * function if not present, since Effect.gen always passes the adapter at runtime.
7
11
  *
8
12
  * @since 0.1.0
9
13
  */
10
14
  import type { NodePath, Visitor } from "@babel/traverse"
11
15
  import * as t from "@babel/types"
12
- import { isYieldAdapterCall } from "../utils/effectDetection.js"
16
+ import {
17
+ getEffectGenGenerator,
18
+ isEffectGenCall,
19
+ isModernYield,
20
+ isYieldAdapterCallWithName
21
+ } from "../utils/effectDetection.js"
13
22
  import {
14
23
  createHoistingState,
15
24
  extractLabelFromParent,
@@ -34,6 +43,30 @@ export interface SourceTraceOptions {
34
43
  interface TransformState {
35
44
  filename: string
36
45
  hoisting: HoistingState
46
+ /**
47
+ * Map from generator function to its adapter parameter name.
48
+ * Used to track which generators have been processed and their adapter names.
49
+ */
50
+ generatorAdapters: Map<unknown, string>
51
+ }
52
+
53
+ /**
54
+ * Default adapter name to use when adding one to modern pattern generators.
55
+ */
56
+ const INJECTED_ADAPTER_NAME = "$"
57
+
58
+ /**
59
+ * Gets the adapter parameter name from a generator function, or null if none.
60
+ */
61
+ function getAdapterParamName(
62
+ gen: t.FunctionExpression | t.ArrowFunctionExpression
63
+ ): string | null {
64
+ if (gen.params.length === 0) return null
65
+ const firstParam = gen.params[0]
66
+ if (t.isIdentifier(firstParam)) {
67
+ return firstParam.name
68
+ }
69
+ return null
37
70
  }
38
71
 
39
72
  /**
@@ -48,6 +81,7 @@ export function createSourceTraceVisitor(
48
81
  enter(_path, state) {
49
82
  state.filename = filename
50
83
  state.hoisting = createHoistingState()
84
+ state.generatorAdapters = new Map()
51
85
  },
52
86
  exit(path, state) {
53
87
  // Prepend all hoisted statements to the program body
@@ -57,11 +91,45 @@ export function createSourceTraceVisitor(
57
91
  }
58
92
  },
59
93
 
94
+ // Process Effect.gen calls to detect and optionally add adapter parameters
95
+ CallExpression(path: NodePath<t.CallExpression>, state) {
96
+ const node = path.node
97
+
98
+ if (!isEffectGenCall(node)) return
99
+
100
+ const generator = getEffectGenGenerator(node)
101
+ if (!generator) return
102
+
103
+ // Check if generator already has an adapter parameter
104
+ const existingAdapter = getAdapterParamName(generator)
105
+
106
+ if (existingAdapter) {
107
+ // Generator uses legacy adapter pattern - store the name
108
+ state.generatorAdapters.set(generator, existingAdapter)
109
+ } else {
110
+ // Modern pattern - add adapter parameter
111
+ generator.params.unshift(t.identifier(INJECTED_ADAPTER_NAME))
112
+ state.generatorAdapters.set(generator, INJECTED_ADAPTER_NAME)
113
+ }
114
+ },
115
+
60
116
  YieldExpression(path: NodePath<t.YieldExpression>, state) {
61
117
  const node = path.node
62
118
 
63
- // Only transform yield* _(effect) patterns
64
- if (!isYieldAdapterCall(node)) {
119
+ // Must be yield* (delegating)
120
+ if (!node.delegate || !node.argument) return
121
+
122
+ // Find the enclosing generator function
123
+ let generatorPath = path.getFunctionParent()
124
+ if (!generatorPath) return
125
+
126
+ const generatorNode = generatorPath.node as t.FunctionExpression | t.ArrowFunctionExpression
127
+ if (!("generator" in generatorNode) || !generatorNode.generator) return
128
+
129
+ // Get the adapter name for this generator
130
+ const adapterName = state.generatorAdapters.get(generatorNode)
131
+ if (!adapterName) {
132
+ // Not inside an Effect.gen - skip
65
133
  return
66
134
  }
67
135
 
@@ -69,7 +137,7 @@ export function createSourceTraceVisitor(
69
137
  const loc = node.loc
70
138
  if (!loc) return
71
139
 
72
- // Extract label from parent (e.g., `const user = yield* _(...)`)
140
+ // Extract label from parent (e.g., `const user = yield* ...`)
73
141
  const label = extractLabelFromParent(path)
74
142
 
75
143
  // Get or create hoisted trace identifier
@@ -81,9 +149,23 @@ export function createSourceTraceVisitor(
81
149
  label
82
150
  )
83
151
 
84
- // Inject trace as second argument: _(effect) -> _(effect, trace)
85
- const callExpr = node.argument as t.CallExpression
86
- callExpr.arguments.push(t.cloneNode(traceId))
152
+ // Check if this is already an adapter call
153
+ if (
154
+ t.isCallExpression(node.argument) &&
155
+ isYieldAdapterCallWithName(node, adapterName)
156
+ ) {
157
+ // Legacy pattern: _(effect) -> _(effect, trace)
158
+ const callExpr = node.argument
159
+ callExpr.arguments.push(t.cloneNode(traceId))
160
+ } else if (isModernYield(node)) {
161
+ // Modern pattern: effect -> $(effect, trace)
162
+ const originalArg = node.argument
163
+ const wrappedCall = t.callExpression(
164
+ t.identifier(adapterName),
165
+ [originalArg, t.cloneNode(traceId)]
166
+ )
167
+ node.argument = wrappedCall
168
+ }
87
169
  }
88
170
  }
89
171
  }
@@ -73,6 +73,62 @@ function isDataFirstCall(node: t.CallExpression): boolean {
73
73
  return false
74
74
  }
75
75
 
76
+ /**
77
+ * Extracts the basename from a filepath.
78
+ */
79
+ function basename(filepath: string): string {
80
+ const parts = filepath.split(/[/\\]/)
81
+ return parts[parts.length - 1] || filepath
82
+ }
83
+
84
+ /**
85
+ * Modifies a span name to include source location.
86
+ * "fetchUser" -> "fetchUser (index.ts:9)"
87
+ */
88
+ function createSpanNameWithLocation(
89
+ originalName: string,
90
+ filepath: string,
91
+ line: number
92
+ ): string {
93
+ const filename = basename(filepath)
94
+ return `${originalName} (${filename}:${line})`
95
+ }
96
+
97
+ /**
98
+ * Creates the source location suffix string.
99
+ */
100
+ function createLocationSuffix(filepath: string, line: number): string {
101
+ const filename = basename(filepath)
102
+ return ` (${filename}:${line})`
103
+ }
104
+
105
+ /**
106
+ * Modifies a template literal span name to include source location.
107
+ * Appends the location to the last quasi of the template.
108
+ */
109
+ function modifyTemplateLiteralWithLocation(
110
+ templateLiteral: t.TemplateLiteral,
111
+ filepath: string,
112
+ line: number
113
+ ): t.TemplateLiteral {
114
+ const suffix = createLocationSuffix(filepath, line)
115
+ const quasis = [...templateLiteral.quasis]
116
+ const lastQuasi = quasis[quasis.length - 1]
117
+
118
+ // Create new quasi with appended location
119
+ const newLastQuasi = t.templateElement(
120
+ {
121
+ raw: lastQuasi.value.raw + suffix,
122
+ cooked: (lastQuasi.value.cooked || lastQuasi.value.raw) + suffix
123
+ },
124
+ lastQuasi.tail
125
+ )
126
+
127
+ quasis[quasis.length - 1] = newLastQuasi
128
+
129
+ return t.templateLiteral(quasis, [...templateLiteral.expressions])
130
+ }
131
+
76
132
  /**
77
133
  * Creates the attributes object with source location.
78
134
  */
@@ -193,7 +249,15 @@ export function createWithSpanTraceVisitor(
193
249
 
194
250
  if (isDataFirst) {
195
251
  // Data-first: withSpan(effect, "name", options?)
196
- // Options is at index 2
252
+ // Name is at index 1, options at index 2
253
+ const nameArg = node.arguments[1]
254
+ if (t.isStringLiteral(nameArg)) {
255
+ const newName = createSpanNameWithLocation(nameArg.value, state.filename, line)
256
+ node.arguments[1] = t.stringLiteral(newName)
257
+ } else if (t.isTemplateLiteral(nameArg)) {
258
+ node.arguments[1] = modifyTemplateLiteralWithLocation(nameArg, state.filename, line)
259
+ }
260
+
197
261
  const optionsArg = node.arguments[2]
198
262
  const newOptions = mergeOrCreateOptions(optionsArg, sourceAttrs)
199
263
 
@@ -206,7 +270,15 @@ export function createWithSpanTraceVisitor(
206
270
  }
207
271
  } else {
208
272
  // Data-last: withSpan("name", options?)
209
- // Options is at index 1
273
+ // Name is at index 0, options at index 1
274
+ const nameArg = node.arguments[0]
275
+ if (t.isStringLiteral(nameArg)) {
276
+ const newName = createSpanNameWithLocation(nameArg.value, state.filename, line)
277
+ node.arguments[0] = t.stringLiteral(newName)
278
+ } else if (t.isTemplateLiteral(nameArg)) {
279
+ node.arguments[0] = modifyTemplateLiteralWithLocation(nameArg, state.filename, line)
280
+ }
281
+
210
282
  const optionsArg = node.arguments[1]
211
283
  const newOptions = mergeOrCreateOptions(optionsArg, sourceAttrs)
212
284
 
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @since 0.1.0
5
5
  */
6
- import type * as t from "@babel/types"
6
+ import * as t from "@babel/types"
7
7
 
8
8
  /**
9
9
  * Checks if a CallExpression is `Effect.gen(...)`.
@@ -79,3 +79,51 @@ export function isYieldAdapterCall(node: t.YieldExpression): boolean {
79
79
  if (node.argument.type !== "CallExpression") return false
80
80
  return isAdapterCall(node.argument)
81
81
  }
82
+
83
+ /**
84
+ * Checks if a YieldExpression is a `yield* _(effect)` pattern with a specific adapter name.
85
+ */
86
+ export function isYieldAdapterCallWithName(node: t.YieldExpression, adapterName: string): boolean {
87
+ if (!node.delegate) return false // Must be yield*, not yield
88
+ if (!node.argument) return false
89
+ if (node.argument.type !== "CallExpression") return false
90
+ const callee = node.argument.callee
91
+ return callee.type === "Identifier" && callee.name === adapterName
92
+ }
93
+
94
+ /**
95
+ * Checks if a YieldExpression is a modern `yield* effect` pattern (without adapter).
96
+ * This is the pattern used when Effect.gen is called without the adapter parameter.
97
+ */
98
+ export function isModernYield(node: t.YieldExpression): boolean {
99
+ if (!node.delegate) return false // Must be yield*, not yield
100
+ if (!node.argument) return false
101
+ // Modern yields are NOT adapter calls - they yield the effect directly
102
+ if (node.argument.type === "CallExpression" && isAdapterCall(node.argument)) {
103
+ return false
104
+ }
105
+ return true
106
+ }
107
+
108
+ /**
109
+ * Gets the generator function from an Effect.gen call.
110
+ * Returns the FunctionExpression/ArrowFunctionExpression if found.
111
+ */
112
+ export function getEffectGenGenerator(node: t.CallExpression): t.FunctionExpression | t.ArrowFunctionExpression | null {
113
+ // Effect.gen can be called with 1 or 2 arguments:
114
+ // Effect.gen(function*() { ... }) - 1 arg
115
+ // Effect.gen(context, function*() { ... }) - 2 args
116
+ const args = node.arguments
117
+
118
+ for (let i = args.length - 1; i >= 0; i--) {
119
+ const arg = args[i]
120
+ if (t.isFunctionExpression(arg) && arg.generator) {
121
+ return arg
122
+ }
123
+ if (t.isArrowFunctionExpression(arg) && arg.generator) {
124
+ return arg
125
+ }
126
+ }
127
+
128
+ return null
129
+ }