github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/runners/scripts/edit.go (about)

     1  package scripts
     2  
     3  import (
     4  	"fmt"
     5  	"os"
     6  	"os/signal"
     7  	"reflect"
     8  
     9  	"github.com/fsnotify/fsnotify"
    10  
    11  	"github.com/ActiveState/cli/internal/errs"
    12  	"github.com/ActiveState/cli/internal/fileutils"
    13  	"github.com/ActiveState/cli/internal/locale"
    14  	"github.com/ActiveState/cli/internal/logging"
    15  	"github.com/ActiveState/cli/internal/osutils"
    16  	"github.com/ActiveState/cli/internal/output"
    17  	"github.com/ActiveState/cli/internal/prompt"
    18  	"github.com/ActiveState/cli/internal/runbits/rationalize"
    19  	"github.com/ActiveState/cli/internal/scriptfile"
    20  	"github.com/ActiveState/cli/pkg/project"
    21  	"github.com/ActiveState/cli/pkg/projectfile"
    22  )
    23  
    24  // The default open command and editors based on platform
    25  const (
    26  	openCmdLin       = "xdg-open"
    27  	openCmdMac       = "open"
    28  	defaultEditorWin = "notepad.exe"
    29  )
    30  
    31  // EditParams stores command line parameters for script edit commands
    32  type EditParams struct {
    33  	Name   string
    34  	Expand bool
    35  }
    36  
    37  // Edit represents the runner for `state script edit`
    38  type Edit struct {
    39  	project  *project.Project
    40  	output   output.Outputer
    41  	prompter prompt.Prompter
    42  	cfg      projectfile.ConfigGetter
    43  }
    44  
    45  // NewEdit creates a new Edit runner
    46  func NewEdit(prime primeable) *Edit {
    47  	return &Edit{
    48  		prime.Project(),
    49  		prime.Output(),
    50  		prime.Prompt(),
    51  		prime.Config(),
    52  	}
    53  }
    54  
    55  func (e *Edit) Run(params *EditParams) error {
    56  	if e.project == nil {
    57  		return rationalize.ErrNoProject
    58  	}
    59  
    60  	script := e.project.ScriptByName(params.Name)
    61  	if script == nil {
    62  		return locale.NewInputError("edit_scripts_no_name", "Could not find script with the given name {{.V0}}", params.Name)
    63  	}
    64  
    65  	err := e.editScript(script, params)
    66  	if err != nil {
    67  		return locale.WrapError(err, "error_edit_script", "Failed to edit script.")
    68  	}
    69  	return nil
    70  }
    71  
    72  func (e *Edit) editScript(script *project.Script, params *EditParams) error {
    73  	scriptFile, err := createScriptFile(script, params.Expand)
    74  	if err != nil {
    75  		return locale.WrapError(
    76  			err, "error_edit_create_scriptfile",
    77  			"Could not create script file.")
    78  	}
    79  	defer scriptFile.Clean()
    80  
    81  	watcher, err := newScriptWatcher(scriptFile)
    82  	if err != nil {
    83  		return errs.Wrap(err, "Failed to initialize file watch.")
    84  	}
    85  	defer watcher.close()
    86  
    87  	err = osutils.OpenEditor(scriptFile.Filename())
    88  	if err != nil {
    89  		return locale.WrapError(
    90  			err, "error_edit_open_scriptfile",
    91  			"Failed to open script file in editor.")
    92  	}
    93  
    94  	return start(e.prompter, watcher, params.Name, e.output, e.cfg, e.project)
    95  }
    96  
    97  func createScriptFile(script *project.Script, expand bool) (*scriptfile.ScriptFile, error) {
    98  	scriptBlock := script.Raw()
    99  	if expand {
   100  		var err error
   101  		scriptBlock, err = script.Value()
   102  		if err != nil {
   103  			return nil, errs.Wrap(err, "Could not get script value")
   104  		}
   105  	}
   106  
   107  	languages := script.LanguageSafe()
   108  	if len(languages) == 0 {
   109  		languages = project.DefaultScriptLanguage()
   110  	}
   111  
   112  	f, err := scriptfile.NewAsSource(languages[0], script.Name(), scriptBlock)
   113  	if err != nil {
   114  		return f, errs.Wrap(err, "Failed to create script file")
   115  	}
   116  	return f, nil
   117  }
   118  
   119  type scriptWatcher struct {
   120  	watcher    *fsnotify.Watcher
   121  	scriptFile *scriptfile.ScriptFile
   122  	done       chan bool
   123  	errs       chan error
   124  }
   125  
   126  func newScriptWatcher(scriptFile *scriptfile.ScriptFile) (*scriptWatcher, error) {
   127  	watcher, err := fsnotify.NewWatcher()
   128  	if err != nil {
   129  		return nil, errs.Wrap(err, "failed to create file watcher")
   130  	}
   131  
   132  	err = watcher.Add(scriptFile.Filename())
   133  	if err != nil {
   134  		return nil, errs.Wrap(err, "failed to add %s to file watcher", scriptFile.Filename())
   135  	}
   136  
   137  	return &scriptWatcher{
   138  		watcher:    watcher,
   139  		scriptFile: scriptFile,
   140  		done:       make(chan bool),
   141  		errs:       make(chan error),
   142  	}, nil
   143  }
   144  
   145  func (sw *scriptWatcher) run(scriptName string, outputer output.Outputer, cfg projectfile.ConfigGetter, proj *project.Project) {
   146  	for {
   147  		select {
   148  		case <-sw.done:
   149  			return
   150  		case event, ok := <-sw.watcher.Events:
   151  			if !ok {
   152  				sw.errs <- locale.NewError(
   153  					"error_edit_watcher_channel_closed",
   154  					"Encountered error watching scriptfile. Please restart edit command.",
   155  				)
   156  				return
   157  			}
   158  			// Some editors do not set WRITE events when a file is modified. Instead they send a REMOVE event
   159  			// followed by a CREATE event. The script file already exists at this point so we capture the
   160  			// CREATE event as a WRITE event.
   161  			if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
   162  				err := updateProjectFile(cfg, proj, sw.scriptFile, scriptName)
   163  				if err != nil {
   164  					sw.errs <- errs.Wrap(err, "Failed to write project file.")
   165  					return
   166  				}
   167  				outputer.Notice(locale.T("edit_scripts_project_file_saved"))
   168  			}
   169  		case err, ok := <-sw.watcher.Errors:
   170  			if !ok {
   171  				sw.errs <- locale.NewError(
   172  					"error_edit_watcher_channel_closed",
   173  					"Encountered error watching scriptfile. Please restart edit command.")
   174  				return
   175  			}
   176  			sw.errs <- errs.Wrap(err, "File watcher reported error.")
   177  			return
   178  		}
   179  	}
   180  }
   181  
   182  func (sw *scriptWatcher) close() {
   183  	sw.watcher.Close()
   184  	close(sw.done)
   185  	close(sw.errs)
   186  }
   187  
   188  func start(prompt prompt.Prompter, sw *scriptWatcher, scriptName string, output output.Outputer, cfg projectfile.ConfigGetter, proj *project.Project) (err error) {
   189  	output.Notice(locale.Tr("script_watcher_watch_file", sw.scriptFile.Filename()))
   190  	if prompt.IsInteractive() {
   191  		return startInteractive(sw, scriptName, output, cfg, proj, prompt)
   192  	}
   193  	return startNoninteractive(sw, scriptName, output, cfg, proj)
   194  }
   195  
   196  func startNoninteractive(sw *scriptWatcher, scriptName string, output output.Outputer, cfg projectfile.ConfigGetter, proj *project.Project) error {
   197  	c := make(chan os.Signal, 1)
   198  	signal.Notify(c, os.Interrupt)
   199  
   200  	errC := make(chan error)
   201  	defer close(errC)
   202  	go func() {
   203  		sig := <-c
   204  		logging.Debug(fmt.Sprintf("Detected: %s handling any failures encountered while watching file", sig))
   205  		var err error
   206  		defer func() {
   207  			// signal the process that we are done
   208  			sw.done <- true
   209  			errC <- err
   210  		}()
   211  		select {
   212  		case err = <-sw.errs:
   213  		default:
   214  			// Do nothing and let defer take over
   215  		}
   216  	}()
   217  	sw.run(scriptName, output, cfg, proj)
   218  
   219  	err := <-errC
   220  
   221  	// clean-up
   222  	sw.scriptFile.Clean()
   223  
   224  	if err != nil {
   225  		return locale.WrapError(
   226  			err, "error_edit_watcher_fail",
   227  			"An error occurred while watching for file changes.  Your changes may not be saved.")
   228  	}
   229  	return nil
   230  }
   231  
   232  func startInteractive(sw *scriptWatcher, scriptName string, output output.Outputer, cfg projectfile.ConfigGetter, proj *project.Project, prompt prompt.Prompter) error {
   233  	go sw.run(scriptName, output, cfg, proj)
   234  
   235  	for {
   236  		doneConfirmDefault := true
   237  		doneEditing, err := prompt.Confirm("", locale.T("prompt_done_editing"), &doneConfirmDefault)
   238  		if err != nil {
   239  			return errs.Wrap(err, "Prompter returned with failure.")
   240  		}
   241  		if doneEditing {
   242  			sw.done <- true
   243  			break
   244  		}
   245  	}
   246  
   247  	select {
   248  	case err := <-sw.errs:
   249  		return err
   250  	default:
   251  		return nil
   252  	}
   253  }
   254  
   255  func updateProjectFile(cfg projectfile.ConfigGetter, pj *project.Project, scriptFile *scriptfile.ScriptFile, name string) error {
   256  	updatedScript, err := fileutils.ReadFile(scriptFile.Filename())
   257  	if err != nil {
   258  		return errs.Wrap(err, "Failed to read script file %s.", scriptFile.Filename())
   259  	}
   260  
   261  	pjf := pj.Source()
   262  	script := pj.ScriptByName(name)
   263  	if script == nil {
   264  		return locale.NewError("err_update_script_cannot_find", "Could not find the source script to update.")
   265  	}
   266  
   267  	idx := -1
   268  	for i, s := range pjf.Scripts {
   269  		if reflect.DeepEqual(s, *script.SourceScript()) {
   270  			idx = i
   271  			break
   272  		}
   273  	}
   274  	if idx == -1 {
   275  		return locale.NewError("err_update_script_cannot_find", "Could not find the source script to update.")
   276  	}
   277  
   278  	pjf.Scripts[idx].Value = string(updatedScript)
   279  
   280  	err = pjf.Save(cfg)
   281  	if err != nil {
   282  		return errs.Wrap(err, "Failed to save project file.")
   283  	}
   284  	return nil
   285  }