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  }