nbview.Nu asks nu --ide-ast, --ide-check and --ide-complete about the cell being written, once typing pauses, with a prelude declaring the notebook's variables and the cell's name and sheet ranges masked, so offsets map back. Questions still out when the text changes are stopped; the tokenizer's draft draws until nu answers, and the edited text keeps nu's roles where it's unchanged. nu is asked only where the cells could run without asking, and not again once it's missing, old or slow. The context line shows a problem when the caret is on it.
- commit
- 199013d3aaa541389c23548b51a7778e7e78e141
- parent
- 2f398c00210ca85e3ec0a00af1bc67ab85641d4a
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.2 of 3, Jev's estimate
| M | internal/ | 2 | +- |
| A | internal/ | 83 | +++++++ |
| A | internal/ | 88 | +++++++ |
| M | internal/ | 14 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 183 | ++++++--------- |
| A | internal/ | 173 | ++++++++++++++ |
| M | internal/ | 2 | + |
| A | internal/ | 219 | ++++++++++++++++++ |
| A | internal/ | 367 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 146 | ++++++++++++ |
| A | internal/ | 5 | + |
| A | internal/ | 4 | + |
| A | internal/ | 1 | + |
| A | internal/ | 4 | + |
| A | internal/ | 1 | + |
| A | internal/ | 4 | + |
| A | internal/ | 1 | + |
| A | internal/ | 5 | + |
| M | internal/ | 4 | +- |
| M | internal/ | 27 | +-- |
| M | internal/ | 3 | + |
@@ -124,7 +124,7 @@ func (m *Model) openFile(text string) tea.Cmd {
124124 // state, served directory and the JEV connection.125125 func (m *Model) reset(s *sheet.Sheet, filename string) {126126 m.stopCells() // the old workbook's cells127- nb := nbState{runner: m.nb.runner, served: m.nb.served, words: m.nb.words, asked: m.nb.asked}127+ nb := nbState{runner: m.nb.runner, served: m.nb.served, words: m.nb.words, asked: m.nb.asked, lang: m.nb.lang}128128 defer func() { m.nb = nb; m.bookOpened() }()129129 *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs, session: m.session, pipe: m.pipe,130130 macros: macroState{machine: m.macros.machine, editor: m.macros.editor}, spans: m.spans}@@ -0,0 +1,83 @@
1+package ui2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+ "github.com/FelineStateMachine/012/internal/ui/nbview"8+)9+10+// What the code editor knows of nushell: nu itself, asked about the11+// cell being written (nbview.Nu), with the notebook's own words. nu is12+// asked only where a cell could run without asking: not in 012 serve13+// unless serve-shell allows it, not with shell = off, and not about a14+// file from another computer until its cells are trusted. Otherwise,15+// and while nu is missing, old or slow, the built-ins answer.16+17+// nbLangKey is what a notebook's words were worked out from.18+type nbLangKey struct {19+ s *sheet.Sheet20+ state int // the workbook's state in its history21+ words int // how many of nu's command names were known22+}23+24+// nbLang is the session's questions to nu.25+func (m *Model) nbLang() *nbview.NuSession {26+ if m.nb.lang == nil {27+ m.nb.lang = nbview.NewNuSession(m.runner())28+ }29+ return m.nb.lang30+}31+32+// nbMayAsk reports whether nu may be asked about the cells: they could33+// run without asking.34+func (m *Model) nbMayAsk() bool {35+ if m.shellOff() != "" {36+ return false37+ }38+ return m.configString("shell", "ask") == "on" || m.macroTrusted() || !m.nb.fromFile && len(m.book().Macros()) == 039+}40+41+// syncLang tells nu's questions about notebook s's view v whether they42+// may be asked, and the notebook's words once they've changed.43+func (m *Model) syncLang(s *sheet.Sheet, v *nbview.View) {44+ m.nbLang().SetOn(m.nbMayAsk())45+ lang, ok := v.Providers.Completer.(*nbview.Nu)46+ key := nbLangKey{s, m.book().StateID(), len(m.nb.words)}47+ if !ok || key == m.nb.langKey {48+ return49+ }50+ m.nb.langKey = key51+ words := m.nbWords(s)52+ var names []string53+ for _, w := range words {54+ if name, ok := strings.CutPrefix(w.Text, "$"); ok && name != "selection" && name != "sheet." {55+ names = append(names, name)56+ }57+ }58+ lang.SetWords(names, words)59+}60+61+// nbWords are the completions of notebook s's cells: its names, the62+// workbook's regions, and nu's commands once they're known.63+func (m *Model) nbWords(s *sheet.Sheet) []nbview.Word {64+ var out []nbview.Word65+ for _, c := range s.NotebookCells() {66+ if n := c.Name(); n != "" {67+ out = append(out, nbview.Word{Text: "$" + n, Desc: "a cell's output"})68+ }69+ }70+ for _, t := range m.book().Sheets() {71+ for _, r := range t.Regions() {72+ if r.Linked() {73+ out = append(out, nbview.Word{Text: "$" + r.Name, Desc: "linked file " + r.File.Path})74+ }75+ }76+ }77+ out = append(out, nbview.Word{Text: "$selection", Desc: "the selection on the sheet shown last"},78+ nbview.Word{Text: "$sheet.", Desc: "a range of a sheet: $sheet.A1:C9"})79+ for _, c := range m.nb.words {80+ out = append(out, nbview.Word{Text: c, Desc: "command"})81+ }82+ return out83+}@@ -0,0 +1,88 @@
1+package ui2+3+import (4+ "bytes"5+ "context"6+ "io"7+ "slices"8+ "strings"9+ "testing"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/nushell"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// ideNu is fakeNu answering the code editor's questions too, as nu18+// 0.116 would about any pipeline, and noting them.19+type ideNu struct {20+ *fakeNu21+ ide []string22+}23+24+func (f *ideNu) Run(ctx context.Context, job nushell.Job, script string, stdout io.Writer) error {25+ if len(job.IDE) == 0 {26+ return f.fakeNu.Run(ctx, job, script, stdout)27+ }28+ f.ide = append(f.ide, job.IDE[0])29+ answer := map[string]string{"--version": "0.116.0", "--ide-ast": "[]", "--ide-complete": `{"completions": []}`}[job.IDE[0]]30+ _, err := io.WriteString(stdout, answer)31+ return err32+}33+34+// asked are the questions nu was asked since the last call.35+func (f *ideNu) asked() []string {36+ out := f.ide37+ f.ide = nil38+ return out39+}40+41+// Writing a cell asks nu about it once typing pauses; a file from42+// another computer isn't asked about until its cells are trusted, and43+// 012 serve asks nu only as serve-shell allows.44+func TestNotebookAsksNuAsTrusted(t *testing.T) {45+ nu := &ideNu{fakeNu: &fakeNu{out: map[string]string{"ls": lsOut}}}46+ m := newModel()47+ m.SetShellRunner(nu)48+ m.SetMachine("here")49+ m.nb.still = true50+ run(m, m.runCommand("nb.open"))51+ press(t, m, "b", "<enter>", "l")52+ if got := nu.asked(); !slices.Contains(got, "--ide-ast") || !slices.Contains(got, "--ide-check") {53+ t.Fatalf("a notebook made here: asked %v", got)54+ }55+ press(t, m, "<esc>")56+57+ var b bytes.Buffer58+ if err := m.book().Write(&b); err != nil {59+ t.Fatal(err)60+ }61+ s, err := sheet.Read(strings.NewReader(strings.Replace(b.String(), `"macroOrigin": "here"`, `"macroOrigin": "elsewhere"`, 1)))62+ if err != nil {63+ t.Fatal(err)64+ }65+ m2 := New(s, "")66+ m2.Update(tea.WindowSizeMsg{Width: 80, Height: 20})67+ m2.SetShellRunner(nu)68+ m2.SetMachine("here")69+ m2.nb.still = true70+ run(m2, m2.runCommand("nb.open"))71+ press(t, m2, "<enter>", "s")72+ if got := nu.asked(); len(got) != 0 {73+ t.Errorf("a file from elsewhere, not trusted: asked %v", got)74+ }75+ press(t, m2, "<esc>")76+ run(m2, m2.runCommand("nb.run_all"))77+ press(t, m2, "<enter>") // Run this file's notebook cells? Run78+ press(t, m2, "<enter>", "s")79+ if got := nu.asked(); !slices.Contains(got, "--ide-ast") {80+ t.Errorf("trusted: asked %v", got)81+ }82+ press(t, m2, "<esc>")83+ m2.nb.served = true84+ press(t, m2, "<enter>", "s")85+ if got := nu.asked(); len(got) != 0 {86+ t.Errorf("served without serve-shell: asked %v", got)87+ }88+}@@ -57,6 +57,10 @@ type nbState struct {
5757 // saved is the outputs' change count when the file was last saved,5858 // and saving when it was being saved.5959 saved, saving int60+ // lang asks nu about the cells being written (nblang.go), and61+ // langKey is what the words it was last given were worked out from.62+ lang *nbview.NuSession63+ langKey nbLangKey6064 }6165 6266 // nbQueued is a cell waiting to run.@@ -89,8 +93,14 @@ type (
8993 nbTickMsg struct{ gen int }9094 )9195 92-// SetShellRunner makes cells run with r rather than nu, for tests.93-func (m *Model) SetShellRunner(r nushell.Runner) { m.nb.runner = r }96+// SetShellRunner makes cells run, and the code editor ask, with r97+// rather than nu, for tests; it's set before anything is asked.98+func (m *Model) SetShellRunner(r nushell.Runner) {99+ m.nb.runner = r100+ if m.nb.lang != nil {101+ m.nb.lang.Runner = r102+ }103+}94104 95105 func (m *Model) runner() nushell.Runner {96106 if m.nb.runner == nil {@@ -236,13 +236,15 @@ func (m *Model) notebookMsg(msg tea.Msg) (tea.Cmd, bool) {
236236 }237237 238238 // notebookSync is what follows any message: outputs sent again where239-// they're stale, and the highlighter asked about what was drawn.239+// they're stale, the language told what the notebook binds, and the240+// highlighter asked about what was drawn.240241 func (m *Model) notebookSync() tea.Cmd {241242 m.syncOutputs()242243 if m.book().OutputsChanged() != m.nb.saved {243244 m.changed = true244245 }245246 if v := m.nbView(); v != nil {247+ m.syncLang(m.sheet, v)246248 return v.Fetch()247249 }248250 return nil@@ -8,7 +8,6 @@ import (
88 tea "charm.land/bubbletea/v2"99 "github.com/charmbracelet/x/ansi"1010 11- "github.com/FelineStateMachine/012/internal/notebook"1211 "github.com/FelineStateMachine/012/internal/ui/lineedit"1312 "github.com/FelineStateMachine/012/internal/ui/theme"1413 )@@ -32,6 +31,7 @@ type editor struct {
3231 diagFor string // the text the diagnostics are of3332 comp []Completion3433 compSel int34+ stop context.CancelFunc // stops the questions about the text still out3535 }3636 3737 func (e *editor) text() string { return e.area.Text() }@@ -49,11 +49,22 @@ func (e *editor) line(th *theme.Theme, r, width int, roles []int8) string {
4949 if r >= len(rows) {5050 return ""5151 }52- var marks []bool53- if e.diagFor == e.text() && len(e.diags) > 0 {54- marks = diagMarks(e.text(), e.diags)52+ return drawRow(th, e.area.Buf, rows[r], roles, e.marks())53+}54+55+// marks are the runes the diagnostics underline: those of the text they56+// were of, where the text is unchanged since, so the underline doesn't57+// blink off as keys are typed.58+func (e *editor) marks() []bool {59+ if len(e.diags) == 0 {60+ return nil5561 }56- return drawRow(th, e.area.Buf, rows[r], roles, marks)62+ text := e.text()63+ marks := diagMarks(e.diagFor, e.diags)64+ if e.diagFor == text {65+ return marks66+ }67+ return blend(e.diagFor, text, marks, make([]bool, len(e.area.Buf)))5768 }5869 5970 // diagMarks marks the runes a diagnostic covers.@@ -110,76 +121,15 @@ func roleAt(roles []int8, i int) int8 {
110121 111122 func markAt(marks []bool, i int) bool { return i < len(marks) && marks[i] }112123 113-// syntaxCache keeps what the highlighter said of each source, as a role114-// per rune, and which sources it has been asked about.115-type syntaxCache struct {116- spans map[string][]Span117- roles map[string][]int8118- asked map[string]bool119- want []string // sources drawn without an answer yet120-}121-122-// Instant is a highlighter quick enough to ask while drawing, as the123-// built-in tokenizer is.124-type Instant interface{ Instant() bool }125-126-// Instant implements Instant: the tokenizer is.127-func (Tokens) Instant() bool { return true }128-129-// spansFor is src's roles, a role per rune, or nil until the130-// highlighter has answered.131-func (v *View) spansFor(src string, kind notebook.Kind) []int8 {132- hl := v.Providers.Highlighter133- if kind != notebook.Code || hl == nil {134- return nil135- }136- c := &v.syntax137- if roles, ok := c.roles[src]; ok {138- return roles139- }140- if in, ok := hl.(Instant); ok && in.Instant() {141- v.keepSpans(src, hl.Highlight(context.Background(), src))142- return c.roles[src]143- }144- if c.asked == nil {145- c.asked = map[string]bool{}146- }147- if !c.asked[src] {148- c.asked[src] = true149- c.want = append(c.want, src)150- }151- return nil152-}153-154-// keepSpans keeps what the highlighter said of src.155-func (v *View) keepSpans(src string, spans []Span) {156- c := &v.syntax157- if c.roles == nil || len(c.roles) > 512 {158- c.roles, c.spans = map[string][]int8{}, map[string][]Span{}159- }160- c.spans[src] = spans161- roles := make([]int8, len([]rune(src)))162- for i := range roles {163- roles[i] = -1164- }165- ri := 0166- for bi := range src {167- for _, s := range spans {168- if bi >= s.From && bi < s.To {169- roles[ri] = int8(s.Kind)170- }171- }172- ri++173- }174- c.roles[src] = roles175-}176-177124 // Messages the view sends itself.178125 type (179- // highlightMsg is the highlighter's answer about src.126+ // highlightMsg is the highlighter's answer about src; edited says127+ // src was the text being edited.180128 highlightMsg struct {181- src string182- spans []Span129+ view *View130+ src string131+ spans []Span132+ edited bool183133 }184134 // pausedMsg is typing having paused at version.185135 pausedMsg struct {@@ -200,40 +150,35 @@ type (
200150 }201151 )202152 203-// Fetch asks the highlighter about the sources drawn without an answer,204-// in the background.205-func (v *View) Fetch() tea.Cmd {206- hl := v.Providers.Highlighter207- want := v.syntax.want208- v.syntax.want = nil209- if hl == nil || len(want) == 0 {210- return nil211- }212- cmds := make([]tea.Cmd, len(want))213- for i, src := range want {214- cmds[i] = func() tea.Msg { return highlightMsg{src: src, spans: hl.Highlight(context.Background(), src)} }215- }216- return tea.Batch(cmds...)217-}218-219153 // Update takes the view's own messages, reporting whether msg was one.220154 func (v *View) Update(msg tea.Msg) (tea.Cmd, bool) {221155 switch msg := msg.(type) {222156 case highlightMsg:157+ if msg.view != v {158+ return nil, false159+ }223160 v.keepSpans(msg.src, msg.spans)224- if v.syntax.asked != nil {225- delete(v.syntax.asked, msg.src)161+ delete(v.syntax.asked, msg.src)162+ if msg.edited {163+ v.syntax.last = msg.src226164 }227165 case pausedMsg:228- if msg.view == v && v.edit.on && msg.version == v.edit.version {166+ if msg.view != v {167+ return nil, false168+ }169+ if v.edit.on && msg.version == v.edit.version {229170 return v.check(), true230171 }231172 case checkedMsg:232- if msg.view == v {233- v.edit.diags, v.edit.diagFor = msg.diags, msg.src173+ if msg.view != v {174+ return nil, false234175 }176+ v.edit.diags, v.edit.diagFor = msg.diags, msg.src235177 case completedMsg:236- if msg.view == v && v.edit.on && msg.version == v.edit.version {178+ if msg.view != v {179+ return nil, false180+ }181+ if v.edit.on && msg.version == v.edit.version {237182 v.edit.comp, v.edit.compSel = msg.comp, 0238183 if len(msg.comp) == 1 {239184 v.complete()@@ -245,32 +190,54 @@ func (v *View) Update(msg tea.Msg) (tea.Cmd, bool) {
245190 return nil, true246191 }247192 248-// changed notes that the text changed: the language is asked about it249-// once typing pauses.193+// changed notes that the text changed: what the language was asked194+// about the text before is stale, and it's asked about this text once195+// typing pauses.250196 func (v *View) changed() tea.Cmd {251197 v.edit.version++252198 v.edit.comp = nil253- if v.Providers.Checker == nil && (v.Providers.Highlighter == nil || isInstant(v.Providers.Highlighter)) {199+ v.edit.cancel()200+ if !v.asksLater() {254201 return nil255202 }256203 version := v.edit.version257204 return tea.Tick(pause, func(time.Time) tea.Msg { return pausedMsg{view: v, version: version} })258205 }259206 260-func isInstant(hl Highlighter) bool {261- in, ok := hl.(Instant)262- return ok && in.Instant()207+// cancel stops the questions about the text being edited that are208+// still out.209+func (e *editor) cancel() {210+ if e.stop != nil {211+ e.stop()212+ e.stop = nil213+ }263214 }264215 265-// check asks the highlighter and checker about the text being edited.216+// check asks the highlighter and checker about the text being edited,217+// in the background; answers that come once it has changed are dropped.266218 func (v *View) check() tea.Cmd {267219 src := v.edit.text()220+ v.edit.cancel()221+ ctx, stop := context.WithCancel(context.Background())222+ v.edit.stop = stop268223 var cmds []tea.Cmd269224 if hl := v.Providers.Highlighter; hl != nil && !isInstant(hl) {270- cmds = append(cmds, func() tea.Msg { return highlightMsg{src: src, spans: hl.Highlight(context.Background(), src)} })271- }272- if ck := v.Providers.Checker; ck != nil {273- cmds = append(cmds, func() tea.Msg { return checkedMsg{view: v, src: src, diags: ck.Check(context.Background(), src)} })225+ cmds = append(cmds, func() tea.Msg {226+ spans := hl.Highlight(ctx, src)227+ if ctx.Err() != nil {228+ return nil229+ }230+ return highlightMsg{view: v, src: src, spans: spans, edited: true}231+ })232+ }233+ if ck := v.Providers.Checker; ck != nil && !isInstant(ck) {234+ cmds = append(cmds, func() tea.Msg {235+ diags := ck.Check(ctx, src)236+ if ctx.Err() != nil {237+ return nil238+ }239+ return checkedMsg{view: v, src: src, diags: diags}240+ })274241 }275242 return tea.Batch(cmds...)276243 }@@ -305,7 +272,8 @@ func (v *View) complete() {
305272 e.version++306273 }307274 308-// Diagnostic is the problem at the caret, for the context line.275+// Diagnostic is the problem the caret is on, for the context line, or276+// "" when it is on none.309277 func (v *View) Diagnostic() string {310278 e := &v.edit311279 if !e.on || e.diagFor != e.text() {@@ -317,9 +285,6 @@ func (v *View) Diagnostic() string {
317285 return d.Msg318286 }319287 }320- if len(e.diags) > 0 {321- return e.diags[0].Msg322- }323288 return ""324289 }325290 @@ -0,0 +1,173 @@
1+package nbview2+3+import (4+ "context"5+ "slices"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/notebook"10+)11+12+// Highlighting: what the highlighter said of each source, kept as a13+// role per rune. A slow highlighter (nu) is asked in the background:14+// about the cells drawn, as they're drawn, and about the cell being15+// edited once typing pauses, never per key. Until it answers, the code16+// shows its draft (the tokenizer's roles), and the cell being edited17+// keeps the answer about its last text where the text is unchanged, so18+// nothing flickers between keys.19+20+// syntaxCache keeps what the highlighter said of each source, as a role21+// per rune, and which sources it has been asked about.22+type syntaxCache struct {23+ spans map[string][]Span24+ roles map[string][]int825+ drafts map[string][]int826+ asked map[string]bool27+ want []string // sources drawn without an answer yet28+ last string // the edited text last answered about29+}30+31+// Instant is a highlighter quick enough to ask while drawing, as the32+// built-in tokenizer is.33+type Instant interface{ Instant() bool }34+35+// Instant implements Instant: the tokenizer is.36+func (Tokens) Instant() bool { return true }37+38+// Drafter is a slow highlighter with a quick draft, drawn until its39+// answer comes, and in place of one while it's Instant.40+type Drafter interface{ Draft(src string) []Span }41+42+// isInstant reports whether a highlighter or checker answers at once43+// (or, a checker, not at all), so it needn't wait for typing to pause.44+func isInstant(p any) bool {45+ in, ok := p.(Instant)46+ return ok && in.Instant()47+}48+49+// asksLater reports whether the language is asked once typing pauses:50+// its highlighter or checker is slow.51+func (v *View) asksLater() bool {52+ hl, ck := v.Providers.Highlighter, v.Providers.Checker53+ return hl != nil && !isInstant(hl) || ck != nil && !isInstant(ck)54+}55+56+// spansFor is src's roles, a role per rune, or nil until the57+// highlighter has answered or drafted. The cell being edited (edited)58+// isn't asked about here, but once typing pauses.59+func (v *View) spansFor(src string, kind notebook.Kind, edited bool) []int8 {60+ hl := v.Providers.Highlighter61+ if kind != notebook.Code || hl == nil {62+ return nil63+ }64+ c := &v.syntax65+ if roles, ok := c.roles[src]; ok {66+ return roles67+ }68+ d, drafts := hl.(Drafter)69+ if isInstant(hl) && !drafts {70+ v.keepSpans(src, hl.Highlight(context.Background(), src))71+ return c.roles[src]72+ }73+ if !edited && !isInstant(hl) {74+ c.ask(src)75+ }76+ if !drafts {77+ return nil78+ }79+ draft := c.draft(src, d)80+ if was, ok := c.roles[c.last]; ok && edited {81+ return blend(c.last, src, was, draft)82+ }83+ return draft84+}85+86+// ask notes that src is drawn without an answer, for Fetch.87+func (c *syntaxCache) ask(src string) {88+ if c.asked == nil {89+ c.asked = map[string]bool{}90+ }91+ if !c.asked[src] {92+ c.asked[src] = true93+ c.want = append(c.want, src)94+ }95+}96+97+// draft is d's roles for src, kept.98+func (c *syntaxCache) draft(src string, d Drafter) []int8 {99+ if roles, ok := c.drafts[src]; ok {100+ return roles101+ }102+ if c.drafts == nil || len(c.drafts) > 512 {103+ c.drafts = map[string][]int8{}104+ }105+ roles := rolesOf(src, d.Draft(src))106+ c.drafts[src] = roles107+ return roles108+}109+110+// keepSpans keeps what the highlighter said of src.111+func (v *View) keepSpans(src string, spans []Span) {112+ c := &v.syntax113+ if c.roles == nil || len(c.roles) > 512 {114+ c.roles, c.spans = map[string][]int8{}, map[string][]Span{}115+ }116+ c.spans[src] = spans117+ c.roles[src] = rolesOf(src, spans)118+}119+120+// rolesOf is a role per rune of src, -1 for none, from spans; a later121+// span overrides an earlier one.122+func rolesOf(src string, spans []Span) []int8 {123+ roles := make([]int8, 0, len(src))124+ for bi := range src {125+ r := int8(-1)126+ for _, s := range spans {127+ if bi >= s.From && bi < s.To {128+ r = int8(s.Kind)129+ }130+ }131+ roles = append(roles, r)132+ }133+ return roles134+}135+136+// blend is src's values: was's (of old) where src keeps old's runes,137+// its start and its end, and draft's in what changed between.138+func blend[T any](old, src string, was, draft []T) []T {139+ o, n := []rune(old), []rune(src)140+ if len(was) != len(o) || len(draft) != len(n) {141+ return draft142+ }143+ p := 0144+ for p < len(o) && p < len(n) && o[p] == n[p] {145+ p++146+ }147+ s := 0148+ for s < len(o)-p && s < len(n)-p && o[len(o)-1-s] == n[len(n)-1-s] {149+ s++150+ }151+ out := slices.Clone(draft)152+ copy(out, was[:p])153+ copy(out[len(n)-s:], was[len(o)-s:])154+ return out155+}156+157+// Fetch asks the highlighter about the sources drawn without an answer,158+// in the background.159+func (v *View) Fetch() tea.Cmd {160+ hl := v.Providers.Highlighter161+ want := v.syntax.want162+ v.syntax.want = nil163+ if hl == nil || len(want) == 0 {164+ return nil165+ }166+ cmds := make([]tea.Cmd, len(want))167+ for i, src := range want {168+ cmds[i] = func() tea.Msg {169+ return highlightMsg{view: v, src: src, spans: hl.Highlight(context.Background(), src)}170+ }171+ }172+ return tea.Batch(cmds...)173+}@@ -186,6 +186,7 @@ func (v *View) StartEdit() tea.Cmd {
186186 return nil187187 }188188 v.full, v.onOut = nil, false189+ v.edit.cancel()189190 v.edit = editor{on: true, id: c.ID}190191 v.edit.area.SetText(c.Source)191192 v.follow()@@ -198,6 +199,7 @@ func (v *View) StopEdit() {
198199 return199200 }200201 id, text := v.edit.id, v.edit.text()202+ v.edit.cancel()201203 v.edit = editor{}202204 for _, c := range v.h.Cells() {203205 if c.ID == id && c.Source != text {@@ -0,0 +1,219 @@
1+package nbview2+3+import (4+ "context"5+ "errors"6+ "slices"7+ "strings"8+ "sync"9+ "sync/atomic"10+ "time"11+12+ "github.com/FelineStateMachine/012/internal/nushell"13+)14+15+// NuSession is what a session's questions to nu share: whether nu may16+// be asked, whether it answers, and how many questions are out. Each17+// question is a process of its own, asked in the background once18+// typing pauses, at most two at a time, stopped when the text it's19+// about is stale and after Timeout. While nu can't be asked (SetOn said20+// no, it isn't installed, it's older than nushell.MinVersion, or it21+// timed out three times in a row) the notebook's Nu answers as the22+// built-ins do, and nu isn't started.23+type NuSession struct {24+ Runner nushell.Runner25+ // Timeout bounds each question; 0 is a second and a half.26+ Timeout time.Duration27+28+ on atomic.Bool29+ off atomic.Bool // nu can't be asked this session30+ slow atomic.Int32 // questions timed out in a row31+ mu sync.Mutex32+ checked bool // nu's version was asked33+ sem chan struct{}34+}35+36+// NewNuSession returns a session asking nu through r, once SetOn allows37+// it.38+func NewNuSession(r nushell.Runner) *NuSession {39+ return &NuSession{Runner: r, sem: make(chan struct{}, 2)}40+}41+42+// SetOn says whether nu may be asked: in 012 serve only as serve-shell43+// allows, and only about a notebook whose cells may run here without44+// asking (docs/nushell/notebooks.md#trust).45+func (s *NuSession) SetOn(on bool) { s.on.Store(on) }46+47+// Asking reports whether nu is asked: allowed, and not found missing,48+// old or slow.49+func (s *NuSession) Asking() bool { return s.on.Load() && !s.off.Load() }50+51+// slowAfter is how many questions in a row may time out before nu52+// isn't asked again this session.53+const slowAfter = 354+55+var errNotAsked = errors.New("nu isn't asked")56+57+// ask asks nu with f, within the timeout, once nu is known to answer.58+func (s *NuSession) ask(ctx context.Context, f func(context.Context) error) error {59+ if !s.Asking() {60+ return errNotAsked61+ }62+ timeout := s.Timeout63+ if timeout <= 0 {64+ timeout = 1500 * time.Millisecond65+ }66+ select {67+ case s.sem <- struct{}{}:68+ defer func() { <-s.sem }()69+ case <-ctx.Done():70+ return ctx.Err()71+ }72+ qctx, cancel := context.WithTimeout(ctx, timeout)73+ defer cancel()74+ if !s.versionOK(qctx) {75+ return errNotAsked76+ }77+ err := f(qctx)78+ switch {79+ case err == nil:80+ s.slow.Store(0)81+ case ctx.Err() != nil: // stale: nobody waits for the answer82+ case errors.Is(err, context.DeadlineExceeded):83+ if s.slow.Add(1) >= slowAfter {84+ s.off.Store(true)85+ }86+ case errors.Is(err, nushell.ErrMissing), errors.Is(err, nushell.ErrOld):87+ s.off.Store(true)88+ }89+ return err90+}91+92+// versionOK asks nu's version the first time, turning nu off for the93+// session if it's missing or too old.94+func (s *NuSession) versionOK(ctx context.Context) bool {95+ s.mu.Lock()96+ defer s.mu.Unlock()97+ if !s.checked {98+ _, err := nushell.Version(ctx, s.Runner)99+ if ctx.Err() != nil {100+ return false // asked again next time101+ }102+ s.checked = true103+ if err != nil {104+ s.off.Store(true)105+ }106+ }107+ return !s.off.Load()108+}109+110+// Nu is a notebook's language as nu itself knows it: highlighting from111+// its --ide-ast shapes, completions from --ide-complete after the112+// notebook's own words, and problems from --ide-check.113+//114+// nu doesn't know the variables a notebook binds ($sales, $selection,115+// $sheet.A1:C9) nor a cell's `name =`, so it's asked about the source116+// with those declared before it, in a prelude, and the cell's name and117+// ranges of sheets masked to as many bytes, so offsets map back by the118+// prelude's length alone (see prepare).119+type Nu struct {120+ s *NuSession121+ mu sync.Mutex122+ names []string123+ words []Word124+}125+126+// For returns a notebook's Nu, asking through the session.127+func (s *NuSession) For() *Nu { return &Nu{s: s} }128+129+// SetWords gives the notebook's names: the variables it binds besides130+// $selection (its cells' names and linked files'), and the built-in131+// completer's words. It's called where the notebook changes, so no132+// question reads the notebook itself.133+func (n *Nu) SetWords(names []string, words []Word) {134+ n.mu.Lock()135+ n.names, n.words = names, words136+ n.mu.Unlock()137+}138+139+func (n *Nu) known() ([]string, []Word) {140+ n.mu.Lock()141+ defer n.mu.Unlock()142+ return n.names, n.words143+}144+145+// Instant implements Instant: while nu isn't asked, the tokenizer146+// answers at once.147+func (n *Nu) Instant() bool { return !n.s.Asking() }148+149+// Draft implements Drafter: the tokenizer's answer, drawn until nu's150+// comes.151+func (n *Nu) Draft(src string) []Span { return Tokens{}.Highlight(context.Background(), src) }152+153+// Highlight implements Highlighter: nu's shapes, or the tokenizer's154+// when nu doesn't answer.155+func (n *Nu) Highlight(ctx context.Context, src string) []Span {156+ names, _ := n.known()157+ p := prepare(src, names)158+ var shapes []nushell.Shape159+ err := n.s.ask(ctx, func(ctx context.Context) (err error) {160+ shapes, err = nushell.AST(ctx, n.s.Runner, p.text)161+ return err162+ })163+ if err != nil {164+ return n.Draft(src)165+ }166+ return p.spans(src, shapes)167+}168+169+// maxProblems is the most problems nu's check reports.170+const maxProblems = 20171+172+// Check implements Checker: nu's problems with the pipeline, leaving173+// out those that only say it's unfinished at its end, as it is while174+// it's typed.175+func (n *Nu) Check(ctx context.Context, src string) []Diagnostic {176+ names, _ := n.known()177+ p := prepare(src, names)178+ var probs []nushell.Problem179+ err := n.s.ask(ctx, func(ctx context.Context) (err error) {180+ probs, err = nushell.Check(ctx, n.s.Runner, p.text, maxProblems)181+ return err182+ })183+ if err != nil {184+ return nil185+ }186+ end := len(strings.TrimRight(src, " \t\n"))187+ var out []Diagnostic188+ for _, pr := range probs {189+ from, to := pr.From-p.at, pr.To-p.at190+ if from < 0 || to > len(src) || from == to && from >= end {191+ continue192+ }193+ out = append(out, Diagnostic{From: from, To: to, Msg: pr.Msg})194+ }195+ return out196+}197+198+// Complete implements Completer: the notebook's words, then nu's that199+// they don't have.200+func (n *Nu) Complete(ctx context.Context, src string, offset int) []Completion {201+ offset = min(offset, len(src))202+ names, words := n.known()203+ out := Words(func() []Word { return words }).Complete(ctx, src, offset)204+ p := prepare(src, names)205+ var said []string206+ if n.s.ask(ctx, func(ctx context.Context) (err error) {207+ said, err = nushell.Complete(ctx, n.s.Runner, p.text, p.at+offset)208+ return err209+ }) != nil {210+ return out211+ }212+ for _, w := range said {213+ if strings.HasPrefix(w, "$__") || slices.ContainsFunc(out, func(c Completion) bool { return c.Text == w }) {214+ continue // the prelude's own, or a word the notebook has215+ }216+ out = append(out, Completion{Text: w, From: replaced(src, offset, w), To: offset})217+ }218+ return out219+}@@ -0,0 +1,367 @@
1+package nbview2+3+import (4+ "context"5+ "fmt"6+ "io"7+ "os"8+ "os/exec"9+ "path/filepath"10+ "slices"11+ "strings"12+ "sync"13+ "testing"14+ "time"15+16+ tea "charm.land/bubbletea/v2"17+18+ "github.com/FelineStateMachine/012/internal/nushell"19+ "github.com/FelineStateMachine/012/internal/ui/theme"20+)21+22+// testdata/nu holds what nu 0.116 said (the .json and .jsonl files)23+// about the text 012 asks it about a cell (the .nu beside each: the24+// prelude, then the source masked), so these tests pin both the text25+// asked and the answers read.26+27+// fakeRunner answers nu's IDE questions from recorded answers, by the28+// flag and the text asked about, and notes each question.29+type fakeRunner struct {30+ mu sync.Mutex31+ version string32+ answers map[string]string // flag + text asked about → answer33+ err error // every question fails so34+ block bool // questions wait until they're stopped35+ asked []string36+}37+38+func (f *fakeRunner) Run(ctx context.Context, job nushell.Job, script string, stdout io.Writer) error {39+ f.mu.Lock()40+ f.asked = append(f.asked, job.IDE[0])41+ f.mu.Unlock()42+ switch {43+ case f.err != nil:44+ return f.err45+ case job.IDE[0] == "--version":46+ _, err := io.WriteString(stdout, f.version)47+ return err48+ case f.block:49+ <-ctx.Done()50+ return context.Cause(ctx)51+ }52+ out, ok := f.answers[job.IDE[0]+script]53+ if !ok {54+ return &nushell.Error{Msg: "not recorded: " + script}55+ }56+ _, err := io.WriteString(stdout, out)57+ return err58+}59+60+func (f *fakeRunner) questions() []string {61+ f.mu.Lock()62+ defer f.mu.Unlock()63+ return slices.Clone(f.asked)64+}65+66+// recording is a question to nu and its answer, as testdata/nu keeps67+// them.68+type recording struct {69+ flags []string70+ script, answer string71+}72+73+// recordings are testdata/nu's questions and answers.74+func recordings(t *testing.T) []recording {75+ t.Helper()76+ read := func(name string) string {77+ data, err := os.ReadFile(filepath.Join("testdata", "nu", name))78+ if err != nil {79+ t.Fatal(err)80+ }81+ return string(data)82+ }83+ var out []recording84+ for _, r := range []struct{ name, answer, flag string }{85+ {"highlight", "highlight.json", "--ide-ast"},86+ {"sheet", "sheet.json", "--ide-ast"},87+ {"check", "check.jsonl", "--ide-check"},88+ {"complete", "complete.json", "--ide-complete"},89+ } {90+ script := read(r.name + ".nu")91+ flags := []string{r.flag}92+ switch r.flag {93+ case "--ide-check":94+ flags = append(flags, fmt.Sprint(maxProblems))95+ case "--ide-complete":96+ flags = append(flags, fmt.Sprint(len(script))) // the caret at the end97+ }98+ out = append(out, recording{flags: flags, script: script, answer: read(r.answer)})99+ }100+ return out101+}102+103+// recordedNu is a fake nu answering what testdata/nu records.104+func recordedNu(t *testing.T) *fakeRunner {105+ t.Helper()106+ f := &fakeRunner{version: "0.116.0\n", answers: map[string]string{}}107+ for _, r := range recordings(t) {108+ f.answers[r.flags[0]+r.script] = r.answer109+ }110+ return f111+}112+113+// nuFor is a notebook's Nu over r, allowed, knowing the cell $files.114+func nuFor(r nushell.Runner) (*NuSession, *Nu) {115+ s := NewNuSession(r)116+ s.SetOn(true)117+ n := s.For()118+ n.SetWords([]string{"files"}, []Word{{Text: "$files", Desc: "a cell's output"}, {Text: "sort-by", Desc: "command"}})119+ return s, n120+}121+122+var roleNames = [...]string{"command", "string", "variable", "number", "keyword", "operator", "comment"}123+124+func describe(src string, spans []Span) []string {125+ roles := rolesOf(src, spans)126+ var out []string127+ rs := []rune(src)128+ for i := 0; i < len(rs); {129+ j := i + 1130+ for j < len(rs) && roles[j] == roles[i] {131+ j++132+ }133+ if roles[i] >= 0 && strings.TrimSpace(string(rs[i:j])) != "" {134+ out = append(out, strings.TrimSpace(string(rs[i:j]))+":"+roleNames[roles[i]])135+ }136+ i = j137+ }138+ return out139+}140+141+func TestNuHighlights(t *testing.T) {142+ _, n := nuFor(recordedNu(t))143+ src := "big = $files | where size > 1kb | sort-by size --reverse # largest"144+ got := describe(src, n.Highlight(context.Background(), src))145+ want := []string{"big:variable", "=:operator", "$files:variable", "|:operator", "where:command", "size:variable", ">:operator",146+ "1kb:number", "|:operator", "sort-by:command", "size:string", "# largest:comment"}147+ if !slices.Equal(got, want) {148+ t.Errorf("roles\n got %q\nwant %q", got, want)149+ }150+ src = "$sheet.A1:C9 | append $selection | first 2"151+ got = describe(src, n.Highlight(context.Background(), src))152+ want = []string{"$sheet.A1:C9:variable", "|:operator", "append:command", "$selection:variable", "|:operator", "first:command", "2:number"}153+ if !slices.Equal(got, want) {154+ t.Errorf("a range of a sheet\n got %q\nwant %q", got, want)155+ }156+}157+158+func TestNuChecks(t *testing.T) {159+ _, n := nuFor(recordedNu(t))160+ src := "$files | sort-by size --revrse | append $nope"161+ var got []string162+ for _, d := range n.Check(context.Background(), src) {163+ got = append(got, src[d.From:d.To]+": "+d.Msg)164+ }165+ want := []string{"--revrse: The `sort-by` command doesn't have flag `revrse`.", "$nope: Variable not found."}166+ if !slices.Equal(got, want) {167+ t.Errorf("problems\n got %q\nwant %q", got, want)168+ }169+}170+171+func TestNuCompletes(t *testing.T) {172+ _, n := nuFor(recordedNu(t))173+ src := "$files | so"174+ var got []string175+ for _, c := range n.Complete(context.Background(), src, len(src)) {176+ got = append(got, fmt.Sprintf("%s %d-%d", c.Text, c.From, c.To))177+ }178+ want := []string{"sort-by 9-11", "sort 9-11", "source 9-11", "source-env 9-11"}179+ if !slices.Equal(got, want) {180+ t.Errorf("completions\n got %q\nwant %q", got, want)181+ }182+}183+184+func TestReplaced(t *testing.T) {185+ for _, c := range []struct {186+ src, w string187+ want int188+ }{189+ {"ls | str jo", "str join", 5},190+ {"$env.PA", "PATH", 5},191+ {"ls | sort-by size -", "--reverse", 18},192+ {"ls ~/Dev", "~/Developer/", 3},193+ {"ls ", "a.txt", 3},194+ {"open 'my fi", "`my file.txt`", 5},195+ } {196+ if got := replaced(c.src, len(c.src), c.w); got != c.want {197+ t.Errorf("replaced(%q, %q) = %d, want %d", c.src, c.w, got, c.want)198+ }199+ }200+}201+202+// nu missing, too old, slow, or not allowed: the built-ins answer, and203+// nu isn't asked again.204+func TestNuFallsBack(t *testing.T) {205+ src := "ls | where size > 1kb # big"206+ builtin := describe(src, Tokens{}.Highlight(context.Background(), src))207+ for name, f := range map[string]*fakeRunner{208+ "missing": {err: nushell.ErrMissing},209+ "old": {version: "0.99.0"},210+ "slow": {version: "0.116.0", block: true},211+ } {212+ s, n := nuFor(f)213+ s.Timeout = 20 * time.Millisecond214+ for range slowAfter {215+ if got := describe(src, n.Highlight(context.Background(), src)); !slices.Equal(got, builtin) {216+ t.Errorf("%s: roles %q", name, got)217+ }218+ if d := n.Check(context.Background(), src); d != nil {219+ t.Errorf("%s: problems %v", name, d)220+ }221+ }222+ if !n.Instant() {223+ t.Errorf("%s: nu is still asked", name)224+ }225+ asked := len(f.questions())226+ n.Highlight(context.Background(), src)227+ if got := n.Complete(context.Background(), "$fi", 3); len(got) != 1 || got[0].Text != "$files" || len(f.questions()) != asked {228+ t.Errorf("%s: completions %v, asked %v", name, got, f.questions())229+ }230+ }231+ f := recordedNu(t)232+ s, n := nuFor(f)233+ s.SetOn(false) // 012 serve without serve-shell, or a file not trusted234+ n.Highlight(context.Background(), src)235+ n.Check(context.Background(), src)236+ n.Complete(context.Background(), src, 2)237+ if q := f.questions(); len(q) != 0 {238+ t.Errorf("asked nu while it isn't allowed: %v", q)239+ }240+}241+242+// drain runs cmd and what it batches, returning their messages.243+func drain(cmd tea.Cmd) []tea.Msg {244+ if cmd == nil {245+ return nil246+ }247+ switch msg := cmd().(type) {248+ case tea.BatchMsg:249+ var out []tea.Msg250+ for _, c := range msg {251+ out = append(out, drain(c)...)252+ }253+ return out254+ case nil:255+ return nil256+ default:257+ return []tea.Msg{msg}258+ }259+}260+261+// Typing asks nu nothing; once typing pauses, nu is asked about the262+// text as it is then, and its answers draw: the roles, the underline263+// and, with the caret on it, the problem on the context line.264+func TestEditAsksNuOncePaused(t *testing.T) {265+ f := recordedNu(t)266+ h := newHost("")267+ v := newView(h, 80, 20)268+ _, n := nuFor(f)269+ v.Providers = Providers{Highlighter: n, Completer: n, Checker: n}270+ v.StartEdit()271+ src := "$files | sort-by size --revrse | append $nope"272+ for _, r := range src {273+ v.Key(key(string(r)))274+ }275+ text(v)276+ if q := f.questions(); len(q) != 0 {277+ t.Fatalf("typing asked nu: %v", q)278+ }279+ if cmd, _ := v.Update(pausedMsg{view: v, version: v.edit.version - 1}); cmd != nil {280+ t.Error("a pause at an older text asked nu")281+ }282+ cmd, _ := v.Update(pausedMsg{view: v, version: v.edit.version})283+ for _, msg := range drain(cmd) {284+ v.Update(msg)285+ }286+ if v.edit.diagFor != src || len(v.edit.diags) != 2 {287+ t.Fatalf("problems %v about %q", v.edit.diags, v.edit.diagFor)288+ }289+ if got := v.Diagnostic(); got != "Variable not found." {290+ t.Errorf("the caret after $nope: %q", got)291+ }292+ v.Key(key("home"))293+ if got := v.Diagnostic(); got != "" {294+ t.Errorf("the caret on no problem: %q", got)295+ }296+ line := strings.Split(text(v), "\n")[1]297+ if !strings.Contains(line, "--revrse") {298+ t.Errorf("line %q", line)299+ }300+ marks := v.edit.marks()301+ if i := strings.Index(src, "--revrse"); !marks[i] || marks[i-1] {302+ t.Errorf("the underline: %v", marks)303+ }304+}305+306+// A question out when the text changes is stopped, and its answer307+// dropped.308+func TestStaleAnswersDropped(t *testing.T) {309+ f := &fakeRunner{version: "0.116.0", block: true}310+ h := newHost("ls")311+ v := newView(h, 80, 20)312+ _, n := nuFor(f)313+ v.Providers = Providers{Highlighter: n, Completer: n, Checker: n}314+ v.StartEdit()315+ cmd, _ := v.Update(pausedMsg{view: v, version: v.edit.version})316+ v.Key(key("x")) // stops the questions out317+ done := make(chan []tea.Msg)318+ go func() { done <- drain(cmd) }()319+ select {320+ case msgs := <-done:321+ if len(msgs) != 0 {322+ t.Errorf("stale answers: %v", msgs)323+ }324+ case <-time.After(5 * time.Second):325+ t.Fatal("the questions weren't stopped")326+ }327+}328+329+// Between keys, the text being edited keeps nu's roles where it's330+// unchanged, the tokenizer's only where it changed.331+func TestEditKeepsRolesBetweenKeys(t *testing.T) {332+ h := newHost("big = $files | where size > 1kb | sort-by size --reverse # largest")333+ v := newView(h, 80, 20)334+ _, n := nuFor(recordedNu(t))335+ v.Providers = Providers{Highlighter: n, Completer: n, Checker: n}336+ v.StartEdit()337+ cmd, _ := v.Update(pausedMsg{view: v, version: v.edit.version})338+ for _, msg := range drain(cmd) {339+ v.Update(msg)340+ }341+ answered := v.spansFor(v.edit.text(), h.cells[0].Kind, true)342+ v.Key(key("x"))343+ now := v.spansFor(v.edit.text(), h.cells[0].Kind, true)344+ if !slices.Equal(now[:len(answered)], answered) || now[len(answered)] != int8(theme.SyntaxComment) {345+ t.Errorf("roles\n was %v\n now %v", answered, now)346+ }347+}348+349+// The real nu, when it's the version recorded, says what testdata/nu350+// records about the same text.351+func TestRecordedAnswersAreNus(t *testing.T) {352+ if _, err := exec.LookPath("nu"); err != nil {353+ t.Skip("nu isn't installed")354+ }355+ if v, err := nushell.Version(context.Background(), nushell.Nu{}); err != nil || v != "0.116.0" {356+ t.Skipf("nu %s %v isn't the version recorded", v, err)357+ }358+ for _, r := range recordings(t) {359+ var b strings.Builder360+ if err := (nushell.Nu{}).Run(context.Background(), nushell.Job{IDE: r.flags}, r.script, &b); err != nil {361+ t.Fatal(err)362+ }363+ if strings.TrimSpace(b.String()) != strings.TrimSpace(r.answer) {364+ t.Errorf("nu %v now says\n%s\nrecorded\n%s", r.flags, b.String(), r.answer)365+ }366+ }367+}@@ -0,0 +1,146 @@
1+package nbview2+3+import (4+ "context"5+ "slices"6+ "strconv"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/notebook"10+ "github.com/FelineStateMachine/012/internal/nushell"11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// What nu is asked about a cell, and how its answers map back to the15+// cell's source.16+17+// prepared is the text nu is asked about: a prelude declaring the18+// notebook's variables, then the source masked, starting at byte at.19+type prepared struct {20+ text string21+ at int22+ head int // the bytes of the source that are its `name =`23+}24+25+// prepare masks src and declares what it reads: `sales = ls` is asked26+// about as ` ls`, $sheet.A1:C9 as $__s1_______, and the prelude27+// declares $__s1, $selection, $sheet and names, each `let x: any = []`28+// so nu takes it for whatever it holds.29+func prepare(src string, names []string) prepared {30+ b := []byte(src)31+ name, pipeline := notebook.SplitName(src)32+ head := 033+ if name != "" {34+ head = len(src) - len(pipeline)35+ for i := range head {36+ if b[i] != '\n' {37+ b[i] = ' '38+ }39+ }40+ }41+ var pre strings.Builder42+ declared := map[string]bool{}43+ declare := func(name, value string) {44+ if !declared[name] {45+ declared[name] = true46+ pre.WriteString("let " + name + ": any = " + value + "\n")47+ }48+ }49+ for i, s := range notebook.RangeSpans(src) {50+ v := "__s" + strconv.Itoa(i+1)51+ if s[0] < head || len(v)+1 > s[1]-s[0] {52+ continue53+ }54+ v += strings.Repeat("_", s[1]-s[0]-len(v)-1)55+ copy(b[s[0]:s[1]], "$"+v)56+ declare(v, "[]")57+ }58+ declare(notebook.Selection, "[]")59+ declare("sheet", "{}")60+ for _, nm := range names {61+ if notebook.ValidName(nm) == nil {62+ declare(nm, "[]")63+ }64+ }65+ return prepared{text: pre.String() + string(b), at: pre.Len(), head: head}66+}67+68+// spans are src's syntax roles from nu's shapes of p's text: the69+// tokenizer's for the cell's name and comments, which nu doesn't70+// report, and a number's unit (the kb of 1kb) as part of the number.71+func (p prepared) spans(src string, shapes []nushell.Shape) []Span {72+ var out []Span73+ for _, s := range (Tokens{}).Highlight(context.Background(), src) {74+ if s.To <= p.head || s.Kind == theme.SyntaxComment {75+ out = append(out, s)76+ }77+ }78+ for i, s := range shapes {79+ from, to := s.From-p.at, s.To-p.at80+ if from < p.head || to > len(src) || from >= to {81+ continue82+ }83+ kind, ok := shapeKind(s.Shape, src[from:to])84+ if s.Shape == "shape_string" && i > 0 && shapes[i-1].To == s.From && isNumberShape(shapes[i-1].Shape) {85+ kind, ok = theme.SyntaxNumber, true86+ }87+ if ok {88+ out = append(out, Span{From: from, To: to, Kind: kind})89+ }90+ }91+ return out92+}93+94+// shapeRoles are the syntax roles of nu's shapes; the rest (flags,95+// brackets, garbage, which the checker underlines) show plain.96+var shapeRoles = map[string]theme.Syntax{97+ "shape_internalcall": theme.SyntaxCommand, "shape_external": theme.SyntaxCommand, "shape_external_resolved": theme.SyntaxCommand,98+ "shape_keyword": theme.SyntaxKeyword, "shape_bool": theme.SyntaxKeyword, "shape_nothing": theme.SyntaxKeyword,99+ "shape_string": theme.SyntaxString, "shape_string_interpolation": theme.SyntaxString, "shape_raw_string": theme.SyntaxString,100+ "shape_glob_interpolation": theme.SyntaxString, "shape_globpattern": theme.SyntaxString, "shape_filepath": theme.SyntaxString,101+ "shape_directory": theme.SyntaxString, "shape_externalarg": theme.SyntaxString,102+ "shape_variable": theme.SyntaxVariable, "shape_vardecl": theme.SyntaxVariable,103+ "shape_int": theme.SyntaxNumber, "shape_float": theme.SyntaxNumber, "shape_filesize": theme.SyntaxNumber,104+ "shape_duration": theme.SyntaxNumber, "shape_datetime": theme.SyntaxNumber, "shape_binary": theme.SyntaxNumber,105+ "shape_operator": theme.SyntaxOperator, "shape_pipe": theme.SyntaxOperator, "shape_redirection": theme.SyntaxOperator,106+ "shape_range": theme.SyntaxOperator,107+}108+109+// shapeKind is the role of text nu shaped so: let, if and def are110+// keywords to 012, as the tokenizer has them, though nu calls them.111+func shapeKind(shape, text string) (theme.Syntax, bool) {112+ k, ok := shapeRoles[shape]113+ if k == theme.SyntaxCommand && slices.Contains(keywords, text) {114+ k = theme.SyntaxKeyword115+ }116+ return k, ok117+}118+119+func isNumberShape(shape string) bool { return shape == "shape_int" || shape == "shape_float" }120+121+// replaced is where completion w starts replacing src before offset:122+// the longest stretch ending there, from a word's start or after a . or123+// /, that w starts with (ignoring case), since nu doesn't say. When none124+// is, the word at the caret.125+func replaced(src string, offset int, w string) int {126+ start := offset127+ for start > 0 && !strings.ContainsRune("|;({[\n", rune(src[start-1])) {128+ start--129+ }130+ lw := strings.ToLower(w)131+ for i := start; i < offset; i++ {132+ if (i == start || strings.ContainsRune(" \t./", rune(src[i-1]))) && strings.HasPrefix(lw, strings.ToLower(src[i:offset])) {133+ return i134+ }135+ }136+ if w != "" && strings.ContainsRune("'\"`", rune(w[0])) { // a quoted path: from its quote137+ if q := strings.LastIndexAny(src[start:offset], "'\"`"); q >= 0 {138+ return start + q139+ }140+ }141+ i := offset142+ for i > start && !isSpace(src[i-1]) {143+ i--144+ }145+ return i146+}@@ -0,0 +1,5 @@
1+{"type":"diagnostic","severity":"Error","message":"The `sort-by` command doesn't have flag `revrse`.","span":{"start":86,"end":94}}2+{"type":"diagnostic","severity":"Error","message":"Variable not found.","span":{"start":104,"end":109}}3+{"type":"hint","typename":"any","position":{"start":4,"end":13}}4+{"type":"hint","typename":"any","position":{"start":28,"end":33}}5+{"type":"hint","typename":"any","position":{"start":48,"end":53}}@@ -387,7 +387,7 @@ func pad2(n int) string {
387387 // edited, a note's Markdown, or a pipeline highlighted.388388 func (v *View) sourceLine(i int, c notebook.Cell, r int) string {389389 if v.edit.on && i == v.sel {390- return v.edit.line(v.h.Theme(), r, v.content(), v.spansFor(v.edit.text(), c.Kind))390+ return v.edit.line(v.h.Theme(), r, v.content(), v.spansFor(v.edit.text(), c.Kind, true))391391 }392392 if c.Kind == notebook.Note {393393 lines := v.noteLines(c)@@ -401,5 +401,5 @@ func (v *View) sourceLine(i int, c notebook.Cell, r int) string {
401401 if r >= len(rows) {402402 return ""403403 }404- return drawRow(v.h.Theme(), buf, rows[r], v.spansFor(c.Source, c.Kind), nil)404+ return drawRow(v.h.Theme(), buf, rows[r], v.spansFor(c.Source, c.Kind, false), nil)405405 }@@ -207,7 +207,8 @@ func (m *Model) viewOf(s *sheet.Sheet) *nbview.View {
207207 if v == nil {208208 v = nbview.New(nbHost{m, s})209209 v.Keys, v.EditKeys = nbKeys, nbEditKeys210- v.Providers = nbview.Providers{Highlighter: nbview.Tokens{}, Completer: nbview.Words(func() []nbview.Word { return m.nbWords(s) })}210+ lang := m.nbLang().For()211+ v.Providers = nbview.Providers{Highlighter: lang, Completer: lang, Checker: lang}211212 m.nb.views[s] = v212213 }213214 return v@@ -358,30 +359,6 @@ func (m *Model) renameCell(s *sheet.Sheet, c notebook.Cell, name string) {
358359 }359360 }360361 361-// nbWords are the completions of notebook s's cells: its names, the362-// workbook's regions, and nu's commands once they're known.363-func (m *Model) nbWords(s *sheet.Sheet) []nbview.Word {364- var out []nbview.Word365- for _, c := range s.NotebookCells() {366- if n := c.Name(); n != "" {367- out = append(out, nbview.Word{Text: "$" + n, Desc: "a cell's output"})368- }369- }370- for _, t := range m.book().Sheets() {371- for _, r := range t.Regions() {372- if r.Linked() {373- out = append(out, nbview.Word{Text: "$" + r.Name, Desc: "linked file " + r.File.Path})374- }375- }376- }377- out = append(out, nbview.Word{Text: "$selection", Desc: "the selection on the sheet shown last"},378- nbview.Word{Text: "$sheet.", Desc: "a range of a sheet: $sheet.A1:C9"})379- for _, c := range m.nb.words {380- out = append(out, nbview.Word{Text: c, Desc: "command"})381- }382- return out383-}384-385362 // nbHost is how a notebook's view reaches the model.386363 type nbHost struct {387364 m *Model@@ -22,6 +22,9 @@ type fakeNu struct {
2222 }2323 2424 func (f *fakeNu) Run(_ context.Context, job nushell.Job, _ string, stdout io.Writer) error {25+ if len(job.IDE) > 0 {26+ return nushell.ErrMissing // the code editor's questions: the built-ins answer27+ }2528 if job.Command == "help commands | get name" {2629 _, err := io.WriteString(stdout, `["ls", "where", "sort-by", "str join"]`)2730 return err