background code checker for golang

model: rework command handling to stream stdout and stderr

+293 -54
+4
config.go
··· 29 29 Separator Style `toml:"separator"` 30 30 File Style `toml:"file"` 31 31 Line Style `toml:"line"` 32 + Stdout Style `toml:"stdout"` 33 + Stderr Style `toml:"stderr"` 32 34 Context ContextStyles `toml:"context"` 33 35 } 34 36 ··· 224 226 Duration: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("7"))}, 225 227 Separator: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("8"))}, 226 228 Line: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("7"))}, 229 + Stdout: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("3"))}, 230 + Stderr: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("1"))}, 227 231 Context: ContextStyles{ 228 232 ActiveLine: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("12"))}, 229 233 ActiveLineNr: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("1"))},
+220 -35
model.go
··· 1 1 package gust 2 2 3 3 import ( 4 + "bufio" 5 + "context" 4 6 "io" 7 + "os" 5 8 "os/exec" 6 9 "path/filepath" 10 + "sync" 7 11 "time" 8 12 9 13 "github.com/charmbracelet/bubbles/spinner" ··· 11 15 "github.com/fsnotify/fsnotify" 12 16 ) 13 17 18 + // CommandProcess represents a running command process 19 + type CommandProcess struct { 20 + cmd *exec.Cmd 21 + ctx context.Context 22 + cancel context.CancelFunc 23 + stdoutReader io.ReadCloser 24 + stderrReader io.ReadCloser 25 + stdoutChan chan string 26 + stderrChan chan string 27 + done chan struct{} 28 + waitDone sync.Once 29 + } 30 + 31 + // NewCommandProcess creates a new command process 32 + func NewCommandProcess(cmdName string, args ...string) (*CommandProcess, error) { 33 + ctx, cancel := context.WithCancel(context.Background()) 34 + cmd := exec.CommandContext(ctx, cmdName, args...) 35 + 36 + stdoutPipe, err := cmd.StdoutPipe() 37 + if err != nil { 38 + cancel() 39 + return nil, err 40 + } 41 + 42 + stderrPipe, err := cmd.StderrPipe() 43 + if err != nil { 44 + cancel() 45 + return nil, err 46 + } 47 + 48 + process := &CommandProcess{ 49 + cmd: cmd, 50 + ctx: ctx, 51 + cancel: cancel, 52 + stdoutReader: stdoutPipe, 53 + stderrReader: stderrPipe, 54 + stdoutChan: make(chan string, 100), 55 + stderrChan: make(chan string, 100), 56 + done: make(chan struct{}), 57 + } 58 + 59 + return process, nil 60 + } 61 + 62 + // Start starts the command and output scanners 63 + func (p *CommandProcess) Start() error { 64 + if err := p.cmd.Start(); err != nil { 65 + p.cancel() 66 + return err 67 + } 68 + 69 + // Start stdout scanner 70 + go func() { 71 + scanner := bufio.NewScanner(p.stdoutReader) 72 + for scanner.Scan() { 73 + select { 74 + case <-p.ctx.Done(): 75 + return 76 + case p.stdoutChan <- scanner.Text() + "\n": 77 + // Line sent 78 + } 79 + } 80 + }() 81 + 82 + // Start stderr scanner 83 + go func() { 84 + scanner := bufio.NewScanner(p.stderrReader) 85 + for scanner.Scan() { 86 + select { 87 + case <-p.ctx.Done(): 88 + return 89 + case p.stderrChan <- scanner.Text() + "\n": 90 + // Line sent 91 + } 92 + } 93 + }() 94 + 95 + // Wait for process in background 96 + go func() { 97 + p.waitDone.Do(func() { 98 + p.cmd.Wait() 99 + close(p.done) 100 + }) 101 + }() 102 + 103 + return nil 104 + } 105 + 106 + // Stop stops the command and cleans up resources 107 + func (p *CommandProcess) Stop() { 108 + p.cancel() 109 + if p.cmd != nil && p.cmd.Process != nil { 110 + p.cmd.Process.Kill() 111 + } 112 + } 113 + 114 + // CheckStatus returns the process state if available 115 + func (p *CommandProcess) CheckStatus() *os.ProcessState { 116 + if p == nil || p.cmd == nil { 117 + return nil 118 + } 119 + return p.cmd.ProcessState 120 + } 121 + 14 122 // Model represents the application state 15 123 type Model struct { 16 124 stderr string ··· 23 131 context int 24 132 25 133 status Status 26 - duration time.Duration 27 134 systemErrors []error 28 135 136 + process *CommandProcess 137 + start *time.Time 138 + end *time.Time 139 + 29 140 width, height int 30 141 } 31 142 ··· 54 165 case tea.KeyMsg: 55 166 switch msg.String() { 56 167 case "q", "ctrl+c": 168 + if m.process != nil { 169 + m.process.Stop() 170 + } 57 171 return m, tea.Quit 58 172 case "r": 59 173 var cmd tea.Cmd ··· 84 198 return m, tea.Batch(cmd, m.listen()) 85 199 case modifiedMsg, reloadMsg: 86 200 m, cmd := m.reload() 87 - return m, tea.Batch(cmd, m.listen()) 201 + return m, cmd 202 + case stdoutMsg: 203 + m.stdout = m.stdout + msg.line 204 + return m, m.checkStdout() 205 + case stderrMsg: 206 + m.stderr = m.stderr + msg.line 207 + if m.config.Mode == "build" { 208 + messages := Parse(m.stderr) 209 + fs := Fs{} 210 + if !m.config.Summarized { 211 + fs = BuildFs(messages) 212 + fs.PopulateContext(messages, m.context) 213 + } 214 + m.messages = messages 215 + m.fs = fs 216 + } 217 + return m, m.checkStderr() 218 + case processState: 219 + end := time.Now() 220 + if msg.state == nil { 221 + return m, tea.Batch(m.checkCmdStatus(), m.checkStderr(), m.checkStdout()) 222 + } else if msg.state.Success() { 223 + m.end = &end 224 + return m, Success.Cmd() 225 + } else if msg.state.Exited() { 226 + m.end = &end 227 + return m, Error.Cmd() 228 + } else { 229 + return m, tea.Batch(m.checkCmdStatus(), m.checkStderr(), m.checkStdout()) 230 + } 88 231 default: 89 232 return m, nil 90 233 } ··· 119 262 return watcherrMsg{} 120 263 } 121 264 return watcherrMsg{} 265 + default: 266 + return nil 122 267 } 123 268 } 124 269 } 125 270 126 271 func (m Model) reload() (Model, tea.Cmd) { 127 - cmd := Success.Cmd() 272 + status := Running.Cmd() 128 273 m.systemErrors = []error{} 129 274 130 - command := exec.Command("go", m.config.Mode, m.config.Package) 131 - stderr, err := command.StderrPipe() 132 - if err != nil { 133 - m.systemErrors = append(m.systemErrors, err) 134 - cmd = Error.Cmd() 135 - return m, cmd 275 + // Clean up old process 276 + if m.process != nil { 277 + m.process.Stop() 278 + m.process = nil 136 279 } 137 280 138 - stdout, err := command.StdoutPipe() 281 + // Reset state 282 + m.stderr = "" 283 + m.stdout = "" 284 + m.messages = nil 285 + m.fs = nil 286 + 287 + // Create new process 288 + process, err := NewCommandProcess("go", m.config.Mode, m.config.Package) 139 289 if err != nil { 140 290 m.systemErrors = append(m.systemErrors, err) 141 - cmd = Error.Cmd() 291 + return m, Error.Cmd() 142 292 } 293 + m.process = process 143 294 295 + // Record start time 144 296 start := time.Now() 145 - if err := command.Start(); err != nil { 297 + m.start = &start 298 + m.end = nil 299 + 300 + // Start the process 301 + if err := m.process.Start(); err != nil { 146 302 m.systemErrors = append(m.systemErrors, err) 147 - cmd = Error.Cmd() 148 - return m, cmd 303 + return m, Error.Cmd() 149 304 } 150 - end := time.Now() 305 + 306 + return m, tea.Batch(status, m.checkCmdStatus(), m.checkStdout(), m.checkStderr()) 307 + } 308 + 309 + type stdoutMsg struct { 310 + line string 311 + } 312 + 313 + type stderrMsg struct { 314 + line string 315 + } 151 316 152 - stderrData, _ := io.ReadAll(stderr) 153 - stdoutData, _ := io.ReadAll(stdout) 317 + func (m Model) checkStdout() tea.Cmd { 318 + if m.process == nil { 319 + return nil 320 + } 154 321 155 - if err := command.Wait(); err != nil { 156 - m.systemErrors = append(m.systemErrors, err) 157 - cmd = Error.Cmd() 322 + return func() tea.Msg { 323 + select { 324 + case line, ok := <-m.process.stdoutChan: 325 + if !ok { 326 + return nil 327 + } 328 + return stdoutMsg{line: line} 329 + case <-time.After(10 * time.Millisecond): 330 + return nil 331 + } 158 332 } 333 + } 159 334 160 - m.stderr = string(stderrData) 161 - m.stdout = string(stdoutData) 335 + func (m Model) checkStderr() tea.Cmd { 336 + if m.process == nil { 337 + return nil 338 + } 162 339 163 - if m.config.Mode == "build" { 164 - messages := Parse(m.stderr) 165 - fs := Fs{} 166 - if !m.config.Summarized { 167 - fs = BuildFs(messages) 168 - fs.PopulateContext(messages, m.context) 340 + return func() tea.Msg { 341 + select { 342 + case line, ok := <-m.process.stderrChan: 343 + if !ok { 344 + return nil 345 + } 346 + return stderrMsg{line: line} 347 + case <-time.After(10 * time.Millisecond): 348 + return nil 169 349 } 170 - m.messages = messages 171 - m.fs = fs 172 - m.duration = end.Sub(start) 350 + } 351 + } 352 + 353 + type processState struct { 354 + state *os.ProcessState 355 + } 173 356 174 - if len(m.messages) != 0 { 175 - cmd = Error.Cmd() 176 - } 357 + func (m Model) checkCmdStatus() tea.Cmd { 358 + if m.process == nil { 359 + return nil 177 360 } 178 361 179 - return m, cmd 362 + return func() tea.Msg { 363 + return processState{m.process.CheckStatus()} 364 + } 180 365 }
+12
parse.go
··· 48 48 continue 49 49 } 50 50 51 + if strings.HasPrefix(line, "go:") { 52 + msg := CompilerMessage{ 53 + Type: "error", 54 + Location: Location{}, 55 + Message: strings.TrimPrefix(line, "go: "), 56 + Raw: line, 57 + Priority: 999, 58 + } 59 + 60 + messages = append(messages, msg) 61 + } 62 + 51 63 if currentMultilineMsg != nil && continuation.MatchString(line) { 52 64 matches := continuation.FindStringSubmatch(line) 53 65 if len(matches) >= 2 {
+57 -19
view.go
··· 4 4 "fmt" 5 5 "strconv" 6 6 "strings" 7 + "time" 7 8 8 9 "github.com/charmbracelet/lipgloss" 9 10 "github.com/muesli/reflow/wordwrap" ··· 11 12 12 13 func (m Model) View() string { 13 14 var b strings.Builder 15 + style := m.config.Styles 16 + sign := m.config.Signs 14 17 15 18 b.WriteString(m.viewStatus()) 16 19 20 + if m.config.Mode == "build" { 21 + m.viewBuild(&b) 22 + } else { 23 + m.viewRun(&b) 24 + } 25 + 26 + for _, e := range m.systemErrors { 27 + b.WriteString(style.Error.Render(sign.System)) 28 + b.WriteString(" ") 29 + b.WriteString(e.Error()) 30 + b.WriteString("\n") 31 + } 32 + 33 + b.WriteString("\n") 34 + 35 + if m.config.Help { 36 + b.WriteString(m.viewHelp()) 37 + } 38 + 39 + return b.String() 40 + } 41 + 42 + func (m Model) viewBuild(b *strings.Builder) { 17 43 maxLineLen := 1 18 44 maxColLen := 1 45 + style := m.config.Styles 46 + sign := m.config.Signs 47 + 19 48 for _, msg := range m.messages { 20 49 lineLen := len(msg.Location.Line) 21 50 colLen := len(msg.Location.Column) ··· 27 56 maxColLen = colLen 28 57 } 29 58 } 30 - 31 - style := m.config.Styles 32 - sign := m.config.Signs 33 59 34 60 for _, msg := range m.messages { 35 61 var mb strings.Builder ··· 102 128 103 129 b.WriteString("\n") 104 130 } 131 + } 105 132 106 - for _, e := range m.systemErrors { 107 - b.WriteString(style.Error.Render(sign.System)) 108 - b.WriteString(" ") 109 - b.WriteString(e.Error()) 110 - b.WriteString("\n") 133 + func (m Model) viewRun(b *strings.Builder) { 134 + style := m.config.Styles 135 + 136 + width := m.width 137 + if m.stdout != "" && m.stderr != "" { 138 + width = width / 2 139 + b.WriteString(style.Stdout.Width(width).Render("stdout")) 140 + b.WriteString(style.Stdout.Width(width).Render("stderr")) 141 + } else if m.stdout != "" { 142 + b.WriteString(style.Stdout.Render("stdout")) 143 + } else if m.stderr != "" { 144 + b.WriteString(style.Stdout.Render("stderr")) 111 145 } 112 146 113 - b.WriteString("\n") 147 + leftSide := fmt.Sprintf("\n%s", m.stdout) 148 + rightSide := fmt.Sprintf("\n%s", m.stderr) 114 149 115 - if m.config.Help { 116 - b.WriteString(m.viewHelp()) 117 - } 150 + columns := lipgloss.JoinHorizontal(lipgloss.Top, leftSide, rightSide) 118 151 119 - return b.String() 152 + b.WriteString(columns) 120 153 } 121 154 122 155 func (m Model) viewStatus() string { ··· 151 184 b.WriteString(statusStyle.Render(m.status.View())) 152 185 } 153 186 154 - plural := func(n int) string { 187 + plural := func(s string, n int) string { 155 188 if n == 1 { 156 - return "" 189 + return s 157 190 } else { 158 - return "s" 191 + return fmt.Sprintf("%ss", s) 159 192 } 160 193 } 161 194 162 195 // write message summary 163 196 if errorCount != 0 { 164 197 b.WriteString(style.Separator.Render(sign.Separator)) 165 - b.WriteString(style.Error.Render(fmt.Sprintf("%d error%s", errorCount, plural(errorCount)))) 198 + b.WriteString(style.Error.Render(fmt.Sprintf("%d %s", errorCount, plural("error", errorCount)))) 166 199 } 167 200 if warningCount != 0 { 168 201 b.WriteString(style.Separator.Render(sign.Separator)) 169 - b.WriteString(style.Warning.Render(fmt.Sprintf("%d warning%s", warningCount, plural(warningCount)))) 202 + b.WriteString(style.Warning.Render(fmt.Sprintf("%d %s", warningCount, plural("warning", warningCount)))) 170 203 } 171 204 172 205 b.WriteString(style.Separator.Render(sign.Separator)) 173 - b.WriteString(style.Duration.Render(m.duration.String())) 206 + 207 + if m.start != nil && m.end != nil { 208 + b.WriteString(style.Duration.Render(m.end.Sub(*m.start).String())) 209 + } else if m.start != nil && m.end == nil { 210 + b.WriteString(style.Duration.Render(time.Now().Sub(*m.start).String())) 211 + } 174 212 175 213 b.WriteString("\n") 176 214