gitstack

grindlemire/go-tui code browser

13.0 KB Go 417 lines 2026-06-12 ยท f0bb13a raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
package tuigen

import (
	"regexp"
	"strconv"
	"strings"
)

// DetectEventsVars scans a component for tui.NewEvents declarations.
// It returns a list of all detected events variables.
func (a *Analyzer) DetectEventsVars(comp *Component) []EventsVar {
	alias := a.getTUIAlias()
	var eventsNewEventsRegex *regexp.Regexp
	if alias == "." {
		eventsNewEventsRegex = regexp.MustCompile(`(\w+)\s*:=\s*NewEvents(?:\[.+\])?\([^)]*\)`)
	} else {
		eventsNewEventsRegex = regexp.MustCompile(`(\w+)\s*:=\s*` + regexp.QuoteMeta(alias) + `\.NewEvents(?:\[.+\])?\([^)]*\)`)
	}

	var eventsVars []EventsVar
	for _, node := range comp.Body {
		if goCode, ok := node.(*GoCode); ok {
			matches := eventsNewEventsRegex.FindAllStringSubmatch(goCode.Code, -1)
			for _, match := range matches {
				if len(match) >= 2 {
					eventsVars = append(eventsVars, EventsVar{
						Name:     match[1],
						Position: goCode.Position,
					})
				}
			}
		}
	}
	return eventsVars
}

// DetectStateVars scans a component for tui.NewState declarations and state parameters.
// It returns a list of all detected state variables.
func (a *Analyzer) DetectStateVars(comp *Component) []StateVar {
	alias := a.getTUIAlias()
	var stateParamRegex *regexp.Regexp
	if alias == "." {
		stateParamRegex = regexp.MustCompile(`\*?State\[(.+)\]$`)
	} else {
		stateParamRegex = regexp.MustCompile(`\*?` + regexp.QuoteMeta(alias) + `\.State\[(.+)\]$`)
	}

	var stateNewStateRegex *regexp.Regexp
	if alias == "." {
		stateNewStateRegex = regexp.MustCompile(`(\w+)\s*:=\s*NewState\((.+)\)`)
	} else {
		stateNewStateRegex = regexp.MustCompile(`(\w+)\s*:=\s*` + regexp.QuoteMeta(alias) + `\.NewState\((.+)\)`)
	}

	var stateVars []StateVar

	// First, detect state parameters in component signature
	for _, param := range comp.Params {
		matches := stateParamRegex.FindStringSubmatch(param.Type)
		if matches != nil {
			stateVars = append(stateVars, StateVar{
				Name:        param.Name,
				Type:        matches[1], // Type inside State[T]
				IsParameter: true,
				Position:    param.Position,
			})
		}
	}

	// Then, scan component body for tui.NewState declarations
	// We need to look for GoCode nodes that contain state declarations
	for _, node := range comp.Body {
		if goCode, ok := node.(*GoCode); ok {
			stateVars = append(stateVars, a.parseStateDeclarations(goCode, stateNewStateRegex)...)
		}
	}

	return stateVars
}

// parseStateDeclarations extracts tui.NewState declarations from Go code using the pre-compiled regex.
func (a *Analyzer) parseStateDeclarations(code *GoCode, stateNewStateRegex *regexp.Regexp) []StateVar {
	var stateVars []StateVar

	// Find all matches in the code
	matches := stateNewStateRegex.FindAllStringSubmatch(code.Code, -1)
	for _, match := range matches {
		if len(match) >= 3 {
			varName := match[1]
			initExpr := match[2]
			stateType := inferTypeFromExpr(initExpr)

			stateVars = append(stateVars, StateVar{
				Name:     varName,
				Type:     stateType,
				InitExpr: initExpr,
				Position: code.Position,
			})
		}
	}

	return stateVars
}

