1package gust
2
3import (
4 "bufio"
5 "context"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "sort"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/charmbracelet/bubbles/spinner"
16 "github.com/charmbracelet/bubbles/viewport"
17 tea "github.com/charmbracelet/bubbletea"
18 "github.com/charmbracelet/lipgloss"
19 "github.com/fsnotify/fsnotify"
20)
21
22// CommandProcess represents a running command process
23type CommandProcess struct {
24 cmd *exec.Cmd
25 ctx context.Context
26 cancel context.CancelFunc
27 stdoutReader io.ReadCloser
28 stderrReader io.ReadCloser
29 stdoutChan chan stdoutMsg
30 stderrChan chan stderrMsg
31 done chan struct{}
32 waitDone sync.Once
33}
34
35// NewCommandProcess creates a new command process
36func NewCommandProcess(cmdName string, args ...string) (*CommandProcess, error) {
37 ctx, cancel := context.WithCancel(context.Background())
38 cmd := exec.CommandContext(ctx, cmdName, args...)
39
40 stdoutPipe, err := cmd.StdoutPipe()
41 if err != nil {
42 cancel()
43 return nil, err
44 }
45
46 stderrPipe, err := cmd.StderrPipe()
47 if err != nil {
48 cancel()
49 return nil, err
50 }
51
52 process := &CommandProcess{
53 cmd: cmd,
54 ctx: ctx,
55 cancel: cancel,
56 stdoutReader: stdoutPipe,
57 stderrReader: stderrPipe,
58 stdoutChan: make(chan stdoutMsg, 1024),
59 stderrChan: make(chan stderrMsg, 1024),
60 done: make(chan struct{}),
61 }
62
63 return process, nil
64}
65
66// Start starts the command and output scanners
67func (p *CommandProcess) Start() error {
68 if err := p.cmd.Start(); err != nil {
69 p.cancel()
70 return err
71 }
72
73 // Start stdout scanner
74 go func() {
75 seq := 0
76 scanner := bufio.NewScanner(p.stdoutReader)
77 for scanner.Scan() {
78 select {
79 case <-p.ctx.Done():
80 return
81 case p.stdoutChan <- stdoutMsg{line: scanner.Text() + "\n", seq: seq}:
82 seq++
83 // Line sent
84 }
85 }
86 }()
87
88 // Start stderr scanner
89 go func() {
90 seq := 0
91 scanner := bufio.NewScanner(p.stderrReader)
92 for scanner.Scan() {
93 select {
94 case <-p.ctx.Done():
95 return
96 case p.stderrChan <- stderrMsg{line: scanner.Text() + "\n", seq: seq}:
97 seq++
98 // Line sent
99 }
100 }
101 }()
102
103 // Wait for process in background
104 go func() {
105 p.waitDone.Do(func() {
106 p.cmd.Wait()
107 close(p.done)
108 })
109 }()
110
111 return nil
112}
113
114func (p *CommandProcess) Stop() {
115 if p.cmd == nil || p.cmd.Process == nil {
116 return
117 }
118
119 p.cancel()
120}
121
122// CheckStatus returns the process state if available
123func (p *CommandProcess) CheckStatus() *os.ProcessState {
124 if p == nil || p.cmd == nil {
125 return nil
126 }
127 return p.cmd.ProcessState
128}
129
130// Model represents the application state
131type Model struct {
132 stdio []IoLine
133
134 messages []CompilerMessage
135 extra []string // whatever was not parsed from the compiler output
136
137 fs Fs
138 w *fsnotify.Watcher
139 config Config
140
141 status Status
142 systemErrors []error
143
144 process *CommandProcess
145 start *time.Time
146 end *time.Time
147
148 viewport viewport.Model
149 ready bool
150 width, height int
151}
152
153type Io []IoLine
154type IoLine struct {
155 Kind IoKind
156 Line string
157 Seq int
158}
159type IoKind int
160
161const (
162 Stdout IoKind = iota
163 Stderr
164)
165
166func (m Model) stdfilter(filter func(kind IoKind) bool) string {
167 var lines []stderrMsg
168
169 for _, l := range m.stdio {
170 if filter(l.Kind) {
171 lines = append(lines, stderrMsg{
172 line: l.Line,
173 seq: l.Seq,
174 })
175 }
176 }
177
178 sort.Slice(lines, func(i, j int) bool {
179 return lines[i].seq < lines[j].seq
180 })
181
182 var b strings.Builder
183
184 for _, l := range lines {
185 b.WriteString(l.line)
186 }
187
188 return b.String()
189}
190
191func (m Model) stderr() string {
192 return m.stdfilter(func(kind IoKind) bool {
193 return kind == Stderr
194 })
195}
196
197func (m Model) stdout() string {
198 return m.stdfilter(func(kind IoKind) bool {
199 return kind == Stdout
200 })
201}
202
203func NewModel(watcher *fsnotify.Watcher, config Config, options ...func(*Config)) Model {
204 for _, o := range options {
205 o(&config)
206 }
207
208 return Model{
209 w: watcher,
210 status: NewStatus(),
211 config: config,
212 }
213}
214
215func (m Model) Init() tea.Cmd {
216 return tea.Batch(reload, m.status.Init())
217}
218
219func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
220 var cmd tea.Cmd
221
222 switch msg := msg.(type) {
223 case tea.WindowSizeMsg:
224 headerHeight := lipgloss.Height(m.viewStatus())
225 footerHeight := lipgloss.Height(m.viewHelp())
226 verticalMarginHeight := headerHeight + footerHeight
227
228 m.width, m.height = msg.Width, msg.Height-verticalMarginHeight
229 if !m.ready {
230 m.viewport = viewport.New(m.width, m.height)
231 m.viewport.YPosition = headerHeight
232 m.ready = true
233 } else {
234 m.viewport.Width, m.viewport.Height = m.width, m.height
235 }
236
237 return m, updateViewport
238 case tea.KeyMsg:
239 switch msg.String() {
240 case "q", "ctrl+c":
241 if m.process != nil {
242 m.process.Stop()
243 }
244 return m, tea.Quit
245 case "r":
246 var cmd tea.Cmd
247 if m.config.Mode != "run" {
248 m.config.Mode = "run"
249 cmd = reload
250 }
251 return m, cmd
252 case "b":
253 var cmd tea.Cmd
254 if m.config.Mode != "build" {
255 m.config.Mode = "build"
256 cmd = reload
257 }
258 return m, cmd
259 case "s":
260 m.config.Summarized = !m.config.Summarized
261 return m, nil
262 case "o":
263 if m.config.Stream == "stdout" {
264 m.config.Stream = "stderr"
265 } else {
266 m.config.Stream = "stdout"
267 }
268 return m, nil
269 case "h", "?":
270 m.config.Help = !m.config.Help
271 return m, nil
272 default:
273 m.viewport, cmd = m.viewport.Update(msg)
274 return m, cmd
275 }
276 case StatusKind, spinner.TickMsg:
277 var cmd tea.Cmd
278 m.status, cmd = m.status.Update(msg)
279 return m, tea.Batch(cmd, m.listen())
280 case modifiedMsg, reloadMsg:
281 m, cmd := m.reload()
282 return m, cmd
283 case stdoutMsg:
284 m.stdio = append(m.stdio, IoLine{
285 Kind: Stdout,
286 Line: msg.line,
287 Seq: msg.seq,
288 })
289 return m, m.checkStdout()
290 case stderrMsg:
291 m.stdio = append(m.stdio, IoLine{
292 Kind: Stderr,
293 Line: msg.line,
294 Seq: msg.seq,
295 })
296 messages, extra := Parse(m.stderr())
297 fs := Fs{}
298 if !m.config.Summarized {
299 fs = BuildFs(messages)
300 fs.PopulateContext(messages, m.config.Context)
301 }
302 m.messages = messages
303 m.extra = extra
304 m.fs = fs
305 return m, m.checkStderr()
306 case processState:
307 end := time.Now()
308 if msg.state == nil {
309 return m, tea.Batch(m.checkCmdStatus(), m.checkStderr(), m.checkStdout(), updateViewport)
310 } else if msg.state.Success() {
311 m.end = &end
312 return m, Success.Cmd()
313 } else if msg.state.Exited() {
314 m.end = &end
315 return m, Error.Cmd()
316 } else {
317 return m, tea.Batch(m.checkCmdStatus(), m.checkStderr(), m.checkStdout(), updateViewport)
318 }
319 case updateViewportMsg:
320 m.viewport.SetContent(m.viewBody())
321 if m.config.Mode == "build" {
322 m.viewport.GotoTop()
323 } else {
324 m.viewport.GotoBottom()
325 }
326 return m, nil
327 default:
328 return m, nil
329 }
330}
331
332type reloadMsg struct{}
333
334func reload() tea.Msg {
335 return reloadMsg{}
336}
337
338type updateViewportMsg struct{}
339
340func updateViewport() tea.Msg {
341 return updateViewportMsg{}
342}
343
344type modifiedMsg struct{}
345type watcherrMsg struct{}
346
347func (m Model) listen() tea.Cmd {
348 return func() tea.Msg {
349 select {
350 case event, ok := <-m.w.Events:
351 if !ok {
352 return watcherrMsg{}
353 }
354
355 if event.Has(fsnotify.Write|fsnotify.Create|fsnotify.Remove) &&
356 filepath.Ext(event.Name) == ".go" {
357 return modifiedMsg{}
358 }
359
360 return nil
361
362 case _, ok := <-m.w.Errors:
363 if !ok {
364 return watcherrMsg{}
365 }
366 return watcherrMsg{}
367 default:
368 return nil
369 }
370 }
371}
372
373func (m Model) reload() (Model, tea.Cmd) {
374 status := Running.Cmd()
375 m.systemErrors = []error{}
376
377 // Clean up old process
378 if m.process != nil {
379 m.process.Stop()
380 m.process = nil
381 }
382
383 // Reset state
384 m.stdio = []IoLine{}
385 m.messages = nil
386 m.extra = nil
387 m.fs = nil
388
389 // Create new process
390 allArgs := []string{}
391 allArgs = append(allArgs, m.config.Mode)
392 allArgs = append(allArgs, m.config.Package)
393 if m.config.Mode == "run" {
394 allArgs = append(allArgs, m.config.Args...)
395 }
396 process, err := NewCommandProcess("go", allArgs...)
397
398 if err != nil {
399 m.systemErrors = append(m.systemErrors, err)
400 return m, Error.Cmd()
401 }
402 m.process = process
403
404 // Record start time
405 start := time.Now()
406 m.start = &start
407 m.end = nil
408
409 // Start the process
410 if err := m.process.Start(); err != nil {
411 m.systemErrors = append(m.systemErrors, err)
412 return m, Error.Cmd()
413 }
414
415 return m, tea.Batch(status, m.checkCmdStatus(), m.checkStdout(), m.checkStderr(), updateViewport)
416}
417
418type stdoutMsg struct {
419 line string
420 seq int
421}
422
423type stderrMsg struct {
424 line string
425 seq int
426}
427
428func (m Model) checkStdout() tea.Cmd {
429 if m.process == nil {
430 return nil
431 }
432
433 return func() tea.Msg {
434 select {
435 case msg, ok := <-m.process.stdoutChan:
436 if !ok {
437 return nil
438 }
439 return msg
440 case <-time.After(10 * time.Millisecond):
441 return nil
442 }
443 }
444}
445
446func (m Model) checkStderr() tea.Cmd {
447 if m.process == nil {
448 return nil
449 }
450
451 return func() tea.Msg {
452 select {
453 case msg, ok := <-m.process.stderrChan:
454 if !ok {
455 return nil
456 }
457 return msg
458 case <-time.After(10 * time.Millisecond):
459 return nil
460 }
461 }
462}
463
464type processState struct {
465 state *os.ProcessState
466}
467
468func (m Model) checkCmdStatus() tea.Cmd {
469 if m.process == nil {
470 return nil
471 }
472
473 return func() tea.Msg {
474 return processState{m.process.CheckStatus()}
475 }
476}