@clayroach/unplugin 0.1.0-source-trace.2 → 0.1.0-source-trace.4

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.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * WithSpan source location trace transformer.
3
+ *
4
+ * This transformer injects source location metadata as attributes into Effect.withSpan() calls.
5
+ * It transforms `Effect.withSpan("name")` into `Effect.withSpan("name", { attributes: { "code.filepath": ..., "code.lineno": ... }})`.
6
+ *
7
+ * @since 0.1.0
8
+ */
9
+ import type { NodePath, Visitor } from "@babel/traverse"
10
+ import * as t from "@babel/types"
11
+ import {
12
+ createHoistingState,
13
+ type HoistingState
14
+ } from "../utils/hoisting.js"
15
+
16
+ /**
17
+ * Options for the withSpan trace transformer.
18
+ */
19
+ export interface WithSpanTraceOptions {
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
+ hoisting: HoistingState
32
+ }
33
+
34
+ /**
35
+ * Checks if a CallExpression is a withSpan call.
36
+ * Matches: Effect.withSpan(...), _.withSpan(...), or standalone withSpan(...)
37
+ */
38
+ function isWithSpanCall(node: t.CallExpression): boolean {
39
+ const callee = node.callee
40
+
41
+ // Match Effect.withSpan(...) or _.withSpan(...)
42
+ if (
43
+ callee.type === "MemberExpression" &&
44
+ callee.property.type === "Identifier" &&
45
+ callee.property.name === "withSpan"
46
+ ) {
47
+ return true
48
+ }
49
+
50
+ // Match standalone withSpan(...)
51
+ if (callee.type === "Identifier" && callee.name === "withSpan") {
52
+ return true
53
+ }
54
+
55
+ return false
56
+ }
57
+
58
+ /**
59
+ * Determines if this is a data-first call (effect as first arg) or data-last (name as first arg).
60
+ * Data-first: withSpan(effect, "name", options?)
61
+ * Data-last: withSpan("name", options?)
62
+ */
63
+ function isDataFirstCall(node: t.CallExpression): boolean {
64
+ // If first argument is a string literal, it's data-last
65
+ if (node.arguments.length > 0 && t.isStringLiteral(node.arguments[0])) {
66
+ return false
67
+ }
68
+ // If second argument exists and is a string literal, it's data-first
69
+ if (node.arguments.length > 1 && t.isStringLiteral(node.arguments[1])) {
70
+ return true
71
+ }
72
+ // Default to data-last pattern
73
+ return false
74
+ }
75
+
76
+ /**
77
+ * Creates the attributes object with source location.
78
+ */
79
+ function createSourceAttributes(
80
+ filepath: string,
81
+ line: number,
82
+ column: number
83
+ ): t.ObjectExpression {
84
+ return t.objectExpression([
85
+ t.objectProperty(
86
+ t.stringLiteral("code.filepath"),
87
+ t.stringLiteral(filepath)
88
+ ),
89
+ t.objectProperty(
90
+ t.stringLiteral("code.lineno"),
91
+ t.numericLiteral(line)
92
+ ),
93
+ t.objectProperty(
94
+ t.stringLiteral("code.column"),
95
+ t.numericLiteral(column)
96
+ )
97
+ ])
98
+ }
99
+
100
+ /**
101
+ * Merges source attributes into an existing options object or creates a new one.
102
+ */
103
+ function mergeOrCreateOptions(
104
+ existingOptions: t.Expression | t.SpreadElement | t.ArgumentPlaceholder | undefined,
105
+ sourceAttrs: t.ObjectExpression
106
+ ): t.ObjectExpression {
107
+ if (!existingOptions || t.isSpreadElement(existingOptions) || t.isArgumentPlaceholder(existingOptions)) {
108
+ // No existing options, create new object with attributes
109
+ return t.objectExpression([
110
+ t.objectProperty(t.identifier("attributes"), sourceAttrs)
111
+ ])
112
+ }
113
+
114
+ if (t.isObjectExpression(existingOptions)) {
115
+ // Check if there's an existing attributes property
116
+ const existingAttrsIndex = existingOptions.properties.findIndex(
117
+ (prop) =>
118
+ t.isObjectProperty(prop) &&
119
+ ((t.isIdentifier(prop.key) && prop.key.name === "attributes") ||
120
+ (t.isStringLiteral(prop.key) && prop.key.value === "attributes"))
121
+ )
122
+
123
+ if (existingAttrsIndex >= 0) {
124
+ // Merge with existing attributes using spread
125
+ const existingAttrsProp = existingOptions.properties[existingAttrsIndex] as t.ObjectProperty
126
+ const mergedAttrs = t.objectExpression([
127
+ t.spreadElement(existingAttrsProp.value as t.Expression),
128
+ ...sourceAttrs.properties
129
+ ])
130
+
131
+ // Clone the options and replace the attributes property
132
+ const newProperties = [...existingOptions.properties]
133
+ newProperties[existingAttrsIndex] = t.objectProperty(
134
+ t.identifier("attributes"),
135
+ mergedAttrs
136
+ )
137
+ return t.objectExpression(newProperties)
138
+ } else {
139
+ // Add new attributes property to existing object
140
+ return t.objectExpression([
141
+ ...existingOptions.properties,
142
+ t.objectProperty(t.identifier("attributes"), sourceAttrs)
143
+ ])
144
+ }
145
+ }
146
+
147
+ // If it's a variable reference, spread it and add attributes
148
+ return t.objectExpression([
149
+ t.spreadElement(existingOptions),
150
+ t.objectProperty(t.identifier("attributes"), sourceAttrs)
151
+ ])
152
+ }
153
+
154
+ /**
155
+ * Creates a Babel visitor that injects source location attributes into Effect.withSpan calls.
156
+ */
157
+ export function createWithSpanTraceVisitor(
158
+ filename: string,
159
+ _options?: WithSpanTraceOptions
160
+ ): Visitor<TransformState> {
161
+ return {
162
+ Program: {
163
+ enter(_path, state) {
164
+ state.filename = filename
165
+ state.hoisting = createHoistingState()
166
+ },
167
+ exit(path, state) {
168
+ // Prepend all hoisted statements to the program body
169
+ if (state.hoisting.statements.length > 0) {
170
+ path.unshiftContainer("body", state.hoisting.statements)
171
+ }
172
+ }
173
+ },
174
+
175
+ CallExpression(path: NodePath<t.CallExpression>, state) {
176
+ const node = path.node
177
+
178
+ if (!isWithSpanCall(node)) {
179
+ return
180
+ }
181
+
182
+ // Get source location
183
+ const loc = node.loc
184
+ if (!loc) return
185
+
186
+ const line = loc.start.line
187
+ const column = loc.start.column
188
+
189
+ // Create source attributes
190
+ const sourceAttrs = createSourceAttributes(state.filename, line, column)
191
+
192
+ const isDataFirst = isDataFirstCall(node)
193
+
194
+ if (isDataFirst) {
195
+ // Data-first: withSpan(effect, "name", options?)
196
+ // Options is at index 2
197
+ const optionsArg = node.arguments[2]
198
+ const newOptions = mergeOrCreateOptions(optionsArg, sourceAttrs)
199
+
200
+ if (node.arguments.length >= 3) {
201
+ // Replace existing options
202
+ node.arguments[2] = newOptions
203
+ } else {
204
+ // Add options as third argument
205
+ node.arguments.push(newOptions)
206
+ }
207
+ } else {
208
+ // Data-last: withSpan("name", options?)
209
+ // Options is at index 1
210
+ const optionsArg = node.arguments[1]
211
+ const newOptions = mergeOrCreateOptions(optionsArg, sourceAttrs)
212
+
213
+ if (node.arguments.length >= 2) {
214
+ // Replace existing options
215
+ node.arguments[1] = newOptions
216
+ } else {
217
+ // Add options as second argument
218
+ node.arguments.push(newOptions)
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Creates the withSpan trace transformer plugin.
227
+ */
228
+ export function withSpanTraceTransformer(options?: WithSpanTraceOptions): {
229
+ visitor: Visitor<TransformState>
230
+ name: string
231
+ } {
232
+ return {
233
+ name: "effect-withspan-trace",
234
+ visitor: createWithSpanTraceVisitor("", options)
235
+ }
236
+ }