github.com/aretext/aretext@v1.3.0/state/shellcmd.go (about) 1 package state 2 3 import ( 4 "context" 5 "fmt" 6 "log" 7 "os" 8 "path/filepath" 9 "strings" 10 11 "github.com/aretext/aretext/clipboard" 12 "github.com/aretext/aretext/config" 13 "github.com/aretext/aretext/locate" 14 "github.com/aretext/aretext/menu" 15 "github.com/aretext/aretext/selection" 16 "github.com/aretext/aretext/shellcmd" 17 "github.com/aretext/aretext/text" 18 ) 19 20 // SuspendScreenFunc suspends the screen, executes a function, then resumes the screen. 21 // This allows the shell command to take control of the terminal. 22 type SuspendScreenFunc func(func() error) error 23 24 // RunShellCmd executes the command in a shell. 25 // Mode must be a valid command mode, as defined in config. 26 // All modes run as an asynchronous task that the user can cancel, 27 // except for CmdModeTerminal which takes over stdin/stdout. 28 func RunShellCmd(state *EditorState, shellCmd string, mode string) { 29 log.Printf("Running shell command: %q\n", shellCmd) 30 31 env := envVars(state) // Read-only copy of env vars is safe to pass to other goroutines. 32 33 switch mode { 34 case config.CmdModeTerminal: 35 // Run synchronously because the command takes over stdin/stdout. 36 ctx := context.Background() 37 err := state.suspendScreenFunc(func() error { 38 return shellcmd.RunInTerminal(ctx, shellCmd, env) 39 }) 40 setStatusForShellCmdResult(state, err) 41 42 case config.CmdModeSilent: 43 StartTask(state, func(ctx context.Context) func(*EditorState) { 44 err := shellcmd.RunSilent(ctx, shellCmd, env) 45 return func(state *EditorState) { 46 setStatusForShellCmdResult(state, err) 47 } 48 }) 49 50 case config.CmdModeInsert: 51 StartTask(state, func(ctx context.Context) func(*EditorState) { 52 output, err := shellcmd.RunAndCaptureOutput(ctx, shellCmd, env) 53 return func(state *EditorState) { 54 if err == nil { 55 insertShellCmdOutput(state, output) 56 } 57 setStatusForShellCmdResult(state, err) 58 } 59 }) 60 61 case config.CmdModeInsertChoice: 62 StartTask(state, func(ctx context.Context) func(*EditorState) { 63 output, err := shellcmd.RunAndCaptureOutput(ctx, shellCmd, env) 64 return func(state *EditorState) { 65 if err == nil { 66 err = showInsertChoiceMenuForShellCmdOutput(state, output) 67 } 68 setStatusForShellCmdResult(state, err) 69 } 70 }) 71 72 case config.CmdModeFileLocations: 73 StartTask(state, func(ctx context.Context) func(*EditorState) { 74 output, err := shellcmd.RunAndCaptureOutput(ctx, shellCmd, env) 75 return func(state *EditorState) { 76 if err == nil { 77 err = showFileLocationsMenuForShellCmdOutput(state, output) 78 } 79 setStatusForShellCmdResult(state, err) 80 } 81 }) 82 83 case config.CmdModeWorkingDir: 84 StartTask(state, func(ctx context.Context) func(*EditorState) { 85 output, err := shellcmd.RunAndCaptureOutput(ctx, shellCmd, env) 86 return func(state *EditorState) { 87 if err == nil { 88 err = showWorkingDirMenuForShellCmdOutput(state, output) 89 } 90 setStatusForShellCmdResult(state, err) 91 } 92 }) 93 94 default: 95 // This should never happen because the config validates the mode. 96 panic("Unrecognized shell cmd mode") 97 } 98 } 99 100 func setStatusForShellCmdResult(state *EditorState, err error) { 101 if err != nil { 102 SetStatusMsg(state, StatusMsg{ 103 Style: StatusMsgStyleError, 104 Text: fmt.Sprintf("Shell command failed: %s", err), 105 }) 106 return 107 } 108 109 SetStatusMsg(state, StatusMsg{ 110 Style: StatusMsgStyleSuccess, 111 Text: "Shell command completed successfully", 112 }) 113 } 114 115 func envVars(state *EditorState) []string { 116 env := os.Environ() 117 118 // $FILEPATH is the path to the current file. 119 filePath := state.fileWatcher.Path() 120 env = append(env, fmt.Sprintf("FILEPATH=%s", filePath)) 121 122 // $WORD is the current word under the cursor (excluding whitespace). 123 currentWord := currentWordEnvVar(state) 124 env = append(env, fmt.Sprintf("WORD=%s", currentWord)) 125 126 // $LINE is the line number of the cursor, starting from one. 127 // $COLUMN is the column position of the cursor in bytes, starting from one. 128 lineNum, columnNum := lineAndColumnEnvVars(state) 129 env = append(env, 130 fmt.Sprintf("LINE=%d", lineNum), 131 fmt.Sprintf("COLUMN=%d", columnNum)) 132 133 // $SELECTION is the current visual mode selection, if any. 134 selection, _ := copySelectionText(state.documentBuffer) 135 if len(selection) > 0 { 136 env = append(env, fmt.Sprintf("SELECTION=%s", selection)) 137 } 138 139 return env 140 } 141 142 func currentWordEnvVar(state *EditorState) string { 143 buffer := state.documentBuffer 144 textTree := buffer.textTree 145 cursorPos := buffer.cursor.position 146 wordStartPos, wordEndPos := locate.InnerWordObject(textTree, cursorPos, 1) 147 word := copyText(textTree, wordStartPos, wordEndPos-wordStartPos) 148 return strings.TrimSpace(word) 149 } 150 151 func lineAndColumnEnvVars(state *EditorState) (uint64, uint64) { 152 buffer := state.documentBuffer 153 textTree := buffer.textTree 154 cursorPos := buffer.cursor.position 155 lineNum := textTree.LineNumForPosition(cursorPos) 156 startOfLinePos := textTree.LineStartPosition(lineNum) 157 columnNum := countBytesBetweenPositions(textTree, startOfLinePos, cursorPos) 158 // convert 0-indexed to 1-indexed 159 return lineNum + 1, columnNum + 1 160 } 161 162 func countBytesBetweenPositions(textTree *text.Tree, startPos, endPos uint64) uint64 { 163 var byteCount uint64 164 reader := textTree.ReaderAtPosition(startPos) 165 for i := startPos; i < endPos; i++ { 166 _, numBytes, err := reader.ReadRune() 167 if err != nil { 168 break 169 } 170 byteCount += uint64(numBytes) 171 } 172 return byteCount 173 } 174 175 func insertShellCmdOutput(state *EditorState, shellCmdOutput string) { 176 page := clipboard.PageContent{Text: shellCmdOutput} 177 state.clipboard.Set(clipboard.PageShellCmdOutput, page) 178 179 BeginUndoEntry(state) 180 if state.documentBuffer.selector.Mode() == selection.ModeNone { 181 PasteAfterCursor(state, clipboard.PageShellCmdOutput) 182 } else { 183 deleteCurrentSelection(state) 184 PasteBeforeCursor(state, clipboard.PageShellCmdOutput) 185 } 186 CommitUndoEntry(state) 187 188 setInputMode(state, InputModeNormal) 189 } 190 191 func deleteCurrentSelection(state *EditorState) { 192 selectionMode := state.documentBuffer.selector.Mode() 193 selectedRegion := state.documentBuffer.SelectedRegion() 194 MoveCursor(state, func(p LocatorParams) uint64 { return selectedRegion.StartPos }) 195 selectionEndLoc := func(p LocatorParams) uint64 { return selectedRegion.EndPos } 196 if selectionMode == selection.ModeChar { 197 DeleteToPos(state, selectionEndLoc, clipboard.PageDefault) 198 } else if selectionMode == selection.ModeLine { 199 DeleteLines(state, selectionEndLoc, false, true, clipboard.PageDefault) 200 } 201 } 202 203 func showInsertChoiceMenuForShellCmdOutput(state *EditorState, shellCmdOutput string) error { 204 var menuItems []menu.Item 205 for _, line := range strings.Split(shellCmdOutput, "\n") { 206 name := strings.TrimRight(line, "\r") // If output is CRLF, strip the CR as well. 207 if len(name) == 0 { 208 continue 209 } 210 menuItems = append(menuItems, menu.Item{ 211 Name: name, 212 Action: func(s *EditorState) { 213 insertShellCmdOutput(state, name) 214 }, 215 }) 216 } 217 218 if len(menuItems) == 0 { 219 return fmt.Errorf("No lines in command output") 220 } 221 222 ShowMenu(state, MenuStyleInsertChoice, menuItems) 223 return nil 224 } 225 226 func showWorkingDirMenuForShellCmdOutput(state *EditorState, shellCmdOutput string) error { 227 var menuItems []menu.Item 228 for _, line := range strings.Split(shellCmdOutput, "\n") { 229 dirPath := strings.TrimRight(line, "\r") // If output is CRLF, strip the CR as well. 230 if len(dirPath) == 0 { 231 continue 232 } 233 234 menuItems = append(menuItems, menu.Item{ 235 Name: dirPath, 236 Action: func(s *EditorState) { 237 SetWorkingDirectory(s, dirPath) 238 }, 239 }) 240 } 241 242 if len(menuItems) == 0 { 243 return fmt.Errorf("No lines in command output") 244 } 245 246 ShowMenu(state, MenuStyleWorkingDir, menuItems) 247 return nil 248 } 249 250 func showFileLocationsMenuForShellCmdOutput(state *EditorState, shellCmdOutput string) error { 251 locations, err := shellcmd.FileLocationsFromLines(strings.NewReader(shellCmdOutput)) 252 if err != nil { 253 return err 254 } 255 256 if len(locations) == 0 { 257 return fmt.Errorf("No file locations in cmd output") 258 } 259 260 menuItems, err := menuItemsFromFileLocations(locations) 261 if err != nil { 262 return err 263 } 264 265 ShowMenu(state, MenuStyleFileLocation, menuItems) 266 return nil 267 } 268 269 func menuItemsFromFileLocations(locations []shellcmd.FileLocation) ([]menu.Item, error) { 270 cwd, err := os.Getwd() 271 if err != nil { 272 return nil, fmt.Errorf("os.Getwd: %w", err) 273 } 274 275 menuItems := make([]menu.Item, 0, len(locations)) 276 for _, loc := range locations { 277 name := formatFileLocationName(loc) 278 path := absPath(loc.Path, cwd) 279 lineNum := translateFileLocationLineNum(loc.LineNum) 280 menuItems = append(menuItems, menu.Item{ 281 Name: name, 282 Action: func(s *EditorState) { 283 abortMsg := "Document has unsaved changes" 284 AbortIfUnsavedChanges(s, abortMsg, func(s *EditorState) { 285 LoadDocument(s, path, true, func(p LocatorParams) uint64 { 286 return locate.StartOfLineNum(p.TextTree, lineNum) 287 }) 288 }) 289 }, 290 }) 291 } 292 return menuItems, nil 293 } 294 295 func formatFileLocationName(loc shellcmd.FileLocation) string { 296 return fmt.Sprintf("%s:%d %s", loc.Path, loc.LineNum, loc.Snippet) 297 } 298 299 func absPath(p, wd string) string { 300 if filepath.IsAbs(p) { 301 return filepath.Clean(p) 302 } 303 return filepath.Join(wd, p) 304 } 305 306 func translateFileLocationLineNum(lineNum uint64) uint64 { 307 if lineNum > 0 { 308 return lineNum - 1 309 } else { 310 return lineNum 311 } 312 }