// inferTypeFromExpr attempts to infer the Go type from an expression.
// This uses heuristics for common patterns.
func inferTypeFromExpr(expr string) string {
	expr = strings.TrimSpace(expr)

	// Integer literal (positive or negative)
	if regexp.MustCompile(`^-?\d+$`).MatchString(expr) {
		return "int"
	}

	// Float literal (positive or negative)
	if regexp.MustCompile(`^-?\d+\.\d+$`).MatchString(expr) {
		return "float64"
	}

	// Boolean literal
	if expr == "true" || expr == "false" {
		return "bool"
	}

	// String literal
	if (strings.HasPrefix(expr, `"`) && strings.HasSuffix(expr, `"`)) ||
		(strings.HasPrefix(expr, "`") && strings.HasSuffix(expr, "`")) {
		return "string"
	}

	// Nil pointer
	if expr == "nil" {
		return "any" // Can't infer specific type from nil
	}

	// Slice literal []Type{...}
	if sliceMatch := regexp.MustCompile(`^\[\](\w+(?:\.\w+)?)\{`).FindStringSubmatch(expr); sliceMatch != nil {
		return "[]" + sliceMatch[1]
	}

	// Map literal map[K]V{...}
	if mapMatch := regexp.MustCompile(`^map\[(\w+)\](\w+(?:\.\w+)?)\{`).FindStringSubmatch(expr); mapMatch != nil {
		return "map[" + mapMatch[1] + "]" + mapMatch[2]
	}

	// Pointer to struct &Type{...}
	if ptrMatch := regexp.MustCompile(`^&(\w+(?:\.\w+)?)\{`).FindStringSubmatch(expr); ptrMatch != nil {
		return "*" + ptrMatch[1]
	}

	// Struct literal Type{...}
	if structMatch := regexp.MustCompile(`^(\w+(?:\.\w+)?)\{`).FindStringSubmatch(expr); structMatch != nil {
		return structMatch[1]
	}

	// Default to any if we can't infer
	return "any"
}

// DetectStateBindings scans elements for state usage and returns bindings.
// This detects both explicit deps={...} attributes and auto-detected .Get() calls.
//
// The elementIndex counter assigns names like "__tui_0", "__tui_1", etc. to unnamed
// elements. This must match the generator's naming scheme in generator.go. Named
// refs use their ref name instead.
//
// Note: Elements inside for loops are skipped for binding generation because their
// generated variable names are scoped to the loop and cannot be referenced from outside.
func (a *Analyzer) DetectStateBindings(comp *Component, stateVars []StateVar) []StateBinding {
	// Build a set of state variable names for quick lookup
	stateNames := make(map[string]bool)
	for _, sv := range stateVars {
		stateNames[sv.Name] = true
	}

	var bindings []StateBinding
	elementIndex := 0

	var scan func(nodes []Node, inLoop bool)
	scan = func(nodes []Node, inLoop bool) {
		for _, node := range nodes {
			switch n := node.(type) {
			case *Element:
				// Check for explicit deps attribute first
				var explicitDeps []string
				for _, attr := range n.Attributes {
					if attr.Name == "deps" {
						explicitDeps = a.parseExplicitDeps(attr, stateNames)
						break
					}
				}

				// Generate element name (same pattern as generator)
				// All elements use auto-generated variable names now (__tui_N).
				// Refs are user-declared variables, bound after element creation.
				elementName := "__tui_" + strconv.Itoa(elementIndex)
				usesCounter := true

				// Skip creating bindings for elements inside for loops - their variable
				// names are scoped to the loop and can't be referenced from the binding code
				if !inLoop {
					// Check text content in children for state usage.
					// We need to calculate the correct child element index for the binding.
					// Exception: span/p with single text/expr child puts it in WithText, not as child element.
					skipChildElements := (n.Tag == "span" || n.Tag == "p") && len(n.Children) == 1

					// Calculate starting index for child elements.
					// If parent uses counter, children start at elementIndex + 1.
					// If parent is a named ref, children start at elementIndex (counter wasn't incremented for parent).
					childElementIndex := elementIndex
					if usesCounter {
						childElementIndex++ // Parent element uses one slot
					}

					for _, child := range n.Children {
						if goExpr, ok := child.(*GoExpr); ok {
							var deps []string
							isExplicit := false

							if explicitDeps != nil {
								deps = explicitDeps
								isExplicit = true
							} else {
								deps = a.detectGetCalls(goExpr.Code, stateNames)
							}

							if len(deps) > 0 {
								// Determine which element the binding should target.
								// If children become separate elements, use the child's index.
								// If the parent element has the text (single child span/p), use parent.
								bindingElementName := elementName
								if !skipChildElements {
									bindingElementName = "__tui_" + strconv.Itoa(childElementIndex)
								}

								bindings = append(bindings, StateBinding{
									StateVars:    deps,
									Element:      n,
									ElementName:  bindingElementName,
									Attribute:    "text",
									Expr:         goExpr.Code,
									ExplicitDeps: isExplicit,
								})
							}
						}

						// Increment child element index for each child that creates an element
						if !skipChildElements {
							switch child.(type) {
							case *GoExpr, *TextContent:
								childElementIndex++
							}
						}
					}

					// Check for dynamic class attribute
					for _, attr := range n.Attributes {
						if attr.Name == "class" {
							if goExpr, ok := attr.Value.(*GoExpr); ok {
								var deps []string
								isExplicit := false

								if explicitDeps != nil {
									deps = explicitDeps
									isExplicit = true
								} else {
									deps = a.detectGetCalls(goExpr.Code, stateNames)
								}

								if len(deps) > 0 {
									bindings = append(bindings, StateBinding{
										StateVars:    deps,
										Element:      n,
										ElementName:  elementName,
										Attribute:    "class",
										Expr:         goExpr.Code,
										ExplicitDeps: isExplicit,
									})
								}
							}
						}
					}
				}

				// Only increment counter if the element uses it (matches generator behavior)
				if usesCounter {
					elementIndex++
				}

				// Count GoExpr and TextContent children that become separate text elements.
				// This matches the generator's behavior where it creates new elements for these.
				// Exception: span/p elements with a single text child put it in WithText, not as child element.
				skipChildren := (n.Tag == "span" || n.Tag == "p") && len(n.Children) == 1
				if !skipChildren {
					for _, child := range n.Children {
						switch child.(type) {
						case *GoExpr, *TextContent:
							elementIndex++
						}
					}
				}

				scan(n.Children, inLoop)

			case *LetBinding:
				// LetBindings wrap elements; recursively scan the wrapped element's children.
				// The wrapped element itself is handled when we encounter the Element node.
				// The RHS can also be a component call or Go expression with no element.
				if n.Element != nil {
					scan(n.Element.Children, inLoop)
				} else if n.Call != nil {
					scan(n.Call.Children, inLoop)
				}

			case *ForLoop:
				// Elements inside for loops have loop-scoped variable names
				scan(n.Body, true)

			case *IfStmt:
				// Check if condition references state - if so, elements inside
				// are rebuilt by the reactive update function and don't need
				// separate text bindings (similar to loop-scoped elements).
				condDeps := detectGetCallsInExpr(n.Condition, stateNames)
				childInLoop := inLoop || len(condDeps) > 0
				scan(n.Then, childInLoop)
				scan(n.Else, childInLoop)

			case *ComponentCall:
				scan(n.Children, inLoop)
			}
		}
	}

	scan(comp.Body, false)
	return bindings
}

