diff --git a/common/common.go b/common/common.go index e8c7965..e93f42c 100644 --- a/common/common.go +++ b/common/common.go @@ -1,7 +1,10 @@ // Package common holds functionality that is common to multiple other packages. package common -import "sort" +import ( + "golang.org/x/exp/constraints" + "sort" +) // Set implements a Set for the key type T. type Set[T comparable] map[T]struct{} @@ -26,13 +29,25 @@ func (s Set[T]) Insert(key T) { s[key] = struct{}{} } -// SortedKeys enumerate keys from a string map and sort them. -// TODO: make it for any key type. -func SortedKeys[T any](m map[string]T) (keys []string) { - keys = make([]string, 0, len(m)) - for key := range m { - keys = append(keys, key) +// Delete key into set. +func (s Set[T]) Delete(key T) { + delete(s, key) +} + +// Keys returns the keys of a map in the form of a slice. +func Keys[K comparable, V any](m map[K]V) []K { + s := make([]K, 0, len(m)) + for k := range m { + s = append(s, k) } - sort.Strings(keys) - return + return s +} + +// SortedKeys returns the sorted keys of a map in the form of a slice. +func SortedKeys[K constraints.Ordered, V any](m map[K]V) []K { + s := Keys(m) + sort.Slice(s, func(i, j int) bool { + return s[i] < s[j] + }) + return s } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b34a662..5b003ec 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # GoNB Changelog +## v0.6.5 - 2023/05/23 + +* More contextual help and auto-complete improvements: + * Added tracking of files in development (`%track`, `%untrack`), for usage with `gopls`. + * Auto-track `replace` directives in `go.mod` pointing to local filesystem. + ## v0.6.4 - 2023/05/22 * More InspectRequest improvements: diff --git a/examples/tutorial.ipynb b/examples/tutorial.ipynb index 782ac53..05b07d2 100644 --- a/examples/tutorial.ipynb +++ b/examples/tutorial.ipynb @@ -198,7 +198,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 7, "id": "d59e0cbd-0d2a-42e5-8f94-29936130f437", "metadata": {}, "outputs": [ @@ -207,8 +207,8 @@ "output_type": "stream", "text": [ "\t...VeryExpensive() call...\n", - "NonCachedValue=829\n", - " CachedValue=594\n" + "NonCachedValue=693\n", + " CachedValue=712\n" ] } ], @@ -360,7 +360,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "lastRenderTime=1.466537\n" + "lastRenderTime=6.04329\n" ] }, { @@ -585,7 +585,7 @@ { "data": { "text/html": [ - "
12345678910X1.02.04.08.016.032.064.0128.0256.0512.0YA diagram of sorts 📊 📈
" + "
12345678910X1.02.04.08.016.032.064.0128.0256.0512.0YA diagram of sorts 📊 📈
" ] }, "metadata": {}, @@ -668,7 +668,7 @@ { "data": { "text/html": [ - "Animated Sine" + "Animated Sine" ] }, "metadata": {}, @@ -954,12 +954,20 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 27, "id": "7298fde2-1bf2-4252-a07e-9601d1901b61", "metadata": { "tags": [] }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2023/05/23 11:16:52 Goodbye!\n" + ] + } + ], "source": [ "import (\n", " \"log\"\n", @@ -1084,11 +1092,11 @@ "name": "stdout", "output_type": "stream", "text": [ - "go version go1.20.2 linux/amd64\n", + "go version go1.20.3 linux/amd64\n", "/home/janpf/Projects/gonb/examples\n", - "total 260\n", - "-rwxrwxrwx 1 janpf janpf 87160 Feb 12 11:09 google_colab_demo.ipynb\n", - "-rw-r--r-- 1 janpf janpf 173022 May 13 22:30 tutorial.ipynb\n" + "total 264\n", + "-rwxr-xr-x 1 janpf janpf 87160 May 22 11:29 google_colab_demo.ipynb\n", + "-rw-r--r-- 1 janpf janpf 179829 May 23 11:14 tutorial.ipynb\n" ] } ], @@ -1120,14 +1128,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "/tmp/gonb_06c2ae0c\n", - "total 9348\n", - "-rw-r--r-- 1 janpf janpf 2668 May 13 22:30 go.mod\n", - "-rw-r--r-- 1 janpf janpf 72490 May 13 22:30 go.sum\n", - "-rwxr-xr-x 1 janpf janpf 9482358 May 13 22:30 gonb_06c2ae0c\n", - "prw------- 1 janpf janpf 0 May 13 22:30 gonb_pipe_348023254\n", - "srwxr-xr-x 1 janpf janpf 0 May 13 22:29 gopls_socket\n", - "-rw-r--r-- 1 janpf janpf 4819 May 13 22:30 main.go\n" + "/tmp/gonb_82fbc9c7\n", + "total 9288\n", + "-rw-r--r-- 1 janpf janpf 1202 May 23 11:16 go.mod\n", + "-rwxr-xr-x 1 janpf janpf 9482462 May 23 11:16 gonb_82fbc9c7\n", + "prw------- 1 janpf janpf 0 May 23 11:16 gonb_pipe_2809117800\n", + "srwxr-xr-x 1 janpf janpf 0 May 23 11:15 gopls_socket\n", + "-rw-r--r-- 1 janpf janpf 9926 May 23 11:16 go.sum\n", + "-rw-r--r-- 1 janpf janpf 4819 May 23 11:16 main.go\n" ] } ], @@ -1204,62 +1212,37 @@ "name": "stdout", "output_type": "stream", "text": [ - "module gonb_06c2ae0c\n", + "module gonb_82fbc9c7\n", "\n", "go 1.20\n", "\n", "require (\n", - "\tfyne.io/fyne/v2 v2.3.4\n", "\tgithub.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b\n", "\tgithub.com/benc-uk/gofract v0.0.0-20230120162050-a6f644f92fd6\n", "\tgithub.com/erkkah/margaid v0.1.1-0.20230128143048-d60b2efd2f5a\n", - "\tgithub.com/janpfeifer/gonb v0.6.0\n", + "\tgithub.com/janpfeifer/gonb v0.6.4\n", "\tgithub.com/schollz/progressbar/v3 v3.13.1\n", - "\tgolang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea\n", + "\tgolang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1\n", "\tgonum.org/v1/plot v0.13.0\n", ")\n", "\n", "require (\n", - "\tfyne.io/systray v1.10.1-0.20230403195833-7dc3c09283d6 // indirect\n", "\tgit.sr.ht/~sbinet/gg v0.4.1 // indirect\n", - "\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n", - "\tgithub.com/fredbi/uri v0.1.0 // indirect\n", - "\tgithub.com/fsnotify/fsnotify v1.5.4 // indirect\n", - "\tgithub.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect\n", - "\tgithub.com/fyne-io/glfw-js v0.0.0-20220120001248-ee7290d23504 // indirect\n", - "\tgithub.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 // indirect\n", "\tgithub.com/go-fonts/liberation v0.3.1 // indirect\n", - "\tgithub.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // indirect\n", - "\tgithub.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b // indirect\n", "\tgithub.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9 // indirect\n", "\tgithub.com/go-pdf/fpdf v0.8.0 // indirect\n", - "\tgithub.com/go-text/typesetting v0.0.0-20230502123426-87572f5551cf // indirect\n", - "\tgithub.com/godbus/dbus/v5 v5.1.0 // indirect\n", "\tgithub.com/gofrs/uuid v4.4.0+incompatible // indirect\n", - "\tgithub.com/goki/freetype v0.0.0-20220119013949-7a161fd3728c // indirect\n", "\tgithub.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect\n", - "\tgithub.com/gopherjs/gopherjs v1.17.2 // indirect\n", - "\tgithub.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect\n", "\tgithub.com/lucasb-eyer/go-colorful v1.0.3 // indirect\n", "\tgithub.com/mattn/go-runewidth v0.0.14 // indirect\n", "\tgithub.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect\n", "\tgithub.com/pkg/errors v0.9.1 // indirect\n", - "\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n", "\tgithub.com/rivo/uniseg v0.2.0 // indirect\n", - "\tgithub.com/srwiley/oksvg v0.0.0-20220731023508-a61f04f16b76 // indirect\n", - "\tgithub.com/srwiley/rasterx v0.0.0-20210519020934-456a8d69b780 // indirect\n", - "\tgithub.com/stretchr/testify v1.8.1 // indirect\n", - "\tgithub.com/tevino/abool v1.2.0 // indirect\n", - "\tgithub.com/yuin/goldmark v1.4.13 // indirect\n", "\tgolang.org/x/image v0.7.0 // indirect\n", - "\tgolang.org/x/mobile v0.0.0-20211207041440-4e6c2922fdee // indirect\n", - "\tgolang.org/x/net v0.6.0 // indirect\n", "\tgolang.org/x/sys v0.7.0 // indirect\n", "\tgolang.org/x/term v0.6.0 // indirect\n", "\tgolang.org/x/text v0.9.0 // indirect\n", - "\tgopkg.in/yaml.v2 v2.4.0 // indirect\n", - "\tgopkg.in/yaml.v3 v3.0.1 // indirect\n", - "\thonnef.co/go/js/dom v0.0.0-20210725211120-f030747120f2 // indirect\n", + "\tgopkg.in/yaml.v2 v2.3.0 // indirect\n", ")\n", "\n", "replace github.com/janpfeifer/gonb => /home/janpf/Projects/gonb\n" @@ -1271,6 +1254,48 @@ "!*cat go.mod" ] }, + { + "cell_type": "markdown", + "id": "6594c119-e2e9-4d93-a4bd-506b4ab190fc", + "metadata": {}, + "source": [ + "**GoNB** also tracks of updates to local files in target directories of `replace`. See `%track` and `%untrack` to list and control tracking. With this you'll have up-to-date \"auto-complete\" and \"context help\" (activate with `control+I` usually) on changes on local files you may be editing on a separate editor.\n", + "\n", + "For instance, in our tutorial, this is what **GoNB** is tracking:" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "f6d836c1-3dfd-41a0-b097-b157f33289a8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "

List of files/directories being tracked:

\n", + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%track" + ] + }, { "cell_type": "markdown", "id": "1c54e53a-037c-451e-9fd4-116d27c58296", @@ -1291,7 +1316,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 26, "id": "ba9172b8-ede5-44cd-baa1-a58a1d668a98", "metadata": { "tags": [] @@ -1362,7 +1387,16 @@ " is always saved in the file \"main.go\". It's also where the \"go.mod\" file for\n", " the notebook is created and maintained. Useful for manipulating \"go.mod\",\n", " for instance to get a package from some specific version, something \n", - " like \"!*go get github.com/my/package@v3\".\n" + " like \"!*go get github.com/my/package@v3\".\n", + "\n", + "Tracking of Go files being develped:\n", + "\n", + "- \"%track [file_or_directory]\": add file or directory to list of tracked files,\n", + " which are monitored by GoNB (and 'gopls') for auto-complete or contextual help.\n", + " If no file is given, it lists the currently tracked files.\n", + "- \"%untrack [file_or_directory][...]\": remove file or directory from list of tracked files.\n", + " If suffixed with \"...\" it will remove all files prefixed with the string given (without the\n", + " \"...\"). If no file is given, it lists the currently tracked files. \n" ] } ], @@ -1384,7 +1418,7 @@ "name": "go", "nbconvert_exporter": "", "pygments_lexer": "", - "version": "go1.20.2" + "version": "go1.20.3" } }, "nbformat": 4, diff --git a/go.mod b/go.mod index 5930f7b..b99bca9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/janpfeifer/gonb go 1.20 require ( + github.com/fsnotify/fsnotify v1.4.7 github.com/go-language-server/jsonrpc2 v0.4.2 github.com/go-language-server/protocol v0.7.0 github.com/go-language-server/uri v0.2.0 diff --git a/go.sum b/go.sum index ca3d517..968621f 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,7 @@ github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25Kn github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= diff --git a/goexec/goexec.go b/goexec/goexec.go index f4ea0c5..1184244 100644 --- a/goexec/goexec.go +++ b/goexec/goexec.go @@ -34,6 +34,9 @@ type State struct { // gopls client gopls *goplsclient.Client + + // trackingInfo is everything related to tracking. + trackingInfo *trackingInfo } // Declarations is a collection of declarations that we carry over from one cell to another. @@ -48,10 +51,11 @@ type Declarations struct { // New returns an empty State object, that can be used to execute Cells. func New(uniqueID string) (*State, error) { s := &State{ - UniqueID: uniqueID, - Package: "gonb_" + uniqueID, - Decls: NewDeclarations(), - AutoGet: true, + UniqueID: uniqueID, + Package: "gonb_" + uniqueID, + Decls: NewDeclarations(), + AutoGet: true, + trackingInfo: newTrackingInfo(), } // Create directory. diff --git a/goexec/inspect.go b/goexec/inspect.go index 7f6b980..e68861e 100644 --- a/goexec/inspect.go +++ b/goexec/inspect.go @@ -22,13 +22,26 @@ var standardFilesForNotification = []string{ "main.go", "go.mod", "go.sum", "go.work", "other.go", } -func (s *State) notifyAboutStandardFiles(ctx context.Context) (err error) { +func (s *State) notifyAboutStandardAndTrackedFiles(ctx context.Context) (err error) { for _, filePath := range standardFilesForNotification { err = s.gopls.NotifyDidOpenOrChange(ctx, path.Join(s.TempDir, filePath)) if err != nil { return } } + + err = s.AutoTrack() + if err != nil { + return + } + + err = s.EnumerateUpdatedFiles(func(filePath string) error { + klog.Infof("Notified of change to %q", filePath) + return s.gopls.NotifyDidOpenOrChange(ctx, filePath) + }) + if err != nil { + return + } return } @@ -91,7 +104,7 @@ func (s *State) InspectIdentifierInCell(lines []string, skipLines map[int]struct s.MainPath(), cursorInFile.Line, cursorInFile.Col) // Notify about standard files updates: - err = s.notifyAboutStandardFiles(ctx) + err = s.notifyAboutStandardAndTrackedFiles(ctx) if err != nil { return } @@ -161,7 +174,7 @@ func (s *State) AutoCompleteOptionsInCell(cellLines []string, skipLines map[int] // Query `gopls`. ctx := context.Background() - err = s.notifyAboutStandardFiles(ctx) + err = s.notifyAboutStandardAndTrackedFiles(ctx) if err != nil { return } diff --git a/goexec/tracking.go b/goexec/tracking.go new file mode 100644 index 0000000..163b840 --- /dev/null +++ b/goexec/tracking.go @@ -0,0 +1,308 @@ +package goexec + +import ( + "github.com/fsnotify/fsnotify" + "github.com/janpfeifer/gonb/common" + "github.com/pkg/errors" + "io/fs" + "k8s.io/klog/v2" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "sync" + "time" +) + +// This file implements the tracking of files and directories. When updated, these files +// are sent to `gopls` to update its contents for auto-complete and contextual info +// (`InspectRequest`) requests. + +// trackingInfo is a substructure of State that holds all the tracking information. +type trackingInfo struct { + // mu protects tracking information + mu sync.Mutex + + // tracked files and directories + tracked map[string]*trackEntry + + // updated is the list of files that changed since last call to State.EnumerateUpdatedFiles. + updated common.Set[string] + + // watcher for files being tracked. It is notified of file system changes. + watcher *fsnotify.Watcher + + // go.mod last modification time, used for the AutoTrack + goModModTime time.Time +} + +// trackEntry has information about a file or directory. +type trackEntry struct { + IsDir bool + UpdatedModTime time.Time +} + +func newTrackingInfo() *trackingInfo { + return &trackingInfo{ + tracked: make(map[string]*trackEntry), + updated: common.MakeSet[string](), + } +} + +// Track adds the fileOrDirPath to the list of tracked files and directories. +// If fileOrDirPath is already tracked, it's a no-op. +func (s *State) Track(fileOrDirPath string) (err error) { + ti := s.trackingInfo + ti.mu.Lock() + defer ti.mu.Unlock() + + _, found := ti.tracked[fileOrDirPath] + if found { + return + } + fileInfo, err := os.Stat(fileOrDirPath) + if err != nil { + if os.IsNotExist(err) { + err = errors.Wrapf(err, "path %q cannot be tracked because it does not exist", fileOrDirPath) + } else { + err = errors.Wrapf(err, "failed to track %q for changes", fileOrDirPath) + } + } + + // Create entry. + entry := &trackEntry{ + IsDir: fileInfo.IsDir(), + UpdatedModTime: fileInfo.ModTime(), + } + ti.tracked[fileOrDirPath] = entry + + // Add watcher. + if ti.watcher == nil { + ti.watcher, err = fsnotify.NewWatcher() + if err != nil { + err = errors.Wrapf(err, "failed to create a filesystem watcher, not able to track file %q", fileOrDirPath) + return + } + go func() { + klog.V(2).Infof("goexec.State.Track(): Starting to listen to watcher") + defer klog.V(2).Infof("goexec.State.Track(): Stopped to listen to watcher") + + for { + select { + case event, ok := <-ti.watcher.Events: + if !ok { + return + } + if event.Op != fsnotify.Write && event.Op != fsnotify.Remove { + // Not interested. + continue + } + if !isGoRelated(event.Name) { + // Not interested. + continue + } + ti.mu.Lock() + klog.V(2).Infof("goexec.Track: updates to %q", event.Name) + ti.updated.Insert(event.Name) + ti.mu.Unlock() + case err, ok := <-ti.watcher.Errors: + klog.V(2).Infof("goexec.Track: async error received %+v", err) + if !ok { + return + } + } + } + }() + } + err = ti.watcher.Add(fileOrDirPath) + if err != nil { + err = errors.Wrapf(err, "Failed to watch tracked file/directory %q", fileOrDirPath) + return + } + + if entry.IsDir { + err = filepath.WalkDir(fileOrDirPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return errors.Wrapf(err, "failed to track file under tracked directory %q", fileOrDirPath) + } + if d.IsDir() || !isGoRelated(path) { + // Directories or files we don't care about. + return nil + } + if !ti.updated.Has(path) { + ti.updated.Insert(path) + klog.V(2).Infof("tracking %q: added file for update %q", fileOrDirPath, path) + } + return nil + }) + } else { + ti.updated.Insert(fileOrDirPath) + } + return +} + +// Untrack removes file or dir from path of tracked files. If it ends with "...", it un-tracks +// anything that has fileOrDirPath as prefix. If you set `fileOrDirPath == "..."`, it will +// un-tracks everything. +func (s *State) Untrack(fileOrDirPath string) (err error) { + s.trackingInfo.mu.Lock() + defer s.trackingInfo.mu.Unlock() + + if !strings.HasSuffix(fileOrDirPath, "...") { + return s.lockedUntrackEntry(fileOrDirPath) + } + + prefix := fileOrDirPath[:len(fileOrDirPath)-3] + var toUntrack []string + for p := range s.trackingInfo.tracked { + if strings.HasPrefix(p, prefix) { + toUntrack = append(toUntrack, p) + } + } + for _, p := range toUntrack { + err = s.lockedUntrackEntry(p) + if err != nil { + return err + } + } + return +} + +func (s *State) lockedUntrackEntry(fileOrDirPath string) (err error) { + ti := s.trackingInfo + entry, found := ti.tracked[fileOrDirPath] + if !found { + err = errors.Errorf("file or directory %q is not tracked, cannot untrack", fileOrDirPath) + return + } + _ = entry + delete(ti.tracked, fileOrDirPath) + err = ti.watcher.Remove(fileOrDirPath) + if err != nil { + klog.V(2).Infof("goexec.Untrack failed to close watcher: %+v", err) + err = nil + } + if len(ti.tracked) == 0 { + klog.V(2).Infof("goexec.Untrack: nothing else to track, closing watcher") + err = ti.watcher.Close() + if err != nil { + klog.V(2).Infof("goexec.Untrack failed to close watcher: %+v", err) + err = nil + } + ti.watcher = nil + } + return +} + +func (s *State) ListTracked() []string { + s.trackingInfo.mu.Lock() + defer s.trackingInfo.mu.Unlock() + return common.SortedKeys(s.trackingInfo.tracked) +} + +// isGoRelated checks whether a file is Go related. +func isGoRelated(fileOrDirPath string) bool { + base := path.Base(fileOrDirPath) + switch base { + case "go.mod", "go.sum", "go.work": + return true + default: + if strings.HasSuffix(base, "_test.go") { + return false + } + if strings.HasSuffix(base, ".go") { + return true + } + } + return false +} + +// EnumerateUpdatedFiles calls fn for each file that has been updated since +// the last call. If `fn` returns an error, then the enumerations is interrupted and +// the error is returned. +func (s *State) EnumerateUpdatedFiles(fn func(filePath string) error) (err error) { + s.trackingInfo.mu.Lock() + defer s.trackingInfo.mu.Unlock() + + files := common.SortedKeys(s.trackingInfo.updated) + for _, filePath := range files { + s.trackingInfo.updated.Delete(filePath) + err = fn(filePath) + if err != nil { + return + } + } + return +} + +// AutoTrack adds automatic tracked directories. It looks at go.mod for +// redirects to the local filesystem. +// TODO: add support for go.work as well. +func (s *State) AutoTrack() (err error) { + ti := s.trackingInfo + goModPath := path.Join(s.TempDir, "go.mod") + fileInfo, err := os.Stat(goModPath) + if err != nil { + if os.IsNotExist(err) { + // No go.mod, we dont' auto-track anything. + err = nil + return + } + err = errors.Wrapf(err, "failed to check %q for auto-tracking of files", goModPath) + return + } + if !fileInfo.ModTime().After(ti.goModModTime) { + // No changes. + return + } + + ti.goModModTime = fileInfo.ModTime() + klog.V(2).Infof("goexec.AutoTrack: re-parsing %q for changes at %s", goModPath, ti.goModModTime) + contents, err := os.ReadFile(goModPath) + if err != nil { + err = errors.Wrapf(err, "failed to read %q for auto-tracking of files", goModPath) + return + } + matches := regexpGoModReplace.FindAllSubmatch(contents, -1) + for _, match := range matches { + replaceTarget := string(match[1]) + if replaceTarget[0] != '/' { + // We only auto-track if the target of the replace is a local directory. + continue + } + _, found := ti.tracked[replaceTarget] + if found { + // already tracked. + continue + } + klog.Infof("- go.mod new replace: %s", replaceTarget) + err = s.Track(replaceTarget) + + // Because fsnotify doesn't support recursion in watching for changes in subdirectories, + // we need to add each subdirectory under the one defined. + err = filepath.WalkDir(replaceTarget, func(entryPath string, d fs.DirEntry, err error) error { + if err != nil { + return errors.Wrapf(err, "failed to auto-track file under directory %q", replaceTarget) + } + if !isGoRelated(entryPath) { + return nil + } + + // Only track directories that have go files. Notice repeated tracked directories + // are quickly ignored. + dir := path.Dir(entryPath) + return s.Track(dir) + }) + if err != nil { + klog.Errorf("Failed to auto-track subdirectories of %q: %+v", replaceTarget, err) + err = nil + } + } + return +} + +var ( + // `(?m)` makes "^" and "$" match beginning and end of line. + regexpGoModReplace = regexp.MustCompile(`(?m)^\s*replace\s+.*?=>\s+(.*)$`) +) diff --git a/specialcmd/specialcmd.go b/specialcmd/specialcmd.go index 4fa67d2..d1475e5 100644 --- a/specialcmd/specialcmd.go +++ b/specialcmd/specialcmd.go @@ -12,6 +12,7 @@ import ( "github.com/janpfeifer/gonb/goexec" "github.com/janpfeifer/gonb/kernel" "github.com/pkg/errors" + "k8s.io/klog/v2" "log" "os" ) @@ -78,6 +79,15 @@ Executing shell commands: the notebook is created and maintained. Useful for manipulating "go.mod", for instance to get a package from some specific version, something like "!*go get github.com/my/package@v3". + +Tracking of Go files being develped: + +- "%track [file_or_directory]": add file or directory to list of tracked files, + which are monitored by GoNB (and 'gopls') for auto-complete or contextual help. + If no file is given, it lists the currently tracked files. +- "%untrack [file_or_directory][...]": remove file or directory from list of tracked files. + If suffixed with "..." it will remove all files prefixed with the string given (without the + "..."). If no file is given, it lists the currently tracked files. ` // cellStatus holds temporary status for the execution of the current cell. @@ -122,6 +132,12 @@ func Parse(msg kernel.Message, goExec *goexec.State, execute bool, codeLines []s if err != nil { return } + + // Runs AutoTrack, in case go.mod has changed. + err = goExec.AutoTrack() + if err != nil { + klog.Errorf("goxec.AutoTrack failed: %+v", err) + } } } } @@ -190,6 +206,10 @@ func execInternal(msg kernel.Message, goExec *goexec.State, cmdStr string, statu return errors.Errorf("%%with_password not available in this notebook, it doesn't allow input prompting") } status.withPassword = true + case "track": + execTrack(msg, goExec, parts[1:]) + case "untrack": + execUntrack(msg, goExec, parts[1:]) default: err := kernel.PublishWriteStream(msg, kernel.StreamStderr, fmt.Sprintf("\"%%%s\" unknown or not implemented yet.", parts[0])) if err != nil { diff --git a/specialcmd/track.go b/specialcmd/track.go new file mode 100644 index 0000000..73d8152 --- /dev/null +++ b/specialcmd/track.go @@ -0,0 +1,73 @@ +package specialcmd + +import ( + "fmt" + "github.com/janpfeifer/gonb/goexec" + "github.com/janpfeifer/gonb/kernel" + "k8s.io/klog/v2" + "strings" +) + +// execTrack executes the "%track" special command. The parameter `args` excludes +// "%track". +func execTrack(msg kernel.Message, goExec *goexec.State, args []string) { + if len(args) == 0 { + showTrackedList(msg, goExec) + return + } + for _, fileOrDirPath := range args { + err := goExec.Track(fileOrDirPath) + if err != nil { + err = kernel.PublishWriteStream(msg, kernel.StreamStderr, err.Error()+"\n") + } else { + err = kernel.PublishWriteStream(msg, kernel.StreamStdout, + fmt.Sprintf("\tTracking %q\n", fileOrDirPath)) + } + if err != nil { + klog.Errorf("Failed to publish to Jupyter: %+v", err) + return + } + } +} + +// execUntrack executes the "%track" special command. The parameter `args` excludes +// "%untrack". +func execUntrack(msg kernel.Message, goExec *goexec.State, args []string) { + if len(args) == 0 { + showTrackedList(msg, goExec) + return + } + for _, fileOrDirPath := range args { + err := goExec.Untrack(fileOrDirPath) + if err != nil { + err = kernel.PublishWriteStream(msg, kernel.StreamStderr, err.Error()+"\n") + } else { + err = kernel.PublishWriteStream(msg, kernel.StreamStdout, + fmt.Sprintf("\tUntracked %q\n", fileOrDirPath)) + } + if err != nil { + klog.Errorf("Failed to publish to Jupyter: %+v", err) + return + } + } + +} + +func showTrackedList(msg kernel.Message, goExec *goexec.State) { + tracked := goExec.ListTracked() + htmlParts := make([]string, 0, len(tracked)+5) + if len(tracked) == 0 { + htmlParts = append(htmlParts, "No files or directory being tracked yet") + } else { + htmlParts = append(htmlParts, "

List of files/directories being tracked:

") + htmlParts = append(htmlParts, "") + } + err := kernel.PublishDisplayDataWithHTML(msg, strings.Join(htmlParts, "\n")) + if err != nil { + klog.Errorf("Failed to publish %track results back to jupyter: %+v", err) + } +}