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 }