// parseExplicitDeps extracts state variable names from a deps={[state1, state2]} attribute.
// It validates that each name exists in the known state variables.
// Returns nil if the attribute is not a valid deps specification.
func (a *Analyzer) parseExplicitDeps(attr *Attribute, stateNames map[string]bool) []string {
	goExpr, ok := attr.Value.(*GoExpr)
	if !ok {
		// deps attribute must use Go expression syntax: deps={[state1, state2]}
		// String literals like deps="..." are not valid
		a.errors.AddErrorf(attr.Position, "deps attribute must use expression syntax: deps={[state1, state2]}")
		return nil
	}

	code := strings.TrimSpace(goExpr.Code)

	// Parse [state1, state2] format - must have brackets
	if !strings.HasPrefix(code, "[") || !strings.HasSuffix(code, "]") {
		a.errors.AddErrorf(attr.Position, "deps attribute must be an array literal: deps={[state1, state2]}")
		return nil
	}

	// Extract the contents between [ and ]
	inner := strings.TrimSpace(code[1 : len(code)-1])
	if inner == "" {
		// Empty deps like deps={[]} - warn but don't treat as error
		a.errors.AddErrorf(attr.Position, "empty deps attribute has no effect")
		return nil
	}

	// Split by comma and validate each name
	var deps []string
	for part := range strings.SplitSeq(inner, ",") {
		name := strings.TrimSpace(part)
		if name == "" {
			continue
		}
		if !stateNames[name] {
			a.errors.AddErrorf(attr.Position, "unknown state variable %q in deps", name)
			continue
		}
		deps = append(deps, name)
	}

	return deps
}

// detectGetCalls finds all state.Get() calls in an expression and returns the state variable names.
// It handles both simple calls (count.Get()) and dereferenced pointers ((*count).Get()).
func (a *Analyzer) detectGetCalls(expr string, stateNames map[string]bool) []string {
	return detectGetCallsInExpr(expr, stateNames)
}

// detectGetCallsInExpr finds all state.Get() calls in an expression and returns the state variable names.
// This is a standalone function usable by both the Analyzer and Generator.
func detectGetCallsInExpr(expr string, stateNames map[string]bool) []string {
	matches := stateGetRegex.FindAllStringSubmatch(expr, -1)

	// Use a map to deduplicate
	seen := make(map[string]bool)
	var deps []string

	for _, match := range matches {
		// The regex has two capture groups:
		// - match[1] captures from (*name) pattern
		// - match[2] captures from simple name pattern
		// Only one will be non-empty for each match
		var name string
		if match[1] != "" {
			name = match[1] // (*name).Get()
		} else if len(match) > 2 && match[2] != "" {
			name = match[2] // name.Get()
		}

		if name != "" && stateNames[name] && !seen[name] {
			seen[name] = true
			deps = append(deps, name)
		}
	}

	return deps
}