background code checker for golang
at foobar 9.1 kB view raw
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}