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": [
- "
"
+ ""
]
},
"metadata": {},
@@ -668,7 +668,7 @@
{
"data": {
"text/html": [
- ""
+ ""
]
},
"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",
+ "\n",
+ "- /home/janpf/Projects/gonb
\n",
+ "- /home/janpf/Projects/gonb/cache
\n",
+ "- /home/janpf/Projects/gonb/common
\n",
+ "- /home/janpf/Projects/gonb/dispatcher
\n",
+ "- /home/janpf/Projects/gonb/goexec
\n",
+ "- /home/janpf/Projects/gonb/goexec/goplsclient
\n",
+ "- /home/janpf/Projects/gonb/gonbui
\n",
+ "- /home/janpf/Projects/gonb/gonbui/protocol
\n",
+ "- /home/janpf/Projects/gonb/kernel
\n",
+ "- /home/janpf/Projects/gonb/specialcmd
\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, "")
+ for _, p := range tracked {
+ htmlParts = append(htmlParts, "- "+p+"
")
+ }
+ 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)
+ }
+}