- commit
- 89ca25dd4e0ee089e709d2b9a2d7e31f9a87bd80
- parent
- f72d79d004f5481619f7ef41a40ca930d1ab00f9
- parent
- e2b994b118564219654779a4fcd67204ce457d74
- author
- Dami <dami.etoile@gmail.com>
@@ -29,12 +29,12 @@ rises in measured steps; see [Bounds of support](docs/contributing/limits.md#wha
2929 3030 Notebook tabs are a TUI Jupyter for nushell3131 ([Notebooks](docs/nushell/notebooks.md)): code and note cells, outputs in32-place, sent to sheets as live regions. What's ahead makes writing a cell33-feel like nushell's own prompt.32+place, sent to sheets as live regions, and cells highlighted, checked and33+completed by nu as they're written.3434 3535 | Item | Result | Size |3636 |---|---|---|37-| Code cells highlighted, completed and checked as you type by nushell itself: `nu --ide-ast` token shapes mapped to theme roles, `nu --ide-complete` plus 012's cell, region and sheet names on Tab, `nu --ide-check` errors underlined; debounced in the background, plain text when nu is missing or slow; hover docs through `nu --lsp` later | Writing pipelines in 012 feels like nushell's own prompt | S to M |37+| A command's signature and description for the word at the caret, on the context line, through `nu --ide-hover` or `nu --lsp` | Writing a cell without leaving for `help` | S |3838 3939 ### 3. Toward multiplayer4040 @@ -138,6 +138,7 @@ stream above rather than a design of their own.
138138 - 012 as a stage in a pipeline: `012 -` reads a table from standard input, `012 --pipe` sends the sheet or selection on, with nushell's types kept through NUON: [Pipelines](docs/nushell/pipelines.md)139139 - The `sheet` command: a nushell module shipped in the binary (`012 nu --install-module`), so nu calls 012 without `^012` or NUON on either side: [Pipelines](docs/nushell/pipelines.md#the-sheet-command)140140 - Notebook tabs, a TUI Jupyter for nushell: code and note cells with Jupyter's keys, outputs drawn in place and opened full-screen, `$name` between cells, stale marks and a reactive mode, outputs sent to sheets as live regions (`nu.name`), saved up to a cap, and earlier notebook sheets converted on open: [Notebooks](docs/nushell/notebooks.md)141+- Code cells highlighted, checked and completed as they're written by nu itself (`--ide-ast`, `--ide-check`, `--ide-complete`), in the background, falling back to 012's own when nu is missing, old, slow or not trusted: [Notebooks](docs/nushell/notebooks.md#writing-a-cell)141142 142143 **Files**143144 @@ -99,7 +99,7 @@ there.
9999 | Entries failing validation | A dotted underline, and `Invalid:` with the rule on the context line |100100 | Values an array spilled | Italic, and `Spilled from B2` on the context line |101101 | Linked files' rows, following, paused or failing | Italic; `●`, `‖` or `!` beside the tab's name and on the context line, with the state in words (`Following`, `Paused`, the error) |102-| A notebook's cells and outputs | `❯` before the tab's name; the selected cell's head in reverse video; the head's words for its state (`[*]` and `running`, `waiting`, `failed`, `stale`); `×` before an error; an output table's header bold and underlined; an output sent to a sheet in italic, with `Output of files` on the context line |102+| A notebook's cells and outputs | `❯` before the tab's name; the selected cell's head in reverse video; the head's words for its state (`[*]` and `running`, `waiting`, `failed`, `stale`); `×` before an error; an output table's header bold and underlined; an output sent to a sheet in italic, with `Output of files` on the context line; a problem nu finds in the cell being written curly-underlined, in words on the context line with the caret on it |103103 | Pivot table results | Their headings (`SUM of Units`, `Grand Total`), and a note in words when an edit is refused |104104 | Protected ranges | A question in words before an edit (`A1:B2 is protected.`) |105105 | Notes | A `▝` in the cell's top-right corner |@@ -138,7 +138,7 @@ stateDiagram-v2
138138 | Enter | A new line, as indented as the one before |139139 | Up, Down, Home, End | Move by the lines on screen, wrapped lines too |140140 | Ctrl+A, Ctrl+E | The start, the end of the line |141-| Tab | Complete the word at the caret: a cell's `$name`, `$selection`, a linked file, or one of nu's commands |141+| Tab | Complete the word at the caret: a cell's `$name`, `$selection`, a linked file, then what nu completes there (commands, flags, paths); one completion goes in at once ([Writing a cell](#writing-a-cell)) |142142 143143 A terminal without the kitty keyboard protocol sends Shift+Enter and144144 Ctrl+Enter as Enter ([Keys the terminal has to tell@@ -150,6 +150,47 @@ shortcuts (Ctrl+/), and File, Edit and the sheet tabs work as anywhere;
150150 commands for a sheet's cells are off on a notebook's tab. Changes to151151 cells are undo steps, as any edit is.152152 153+## Writing a cell154+155+The cell being written is read by nu itself, as nushell's own prompt156+reads what you type: once typing pauses, 012 asks `nu --ide-ast` what157+each word is and colors it (commands, strings, variables, numbers,158+keywords, operators; flags stay plain), and `nu --ide-check` what's159+wrong. A problem is underlined with a curly line, and with the caret on160+it the context line says what nu said:161+162+```163+ [ ] not run164+│ $files | sort-by size --revrse165+ ~~~~~~~~166+The `sort-by` command doesn't have flag `revrse`.167+```168+169+Tab asks `nu --ide-complete` too, after the notebook's own names, so it170+completes flags, subcommands and paths as well as cells and commands.171+nu doesn't know the variables a notebook binds, so it's asked about the172+cell with `$name` for each named cell and linked file, `$selection` and173+`$sheet` declared before it; a `$sheet.A1:C9` range reads as one174+variable, and `name =` isn't part of what nu reads.175+176+Questions to nu run in the background and never on each key: one177+process for each question after a pause, at most two at a time, each178+stopped once the text changes and after a second and a half. Until nu179+answers, the cell shows 012's own highlighting, and what's unchanged180+keeps nu's colors, so nothing flickers or moves. 012 falls back to its181+own highlighting and names, without saying so, when:182+183+- nu isn't installed, or is older than 0.100;184+- nu timed out three times in a row (asked again in the next session);185+- the cells couldn't run without asking: a file from another computer186+ not yet trusted ([Saving and trust](#saving-and-trust)), `shell =187+ off`, or [012 serve](../terminal/ssh.md#notebooks) without188+ `serve-shell`.189+190+nu reads the cell without your `config.nu` or its standard library, so191+your own commands aren't known to it; they still run when the cell does192+with `nu-config`.193+153194 ## Running154195 155196 Cells run in the background, one at a time, so the screen stays live:@@ -153,7 +153,7 @@ on a sheet, `!` starts an entry as any character does.
153153 |---|---|154154 | Esc | Back to command mode, keeping what was typed |155155 | Shift+Enter, Ctrl+Enter, Alt+Enter | Run, as in command mode |156-| Enter, Tab | A new line; complete the word at the caret |156+| Enter, Tab | A new line; complete the word at the caret, with what nu completes there ([Writing a cell](../nushell/notebooks.md#writing-a-cell)) |157157 | Up, Down, Home, End, Ctrl+A, Ctrl+E | Move by the lines on screen; the start, the end of the line |158158 | Word keys | As [anywhere text is edited](#entering-data); Ctrl+U deletes to the start of the line, not the cell |159159 @@ -158,7 +158,9 @@ reach, well beyond the served directory. `serve-shell = on` in the
158158 server's config lets sessions run them, as the user 012 serve runs as,159159 following the `shell` option as the local app does; a file's cells still160160 ask once per session before they run. Only turn it on when everyone holding an161-authorized key may run programs on the server.161+authorized key may run programs on the server. The same setting decides162+whether nu is started to [highlight and check](../nushell/notebooks.md#writing-a-cell)163+the cell being written; without it, 012's own highlighting answers.162164 163165 ## Unsaved work164166 @@ -61,6 +61,17 @@ func TestNotebookSendsToSheetWithNu(t *testing.T) {
6161 s.waitFor(" 16")6262 }6363 64+// With the real nu, a cell being written is highlighted by nu's shapes65+// once typing pauses, and Tab completes what only nu knows: a flag.66+func TestNotebookHighlightsAndCompletesWithNu(t *testing.T) {67+ needNu(t)68+ s := startWith(t, options{startsOn: "EDIT"}, "nu")69+ s.keys("ls | where size > 1kb | sort-by size")70+ s.eventually("nu's highlighting", func() bool { return nuHighlighted(s.html(), "sort-by", "size") })71+ s.keys(" --rev", "<tab>")72+ s.waitFor("sort-by size --reverse")73+}74+6475 // Data > Shell from a workbook makes a Notebook tab; $selection is6576 // the range selected on the sheet shown before it.6677 func TestNotebookReadsSelection(t *testing.T) {@@ -3,14 +3,17 @@ package e2e
33 import (44 "os"55 "path/filepath"6+ "regexp"7+ "strings"68 )79 810 // Golden screens for notebooks: cells with a note and table outputs, a911 // cell edited with a long pipeline wrapped (at 80 and 60 columns), a10-// failure, a cell running and one waiting, stale outputs, an output11-// full-screen, and an output sent to a sheet. Each is recorded on a12-// light terminal and in the high-contrast theme too. nu is testdata/nu/nu,13-// which answers what the screens run with fixed outputs.12+// failure, nu's highlighting and a problem nu found, a cell running and13+// one waiting, stale outputs, an output full-screen, and an output sent14+// to a sheet. Each is recorded on a light terminal and in the15+// high-contrast theme too. nu is testdata/nu/nu, which answers what the16+// screens run, and what the code editor asks, with fixed outputs.1417 1518 // fakeNu puts the stand-in nu first on the PATH.1619 var fakeNu = func() []string {@@ -21,6 +24,24 @@ var fakeNu = func() []string {
2124 return []string{"PATH=" + dir + string(os.PathListSeparator) + os.Getenv("PATH")}2225 }()2326 27+// nuHighlighted reports whether the line of screen html holding mark28+// draws word twice in two styles, as nu's shapes of `where size > 1kb29+// | sort-by size` do (a column, then a string) where the tokenizer30+// leaves both plain.31+func nuHighlighted(html, mark, word string) bool {32+ for line := range strings.SplitSeq(html, "\n") {33+ if !strings.Contains(line, mark) {34+ continue35+ }36+ var styles []string37+ for _, m := range regexp.MustCompile(`<span style="([^"]*)">`+regexp.QuoteMeta(word)+`</span>`).FindAllStringSubmatch(line, -1) {38+ styles = append(styles, m[1])39+ }40+ return len(styles) == 2 && styles[0] != styles[1]41+ }42+ return false43+}44+2445 // longPipeline is a pipeline too long for one line at 80 columns.2546 const longPipeline = "$files | where size > 1kb | where type == file | sort-by size --reverse | select name size | first 10 | rename file bytes"2647 @@ -73,6 +94,16 @@ var notebookScreens = []screen{
7394 s.keys("<up>", "<up>", "<up>", "<enter>")7495 s.waitFor("full-screen")7596 }},97+ {name: "notebook-nu-highlight", setup: func(s *session) {98+ filesNotebook(s)99+ s.keys("<enter>", "big = $files | where size > 1kb | sort-by size --reverse # largest")100+ s.eventually("nu's highlighting", func() bool { return nuHighlighted(s.html(), "largest", "size") })101+ }},102+ {name: "notebook-nu-error", setup: func(s *session) {103+ filesNotebook(s)104+ s.keys("<enter>", "$files | sort-by size --revrse")105+ s.waitFor("doesn't have flag `revrse`")106+ }},76107 {name: "notebook-sent", setup: func(s *session) {77108 filesNotebook(s)78109 s.keys("<up>", "<up>", "<up>", "<up>", "G", "<enter>")@@ -0,0 +1,4 @@
1+{"type":"diagnostic","severity":"Error","message":"The `sort-by` command doesn't have flag `revrse`.","span":{"start":86,"end":94}}2+{"type":"hint","typename":"any","position":{"start":4,"end":13}}3+{"type":"hint","typename":"any","position":{"start":28,"end":33}}4+{"type":"hint","typename":"any","position":{"start":48,"end":53}}@@ -4,6 +4,29 @@
44 # installed or on what ls finds. The script 012 runs is the last55 # argument; the pipeline is between its "do {" and "} | to nuon".66 for arg; do script=$arg; done7+# The code editor's questions (--ide-ast and the rest, about the file8+# named last) are answered as nu 0.116 answered them about the same9+# text, recorded in ide/: name.nu, and its name.ast.json,10+# name.check.jsonl or name.complete.json. Text not recorded fails, and11+# 012 falls back to its own highlighting.12+case " $* " in13+*' --version '*)14+ echo 0.116.015+ exit 0 ;;16+*' --ide-ast '*) answer=ast.json ;;17+*' --ide-check '*) answer=check.jsonl ;;18+*' --ide-complete '*) answer=complete.json ;;19+esac20+if [ -n "$answer" ]; then21+ for nu in "$(dirname "$0")"/ide/*.nu; do22+ if cmp -s "$nu" "$script" && [ -f "${nu%.nu}.$answer" ]; then23+ cat "${nu%.nu}.$answer"24+ exit 025+ fi26+ done27+ echo "Error: not recorded" >&228+ exit 129+fi730 cmd=$(printf '%s\n' "$script" | sed -n '/^do {$/,/^} | to nuon$/p' | sed '1d;$d')831 case $cmd in932 *'help commands'*)@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">EDIT</span><span style="background:#40e0ff"> </span>2+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">cell</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">a</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">nushell</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">pipeline</span><span style="background:#000000"> </span>3+<span style="color:#ffe040;background:#000000">The</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">`sort-by`</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">command</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">doesn't</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">have</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">flag</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">`revrse`.</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Shift+Enter</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">run</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Esc</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">done</span>4+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Notebook</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">cells</span><span style="background:#3a3a3a"> </span>5+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">Markdown</span><span style="background:#000000"> </span>6+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">Files</span><span style="background:#000000"> </span>7+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">What's</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">big</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">in</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">the</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">repo,</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">from</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">ls</span><span style="color:#ffffff;background:#000000">.</span><span style="background:#000000"> </span>8+<span style="background:#000000"> </span>9+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">[1]</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">files</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">✓</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000"><1s</span><span style="background:#000000"> </span>10+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">=</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">ls</span><span style="background:#000000"> </span>11+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">name</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">type</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">size</span><span style="background:#000000"> </span>12+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>13+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">CLAUDE.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">1.0</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>14+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>15+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>16+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">go.mod</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">812</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">B</span><span style="background:#000000"> </span>17+<span style="background:#000000"> </span>18+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">[2]</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">✓</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000"><1s</span><span style="background:#000000"> </span>19+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">$files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">where</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">></span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">1kb</span><span style="background:#000000"> </span>20+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">name</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">type</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">size</span><span style="background:#000000"> </span>21+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>22+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>23+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>24+<span style="background:#000000"> </span>25+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">[</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">]</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">not</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">run</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span>26+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">$files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">sort-by</span><span style="background:#000000"> </span><span style="color:#40e040;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;text-decoration:underline wavy #ff8080">--revrse</span><span style="background:#000000"> </span>27+<span style="background:#000000"> </span>28+<span style="background:#000000"> </span>29+<span style="background:#000000"> </span>30+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">❯</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">Notebook</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">+</span><span style="background:#1f1f1f"> </span><span style="color:#757575;background:#1f1f1f">│</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">untitled</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">modified</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+K</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">search</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+/</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">shortcuts</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F10</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">EDIT</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">cell</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">a</span> <span style="color:var(--vt-palette-0)">nushell</span> <span style="color:var(--vt-palette-0)">pipeline</span>3+<span style="color:var(--vt-palette-3)">The</span> <span style="color:var(--vt-palette-3)">`sort-by`</span> <span style="color:var(--vt-palette-3)">command</span> <span style="color:var(--vt-palette-3)">doesn't</span> <span style="color:var(--vt-palette-3)">have</span> <span style="color:var(--vt-palette-3)">flag</span> <span style="color:var(--vt-palette-3)">`revrse`.</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Shift+Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">done</span>4+ Notebook 4 cells5+ <span style="color:var(--vt-palette-0)">Markdown</span>6+<span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold;text-decoration:underline">Files</span>7+<span style="color:var(--vt-palette-8)">│</span> What's big in the repo, from <span style="color:var(--vt-palette-4)">ls</span>.8+9+ <span style="color:var(--vt-palette-0)">[1]</span> <span style="color:var(--vt-palette-0)">files</span> <span style="color:var(--vt-palette-0)">✓</span> <span style="color:var(--vt-palette-0)"><1s</span>10+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">files</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-4)">ls</span>11+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>12+ 012 file 4.2 kB13+ CLAUDE.md file 1.0 kB14+ README.md file 9.8 kB15+ demos dir 3.1 kB16+ go.mod file 812 B17+18+ <span style="color:var(--vt-palette-0)">[2]</span> <span style="color:var(--vt-palette-0)">✓</span> <span style="color:var(--vt-palette-0)"><1s</span>19+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">where</span> size <span style="font-weight:bold">></span> <span style="color:var(--vt-palette-1)">1kb</span>20+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>21+ 012 file 4.2 kB22+ README.md file 9.8 kB23+ demos dir 3.1 kB24+25+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">[</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">]</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">not</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">run</span><span style="background:var(--vt-palette-6)"> </span>26+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">sort-by</span> <span style="color:var(--vt-palette-2)">size</span> <span style="text-decoration:underline wavy var(--vt-palette-1)">--revrse</span>27+28+29+30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">❯</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Notebook</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">EDIT</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">cell</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">nushell</span> <span style="color:var(--vt-palette-8)">pipeline</span>3+<span style="color:var(--vt-palette-3)">The</span> <span style="color:var(--vt-palette-3)">`sort-by`</span> <span style="color:var(--vt-palette-3)">command</span> <span style="color:var(--vt-palette-3)">doesn't</span> <span style="color:var(--vt-palette-3)">have</span> <span style="color:var(--vt-palette-3)">flag</span> <span style="color:var(--vt-palette-3)">`revrse`.</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">done</span>4+ Notebook 4 cells5+ <span style="color:var(--vt-palette-8)">Markdown</span>6+<span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold;text-decoration:underline">Files</span>7+<span style="color:var(--vt-palette-8)">│</span> What's big in the repo, from <span style="color:var(--vt-palette-6)">ls</span>.8+9+ <span style="color:var(--vt-palette-8)">[1]</span> <span style="color:var(--vt-palette-8)">files</span> <span style="color:var(--vt-palette-8)">✓</span> <span style="color:var(--vt-palette-8)"><1s</span>10+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">files</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-6)">ls</span>11+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>12+ 012 file 4.2 kB13+ CLAUDE.md file 1.0 kB14+ README.md file 9.8 kB15+ demos dir 3.1 kB16+ go.mod file 812 B17+18+ <span style="color:var(--vt-palette-8)">[2]</span> <span style="color:var(--vt-palette-8)">✓</span> <span style="color:var(--vt-palette-8)"><1s</span>19+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">where</span> size <span style="font-weight:bold">></span> <span style="color:var(--vt-palette-3)">1kb</span>20+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>21+ 012 file 4.2 kB22+ README.md file 9.8 kB23+ demos dir 3.1 kB24+25+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">[</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">]</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">not</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">run</span><span style="background:var(--vt-palette-6)"> </span>26+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">sort-by</span> <span style="color:var(--vt-palette-2)">size</span> <span style="text-decoration:underline wavy var(--vt-palette-1)">--revrse</span>27+28+29+30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">❯</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Notebook</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">EDIT</span><span style="background:#40e0ff"> </span>2+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">cell</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">a</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">nushell</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">pipeline</span><span style="background:#000000"> </span>3+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Shift+Enter</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">run</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+Enter</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">run</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">here</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Tab</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">complete</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Esc</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">done</span>4+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Notebook</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">cells</span><span style="background:#3a3a3a"> </span>5+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">Markdown</span><span style="background:#000000"> </span>6+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">Files</span><span style="background:#000000"> </span>7+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">What's</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">big</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">in</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">the</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">repo,</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">from</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">ls</span><span style="color:#ffffff;background:#000000">.</span><span style="background:#000000"> </span>8+<span style="background:#000000"> </span>9+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">[1]</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">files</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">✓</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000"><1s</span><span style="background:#000000"> </span>10+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">=</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">ls</span><span style="background:#000000"> </span>11+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">name</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">type</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">size</span><span style="background:#000000"> </span>12+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>13+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">CLAUDE.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">1.0</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>14+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>15+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>16+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">go.mod</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">812</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">B</span><span style="background:#000000"> </span>17+<span style="background:#000000"> </span>18+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">[2]</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">✓</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000"><1s</span><span style="background:#000000"> </span>19+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">$files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">where</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">></span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">1kb</span><span style="background:#000000"> </span>20+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">name</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">type</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">size</span><span style="background:#000000"> </span>21+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>22+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>23+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>24+<span style="background:#000000"> </span>25+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">[</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">]</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">not</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">run</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span>26+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">big</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">=</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">$files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">where</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">></span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">1kb</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">sort-by</span><span style="background:#000000"> </span><span style="color:#40e040;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">--reverse</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000;font-style:italic">#</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000;font-style:italic">largest</span><span style="background:#000000"> </span>27+<span style="background:#000000"> </span>28+<span style="background:#000000"> </span>29+<span style="background:#000000"> </span>30+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">❯</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">Notebook</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">+</span><span style="background:#1f1f1f"> </span><span style="color:#757575;background:#1f1f1f">│</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">untitled</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">modified</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+K</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">search</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+/</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">shortcuts</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F10</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">EDIT</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">cell</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">a</span> <span style="color:var(--vt-palette-0)">nushell</span> <span style="color:var(--vt-palette-0)">pipeline</span>3+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Shift+Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="color:var(--vt-palette-0)">here</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Tab</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">complete</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">done</span>4+ Notebook 4 cells5+ <span style="color:var(--vt-palette-0)">Markdown</span>6+<span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold;text-decoration:underline">Files</span>7+<span style="color:var(--vt-palette-8)">│</span> What's big in the repo, from <span style="color:var(--vt-palette-4)">ls</span>.8+9+ <span style="color:var(--vt-palette-0)">[1]</span> <span style="color:var(--vt-palette-0)">files</span> <span style="color:var(--vt-palette-0)">✓</span> <span style="color:var(--vt-palette-0)"><1s</span>10+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">files</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-4)">ls</span>11+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>12+ 012 file 4.2 kB13+ CLAUDE.md file 1.0 kB14+ README.md file 9.8 kB15+ demos dir 3.1 kB16+ go.mod file 812 B17+18+ <span style="color:var(--vt-palette-0)">[2]</span> <span style="color:var(--vt-palette-0)">✓</span> <span style="color:var(--vt-palette-0)"><1s</span>19+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">where</span> size <span style="font-weight:bold">></span> <span style="color:var(--vt-palette-1)">1kb</span>20+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>21+ 012 file 4.2 kB22+ README.md file 9.8 kB23+ demos dir 3.1 kB24+25+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">[</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">]</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">not</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">run</span><span style="background:var(--vt-palette-6)"> </span>26+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">big</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">where</span> <span style="color:var(--vt-palette-5)">size</span> <span style="font-weight:bold">></span> <span style="color:var(--vt-palette-1)">1kb</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">sort-by</span> <span style="color:var(--vt-palette-2)">size</span> --reverse <span style="color:var(--vt-palette-0);font-style:italic">#</span> <span style="color:var(--vt-palette-0);font-style:italic">largest</span>27+28+29+30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">❯</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Notebook</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">EDIT</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">cell</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">nushell</span> <span style="color:var(--vt-palette-8)">pipeline</span>3+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="color:var(--vt-palette-8)">here</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">complete</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">done</span>4+ Notebook 4 cells5+ <span style="color:var(--vt-palette-8)">Markdown</span>6+<span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold;text-decoration:underline">Files</span>7+<span style="color:var(--vt-palette-8)">│</span> What's big in the repo, from <span style="color:var(--vt-palette-6)">ls</span>.8+9+ <span style="color:var(--vt-palette-8)">[1]</span> <span style="color:var(--vt-palette-8)">files</span> <span style="color:var(--vt-palette-8)">✓</span> <span style="color:var(--vt-palette-8)"><1s</span>10+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">files</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-6)">ls</span>11+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>12+ 012 file 4.2 kB13+ CLAUDE.md file 1.0 kB14+ README.md file 9.8 kB15+ demos dir 3.1 kB16+ go.mod file 812 B17+18+ <span style="color:var(--vt-palette-8)">[2]</span> <span style="color:var(--vt-palette-8)">✓</span> <span style="color:var(--vt-palette-8)"><1s</span>19+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">where</span> size <span style="font-weight:bold">></span> <span style="color:var(--vt-palette-3)">1kb</span>20+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>21+ 012 file 4.2 kB22+ README.md file 9.8 kB23+ demos dir 3.1 kB24+25+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">[</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">]</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">not</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">run</span><span style="background:var(--vt-palette-6)"> </span>26+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">big</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">where</span> <span style="color:var(--vt-palette-5)">size</span> <span style="font-weight:bold">></span> <span style="color:var(--vt-palette-3)">1kb</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">sort-by</span> <span style="color:var(--vt-palette-2)">size</span> --reverse <span style="color:var(--vt-palette-8);font-style:italic">#</span> <span style="color:var(--vt-palette-8);font-style:italic">largest</span>27+28+29+30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">❯</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Notebook</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file@@ -51,6 +51,14 @@ func TestBind(t *testing.T) {
5151 if len(refs) != 2 || refs[0].Ref != "A1:C9" || refs[1].Ref != "'Q1 data'!B2:B5" || refs[1].Var != "__sheet2" {5252 t.Errorf("refs %+v", refs)5353 }54+ src := "$sheet.A1:C9 | append $sheet.'Q1 data'!B2:B5 | append $sheet.A1:C9.name | $x$sheet.A1 | $sheet."55+ var spans []string56+ for _, s := range RangeSpans(src) {57+ spans = append(spans, src[s[0]:s[1]])58+ }59+ if !slices.Equal(spans, []string{"$sheet.A1:C9", "$sheet.'Q1 data'!B2:B5", "$sheet.A1:C9"}) {60+ t.Errorf("RangeSpans %q", spans)61+ }5462 }5563 5664 func cells(srcs ...string) []Cell {@@ -100,6 +100,27 @@ func Bind(pipeline string) (string, []SheetRef) {
100100 }101101 }102102 103+// RangeSpans are where a pipeline reads ranges of sheets, by byte104+// offsets: each $sheet.A1:C9 whole, as Bind renames them.105+func RangeSpans(pipeline string) [][2]int {106+ var out [][2]int107+ for i := 0; ; {108+ k := strings.Index(pipeline[i:], sheetVar)109+ if k < 0 {110+ return out111+ }112+ k += i113+ i = k + len(sheetVar)114+ if k > 0 && isWord(pipeline[k-1]) {115+ continue116+ }117+ if n := refLen(pipeline[i:]); n > 0 {118+ i += n119+ out = append(out, [2]int{k, i})120+ }121+ }122+}123+103124 // refLen is how long the range at the start of s is: a sheet's name,104125 // quoted in ” if it needs to be, then ! and cells, or cells alone. A105126 // dot ends it, so a cell path may follow: $sheet.A1:C9.name.@@ -0,0 +1,219 @@
1+package nushell2+3+import (4+ "bufio"5+ "bytes"6+ "context"7+ "encoding/json"8+ "errors"9+ "fmt"10+ "strconv"11+ "strings"12+)13+14+// What nu says of a script without running it, through its IDE flags,15+// each reading the script from a file:16+//17+// nu --ide-ast file [{"type":"ast","span":{"start":0,"end":2},"shape":"shape_internalcall","content":"ls"}, ...]18+// nu --ide-check 20 file {"type":"diagnostic","severity":"Error","message":"Variable not found.","span":{"start":31,"end":35}}19+// one object a line, "hint" lines among them20+// nu --ide-complete 7 file {"completions": ["sort", "sort-by"]}21+//22+// Spans and offsets are bytes of the file. These are the shapes of nu23+// 0.116 (testdata/ide holds its answers); an older nu whose answers24+// don't read as these is reported as ErrOld, and a nu older than25+// MinVersion isn't asked at all.26+27+// MinVersion is the oldest nu asked about scripts.28+const MinVersion = "0.100.0"29+30+// ErrOld is a nu too old to ask, or one whose answers don't read.31+var ErrOld = errors.New("nu is too old to ask about pipelines")32+33+// Shape is a stretch of a script, by byte offsets, and nu's name for34+// what it is: shape_internalcall, shape_string.35+type Shape struct {36+ From, To int37+ Shape string38+}39+40+// Problem is what nu's check found wrong with a stretch of a script.41+type Problem struct {42+ From, To int43+ Severity string // Error or Warning44+ Msg string45+}46+47+// maxAnswer is the most of an answer kept: a script's shapes or48+// completions are far less.49+const maxAnswer = 4 << 2050+51+// ask runs job, asking nu about script, and returns what it printed.52+func ask(ctx context.Context, r Runner, script string, flags ...string) ([]byte, error) {53+ ctx, cancel := context.WithCancelCause(ctx)54+ defer cancel(nil)55+ out := &capped{max: maxAnswer, stop: func() { cancel(ErrTooLarge) }}56+ err := r.Run(ctx, Job{Command: script, IDE: flags}, script, out)57+ switch {58+ case ctx.Err() != nil:59+ return nil, context.Cause(ctx)60+ case err != nil:61+ return nil, err62+ }63+ return bytes.TrimSpace(out.b.Bytes()), nil64+}65+66+// Version is nu's version, checked against MinVersion: ErrOld when it's67+// older or doesn't read as one.68+func Version(ctx context.Context, r Runner) (string, error) {69+ out, err := ask(ctx, r, "", "--version")70+ if err != nil {71+ return "", err72+ }73+ v := string(out)74+ if !AtLeast(v, MinVersion) {75+ return v, fmt.Errorf("%w: %s", ErrOld, v)76+ }77+ return v, nil78+}79+80+// AtLeast reports whether version v is oldest or later; one that doesn't81+// read as major.minor.patch isn't.82+func AtLeast(v, oldest string) bool {83+ a, okA := semver(v)84+ b, okB := semver(oldest)85+ if !okA || !okB {86+ return false87+ }88+ for i := range a {89+ if a[i] != b[i] {90+ return a[i] > b[i]91+ }92+ }93+ return true94+}95+96+func semver(v string) ([3]int, bool) {97+ var out [3]int98+ v, _, _ = strings.Cut(strings.TrimSpace(v), "-") // 0.117.0-nightly99+ parts := strings.Split(v, ".")100+ if len(parts) != 3 {101+ return out, false102+ }103+ for i, p := range parts {104+ n, err := strconv.Atoi(p)105+ if err != nil {106+ return out, false107+ }108+ out[i] = n109+ }110+ return out, true111+}112+113+// AST is nu's shapes of script's tokens, in order.114+func AST(ctx context.Context, r Runner, script string) ([]Shape, error) {115+ out, err := ask(ctx, r, script, "--ide-ast")116+ if err != nil {117+ return nil, err118+ }119+ return ParseAST(out)120+}121+122+type span struct{ Start, End int }123+124+// ParseAST reads what --ide-ast printed.125+func ParseAST(out []byte) ([]Shape, error) {126+ var items []struct {127+ Type string128+ Span *span129+ Shape string130+ }131+ if err := json.Unmarshal(out, &items); err != nil {132+ return nil, fmt.Errorf("%w: %v", ErrOld, err)133+ }134+ shapes := make([]Shape, 0, len(items))135+ for _, it := range items {136+ if it.Type != "ast" || it.Span == nil || !strings.HasPrefix(it.Shape, "shape_") {137+ continue138+ }139+ shapes = append(shapes, Shape{From: it.Span.Start, To: it.Span.End, Shape: it.Shape})140+ }141+ return shapes, nil142+}143+144+// Check is what nu's check finds wrong with script, at most limit145+// problems, each once.146+func Check(ctx context.Context, r Runner, script string, limit int) ([]Problem, error) {147+ out, err := ask(ctx, r, script, "--ide-check", strconv.Itoa(limit))148+ if err != nil {149+ return nil, err150+ }151+ return ParseCheck(out)152+}153+154+// ParseCheck reads what --ide-check printed: its diagnostics, each once,155+// leaving out its hints (the types it inferred).156+func ParseCheck(out []byte) ([]Problem, error) {157+ var probs []Problem158+ sc := bufio.NewScanner(bytes.NewReader(out))159+ sc.Buffer(nil, maxAnswer)160+ for sc.Scan() {161+ line := bytes.TrimSpace(sc.Bytes())162+ if len(line) == 0 {163+ continue164+ }165+ var d struct {166+ Type, Severity, Message string167+ Span *span168+ }169+ if err := json.Unmarshal(line, &d); err != nil {170+ return nil, fmt.Errorf("%w: %v", ErrOld, err)171+ }172+ if d.Type != "diagnostic" || d.Span == nil {173+ continue174+ }175+ p := Problem{From: d.Span.Start, To: d.Span.End, Severity: d.Severity, Msg: d.Message}176+ if !containsProblem(probs, p) {177+ probs = append(probs, p)178+ }179+ }180+ return probs, sc.Err()181+}182+183+func containsProblem(probs []Problem, p Problem) bool {184+ for _, q := range probs {185+ if q == p {186+ return true187+ }188+ }189+ return false190+}191+192+// Complete is what nu would complete at offset, a byte of script, in193+// its order. nu says what goes there, not what it replaces.194+func Complete(ctx context.Context, r Runner, script string, offset int) ([]string, error) {195+ out, err := ask(ctx, r, script, "--ide-complete", strconv.Itoa(offset))196+ if err != nil {197+ return nil, err198+ }199+ return ParseComplete(out)200+}201+202+// ParseComplete reads what --ide-complete printed.203+func ParseComplete(out []byte) ([]string, error) {204+ if len(out) == 0 {205+ return nil, nil206+ }207+ var c struct {208+ Completions *[]string209+ }210+ if err := json.Unmarshal(out, &c); err != nil || c.Completions == nil {211+ return nil, fmt.Errorf("%w: %s", ErrOld, firstLine(out))212+ }213+ return *c.Completions, nil214+}215+216+func firstLine(b []byte) string {217+ line, _, _ := bytes.Cut(b, []byte("\n"))218+ return string(line)219+}@@ -0,0 +1,127 @@
1+package nushell2+3+import (4+ "context"5+ "errors"6+ "os"7+ "os/exec"8+ "path/filepath"9+ "slices"10+ "strings"11+ "testing"12+)13+14+// testdata/ide holds nu 0.116's answers about the scripts beside them15+// (ast.nu, check.nu, complete.nu at its end): what 012 relies on nu16+// saying.17+func recorded(t *testing.T, name string) []byte {18+ t.Helper()19+ data, err := os.ReadFile(filepath.Join("testdata", "ide", name))20+ if err != nil {21+ t.Fatal(err)22+ }23+ return data24+}25+26+func TestParseAST(t *testing.T) {27+ shapes, err := ParseAST(recorded(t, "ast.json"))28+ if err != nil {29+ t.Fatal(err)30+ }31+ src := string(recorded(t, "ast.nu"))32+ var got []string33+ for _, s := range shapes {34+ got = append(got, src[s.From:s.To]+"="+strings.TrimPrefix(s.Shape, "shape_"))35+ }36+ want := []string{"ls=internalcall", "|=pipe", "where=internalcall", "size=string", "size=variable", ">=operator",37+ "1=int", "kb=string", "|=pipe", "sort-by=internalcall", "name=string", "--reverse=flag"}38+ if !slices.Equal(got, want) {39+ t.Errorf("shapes\n got %q\nwant %q", got, want)40+ }41+ if _, err := ParseAST([]byte("Error: unknown flag")); !errors.Is(err, ErrOld) {42+ t.Errorf("unreadable answer: %v", err)43+ }44+}45+46+func TestParseCheck(t *testing.T) {47+ probs, err := ParseCheck(recorded(t, "check.jsonl"))48+ if err != nil {49+ t.Fatal(err)50+ }51+ src := string(recorded(t, "check.nu"))52+ var got []string53+ for _, p := range probs {54+ got = append(got, src[p.From:p.To]+": "+p.Msg)55+ }56+ want := []string{"(: Unclosed delimiter.", "--foo: The `math sum` command doesn't have flag `foo`.", ": Unclosed delimiter.",57+ "(: Type mismatch.", "where: Command does not support number input."}58+ if !slices.Equal(got, want) {59+ t.Errorf("problems\n got %q\nwant %q", got, want)60+ }61+ probs, err = ParseCheck([]byte(`{"type":"hint","typename":"any","position":{"start":4,"end":6}}` + "\n"))62+ if err != nil || len(probs) != 0 {63+ t.Errorf("hints are left out: %v %v", probs, err)64+ }65+}66+67+func TestParseComplete(t *testing.T) {68+ got, err := ParseComplete(recorded(t, "complete.json"))69+ if err != nil || !slices.Equal(got, []string{"sort", "sort-by", "source", "source-env"}) {70+ t.Errorf("completions %q %v", got, err)71+ }72+ if got, err := ParseComplete([]byte(`{"completions": []}`)); err != nil || len(got) != 0 {73+ t.Errorf("none %q %v", got, err)74+ }75+ if _, err := ParseComplete([]byte(`{"other": 1}`)); !errors.Is(err, ErrOld) {76+ t.Errorf("unreadable answer: %v", err)77+ }78+}79+80+func TestVersion(t *testing.T) {81+ f := &fake{out: string(recorded(t, "version.txt"))}82+ if v, err := Version(context.Background(), f); err != nil || v != "0.116.0" || !slices.Equal(f.job.IDE, []string{"--version"}) {83+ t.Errorf("version %q %v %q", v, err, f.job.IDE)84+ }85+ if _, err := Version(context.Background(), &fake{out: "0.99.1\n"}); !errors.Is(err, ErrOld) {86+ t.Errorf("an old nu: %v", err)87+ }88+ for v, want := range map[string]bool{"0.100.0": true, "0.117.0-nightly.3": true, "1.0.0": true, "0.99.9": false, "nu": false} {89+ if AtLeast(v, MinVersion) != want {90+ t.Errorf("AtLeast(%q) = %v", v, !want)91+ }92+ }93+}94+95+// The script asked about reaches the runner as it is, with the flags.96+func TestAskHandsTheScript(t *testing.T) {97+ f := &fake{out: `{"completions": ["sort"]}`}98+ if _, err := Complete(context.Background(), f, "ls | so", 7); err != nil {99+ t.Fatal(err)100+ }101+ if f.script != "ls | so" || !slices.Equal(f.job.IDE, []string{"--ide-complete", "7"}) {102+ t.Errorf("script %q flags %q", f.script, f.job.IDE)103+ }104+}105+106+// The real nu, when it's installed, says what the recorded answers do.107+func TestNuIDE(t *testing.T) {108+ if _, err := exec.LookPath("nu"); err != nil {109+ t.Skip("nu isn't installed")110+ }111+ ctx := context.Background()112+ if _, err := Version(ctx, Nu{}); err != nil {113+ t.Skipf("nu: %v", err)114+ }115+ shapes, err := AST(ctx, Nu{}, string(recorded(t, "ast.nu")))116+ if err != nil || len(shapes) < 5 || shapes[0] != (Shape{0, 2, "shape_internalcall"}) {117+ t.Errorf("ast %v %v", shapes, err)118+ }119+ probs, err := Check(ctx, Nu{}, string(recorded(t, "check.nu")), 20)120+ if err != nil || !slices.ContainsFunc(probs, func(p Problem) bool { return strings.Contains(p.Msg, "flag `foo`") }) {121+ t.Errorf("check %v %v", probs, err)122+ }123+ comp, err := Complete(ctx, Nu{}, "ls | so", 7)124+ if err != nil || !slices.Contains(comp, "sort-by") {125+ t.Errorf("complete %q %v", comp, err)126+ }127+}@@ -33,6 +33,11 @@ type Job struct {
3333 Tables map[string][]byte3434 // Config runs nu with the user's config files rather than without.3535 Config bool36+ // IDE asks nu about the script rather than running it: the flags37+ // that ask (--ide-ast, or --ide-complete 14), nu reading the script38+ // from a file named after them, without config files or the39+ // standard library (see ide.go).40+ IDE []string3641 }3742 3843 // Runner runs a job's script, writing what it prints to stdout. A test@@ -171,9 +176,9 @@ func (n Nu) Run(ctx context.Context, job Job, script string, stdout io.Writer) e
171176 }172177 env = append(env, "NU012_TABLE_"+strconv.Itoa(i)+"="+file)173178 }174- args := []string{"-c", script}175- if !job.Config {176- args = append([]string{"--no-config-file"}, args...)179+ args, err := nuArgs(job, script, dir)180+ if err != nil {181+ return err177182 }178183 cmd := exec.CommandContext(ctx, path, args...)179184 cmd.Env, cmd.Stdout = env, stdout@@ -190,6 +195,23 @@ func (n Nu) Run(ctx context.Context, job Job, script string, stdout io.Writer) e
190195 return nil191196 }192197 198+// nuArgs is nu's command line for job: the script to run, or the flags199+// that ask about it, the script in a file in dir.200+func nuArgs(job Job, script, dir string) ([]string, error) {201+ if len(job.IDE) > 0 {202+ file := dir + string(os.PathSeparator) + "cell.nu"203+ if err := os.WriteFile(file, []byte(script), 0o600); err != nil {204+ return nil, err205+ }206+ return append(append([]string{"--no-config-file", "--no-std-lib"}, job.IDE...), file), nil207+ }208+ args := []string{"-c", script}209+ if !job.Config {210+ args = append([]string{"--no-config-file"}, args...)211+ }212+ return args, nil213+}214+193215 // Message is the line of nu's standard error that says what went wrong:194216 // the one marked ×, or the first, or how the process ended.195217 func Message(stderr string, err error) string {@@ -0,0 +1,6 @@
1+{"type":"diagnostic","severity":"Error","message":"Unclosed delimiter.","span":{"start":31,"end":32}}2+{"type":"diagnostic","severity":"Error","message":"The `math sum` command doesn't have flag `foo`.","span":{"start":17,"end":22}}3+{"type":"diagnostic","severity":"Error","message":"Unclosed delimiter.","span":{"start":31,"end":32}}4+{"type":"diagnostic","severity":"Error","message":"Unclosed delimiter.","span":{"start":32,"end":32}}5+{"type":"diagnostic","severity":"Error","message":"Type mismatch.","span":{"start":31,"end":32}}6+{"type":"diagnostic","severity":"Error","message":"Command does not support number input.","span":{"start":25,"end":30}}@@ -0,0 +1 @@
1+0.116.0@@ -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}@@ -457,3 +457,26 @@ func TestMonochromeBarsIconsChips(t *testing.T) {
457457 t.Errorf("chip %q %+v", got, c1[2])458458 }459459 }460+461+// A problem nu finds in the cell being written is underlined, curly as462+// an error cell is, and in words on the context line with the caret on463+// it.464+func TestMonochromeNuProblem(t *testing.T) {465+ nu := &ideNu{fakeNu: &fakeNu{}, problem: "--revrse"}466+ m := newModel()467+ m.SetShellRunner(nu)468+ m.SetMachine("here")469+ run(m, m.runCommand("nb.open"))470+ press(t, m, "b", "<enter>")471+ send(m, pasteMsg("ls | sort-by name --revrse"))472+ l := findLine(t, m, "sort-by")473+ if c := cellsOf(l, "--revrse"); !every(c, true, func(c monoCell) bool { return c.underline == "3" }) {474+ t.Errorf("the problem isn't underlined: %+v", c)475+ }476+ if c := cellsOf(l, "name"); every(c, true, func(c monoCell) bool { return c.underline == "3" }) {477+ t.Error("what's fine is underlined")478+ }479+ if !strings.Contains(cellText(monoLine(m, contextLine)), "Not a flag.") {480+ t.Errorf("the context line: %q", cellText(monoLine(m, contextLine)))481+ }482+}@@ -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,94 @@
1+package ui2+3+import (4+ "bytes"5+ "context"6+ "fmt"7+ "io"8+ "slices"9+ "strings"10+ "testing"11+12+ tea "charm.land/bubbletea/v2"13+14+ "github.com/FelineStateMachine/012/internal/nushell"15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+// ideNu is fakeNu answering the code editor's questions too, as nu19+// 0.116 would about any pipeline, and noting them. Its check finds20+// problem wherever the pipeline has it.21+type ideNu struct {22+ *fakeNu23+ ide []string24+ problem string25+}26+27+func (f *ideNu) Run(ctx context.Context, job nushell.Job, script string, stdout io.Writer) error {28+ if len(job.IDE) == 0 {29+ return f.fakeNu.Run(ctx, job, script, stdout)30+ }31+ f.ide = append(f.ide, job.IDE[0])32+ answer := map[string]string{"--version": "0.116.0", "--ide-ast": "[]", "--ide-complete": `{"completions": []}`}[job.IDE[0]]33+ if i := strings.Index(script, f.problem); job.IDE[0] == "--ide-check" && f.problem != "" && i >= 0 {34+ answer = fmt.Sprintf(`{"type":"diagnostic","severity":"Error","message":"Not a flag.","span":{"start":%d,"end":%d}}`, i, i+len(f.problem))35+ }36+ _, err := io.WriteString(stdout, answer)37+ return err38+}39+40+// asked are the questions nu was asked since the last call.41+func (f *ideNu) asked() []string {42+ out := f.ide43+ f.ide = nil44+ return out45+}46+47+// Writing a cell asks nu about it once typing pauses; a file from48+// another computer isn't asked about until its cells are trusted, and49+// 012 serve asks nu only as serve-shell allows.50+func TestNotebookAsksNuAsTrusted(t *testing.T) {51+ nu := &ideNu{fakeNu: &fakeNu{out: map[string]string{"ls": lsOut}}}52+ m := newModel()53+ m.SetShellRunner(nu)54+ m.SetMachine("here")55+ m.nb.still = true56+ run(m, m.runCommand("nb.open"))57+ press(t, m, "b", "<enter>", "l")58+ if got := nu.asked(); !slices.Contains(got, "--ide-ast") || !slices.Contains(got, "--ide-check") {59+ t.Fatalf("a notebook made here: asked %v", got)60+ }61+ press(t, m, "<esc>")62+63+ var b bytes.Buffer64+ if err := m.book().Write(&b); err != nil {65+ t.Fatal(err)66+ }67+ s, err := sheet.Read(strings.NewReader(strings.Replace(b.String(), `"macroOrigin": "here"`, `"macroOrigin": "elsewhere"`, 1)))68+ if err != nil {69+ t.Fatal(err)70+ }71+ m2 := New(s, "")72+ m2.Update(tea.WindowSizeMsg{Width: 80, Height: 20})73+ m2.SetShellRunner(nu)74+ m2.SetMachine("here")75+ m2.nb.still = true76+ run(m2, m2.runCommand("nb.open"))77+ press(t, m2, "<enter>", "s")78+ if got := nu.asked(); len(got) != 0 {79+ t.Errorf("a file from elsewhere, not trusted: asked %v", got)80+ }81+ press(t, m2, "<esc>")82+ run(m2, m2.runCommand("nb.run_all"))83+ press(t, m2, "<enter>") // Run this file's notebook cells? Run84+ press(t, m2, "<enter>", "s")85+ if got := nu.asked(); !slices.Contains(got, "--ide-ast") {86+ t.Errorf("trusted: asked %v", got)87+ }88+ press(t, m2, "<esc>")89+ m2.nb.served = true90+ press(t, m2, "<enter>", "s")91+ if got := nu.asked(); len(got) != 0 {92+ t.Errorf("served without serve-shell: asked %v", got)93+ }94+}@@ -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#saving-and-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