cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

nushell: nu --ide-hover, and a command's help read from its hover

Dami committed e6a3731
HoverAt asks nu about the word at a byte; ParseHelp reads a command's
help from the Markdown nu answers with: its usage, flags, parameters,
types and examples, and the signature and docs URL built from them.
Answers of nu 0.116 are recorded in testdata/ide and pinned against
the real nu.
commit
e6a3731a93c7f99c943ee2f28a592038717add7e
parent
20d8b4d6b627a301963de15727f4f71c7b91d4fa
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 0.6 of 3, Jev's estimate
8 files changed, +375 -0
Ainternal/nushell/hover.go275++++++++++++++++++++++++++++++
Ainternal/nushell/hover_test.go94++++++++++
Ainternal/nushell/testdata/ide/hover-flag.json1+
Ainternal/nushell/testdata/ide/hover-into-string.json1+
Ainternal/nushell/testdata/ide/hover-none.json0
Ainternal/nushell/testdata/ide/hover-sort-by.json1+
Ainternal/nushell/testdata/ide/hover-variable.json1+
Ainternal/nushell/testdata/ide/hover.nu2+
A internal/nushell/hover.go +275 -0
@@ -0,0 +1,275 @@
1+package nushell2+3+import (4+ "cmp"5+ "context"6+ "encoding/json"7+ "fmt"8+ "strconv"9+ "strings"10+)11+12+// What nu says of the word at a byte of a script, through13+//14+// nu --ide-hover 7 file {"hover":"Sort by the given cell path or closure.\n### Usage\n...","span":{"start":5,"end":12}}15+//16+// A command's hover is its help in Markdown (Help reads it), a17+// variable's is its type ("int"), a flag's is just "flag", and a byte18+// on no word prints nothing. nu --lsp answers the same through the19+// language server's protocol; a process per question, as the other20+// questions are, needs no session with it.21+22+// Hover is nu's text about the word at a byte, and the word's bytes.23+type Hover struct {24+ From, To int25+ Text string26+}27+28+// HoverAt is what nu says of the word at offset, a byte of script;29+// false when it's on none.30+func HoverAt(ctx context.Context, r Runner, script string, offset int) (Hover, bool, error) {31+ out, err := ask(ctx, r, script, "--ide-hover", strconv.Itoa(offset))32+ if err != nil {33+ return Hover{}, false, err34+ }35+ return ParseHover(out)36+}37+38+// ParseHover reads what --ide-hover printed.39+func ParseHover(out []byte) (Hover, bool, error) {40+ if len(out) == 0 {41+ return Hover{}, false, nil42+ }43+ var h struct {44+ Hover *string45+ Span *span46+ }47+ if err := json.Unmarshal(out, &h); err != nil || h.Hover == nil || h.Span == nil {48+ return Hover{}, false, fmt.Errorf("%w: %s", ErrOld, firstLine(out))49+ }50+ return Hover{From: h.Span.Start, To: h.Span.End, Text: *h.Hover}, true, nil51+}52+53+// Help is a command's help, as its hover has it.54+type Help struct {55+ Name string // sort-by, str join56+ Desc string // what it does, its paragraphs57+ Usage string // sort-by {flags} <...comparator>58+ // Flags are its flags but --help; Params its positional59+ // parameters, in order.60+ Flags []Flag61+ Params []Param62+ // Types are its input and output types, "table | table".63+ Types []string64+ Examples []Example65+}66+67+// Flag is a command's flag: -r, --reverse, the type of its value if it68+// takes one, and what it does.69+type Flag struct {70+ Short, Long, Arg, Desc string71+}72+73+// Param is a positional parameter: comparator, its type as nu writes it74+// (oneof<cell-path, closure>), and what it is.75+type Param struct {76+ Name, Type, Desc string77+}78+79+// Example is one of a command's examples: what it does, and the code.80+type Example struct {81+ Desc, Code string82+}83+84+// ParseHelp reads a command's hover; false when it isn't one (a85+// variable's type, a flag's "flag").86+func ParseHelp(md string) (Help, bool) {87+ desc, rest, ok := strings.Cut(md, "### Usage")88+ if !ok {89+ return Help{}, false90+ }91+ h := Help{Desc: strings.TrimSpace(desc)}92+ for _, sec := range strings.Split("### Usage"+rest, "\n### ") {93+ head, body, _ := strings.Cut(strings.TrimPrefix(sec, "### "), "\n")94+ switch strings.TrimSpace(head) {95+ case "Usage":96+ h.Usage = strings.TrimSpace(strings.Trim(strings.TrimSpace(body), "`"))97+ h.Name = commandName(h.Usage)98+ case "Flags":99+ h.Flags = parseFlags(body)100+ case "Parameters":101+ h.Params = parseParams(body)102+ case "Input/output types":103+ h.Types = fenced(body)104+ case "Example(s)":105+ h.Examples = parseExamples(body)106+ }107+ }108+ return h, h.Name != ""109+}110+111+// commandName is the words of usage before its flags and parameters.112+func commandName(usage string) string {113+ var words []string114+ for w := range strings.FieldsSeq(usage) {115+ if strings.ContainsAny(w[:1], "{<(.[") {116+ break117+ }118+ words = append(words, w)119+ }120+ return strings.Join(words, " ")121+}122+123+// fenced are the lines of body's first code fence, trimmed.124+func fenced(body string) []string {125+ var out []string126+ in := false127+ for line := range strings.SplitSeq(body, "\n") {128+ t := strings.TrimSpace(line)129+ switch {130+ case strings.HasPrefix(t, "```") && in:131+ return out132+ case strings.HasPrefix(t, "```"):133+ in = true134+ case in && t != "":135+ out = append(out, t)136+ }137+ }138+ return out139+}140+141+// ticked splits `-r`, `--reverse` `<int>` - Sort in reverse order.\142+// into its backquoted parts and what follows " - ".143+func ticked(line string) ([]string, string) {144+ line = strings.TrimSuffix(strings.TrimSpace(line), "\\")145+ var parts []string146+ for strings.HasPrefix(line, "`") {147+ end := strings.Index(line[1:], "`")148+ if end < 0 {149+ break150+ }151+ parts = append(parts, line[1:end+1])152+ line = strings.TrimLeft(line[end+2:], ", ")153+ }154+ return parts, strings.TrimSpace(strings.TrimPrefix(line, "- "))155+}156+157+func parseFlags(body string) []Flag {158+ var out []Flag159+ for line := range strings.SplitSeq(body, "\n") {160+ parts, desc := ticked(line)161+ var f Flag162+ for _, p := range parts {163+ switch {164+ case strings.HasPrefix(p, "--"):165+ f.Long = p166+ case strings.HasPrefix(p, "-"):167+ f.Short = p168+ case strings.HasPrefix(p, "<"):169+ f.Arg = strings.Trim(p, "<>")170+ }171+ }172+ if (f.Long != "" || f.Short != "") && f.Long != "--help" {173+ f.Desc = desc174+ out = append(out, f)175+ }176+ }177+ return out178+}179+180+func parseParams(body string) []Param {181+ var out []Param182+ for line := range strings.SplitSeq(body, "\n") {183+ parts, desc := ticked(line)184+ if len(parts) == 0 {185+ continue186+ }187+ name, typ, _ := strings.Cut(parts[0], ":")188+ out = append(out, Param{Name: strings.TrimSpace(name), Type: strings.TrimSpace(typ), Desc: desc})189+ }190+ return out191+}192+193+// parseExamples reads the examples: an empty fence, then each194+// example's description outside a fence and its code inside one.195+func parseExamples(body string) []Example {196+ segs := strings.Split(body, "```")197+ var out []Example198+ for i := 2; i+1 < len(segs); i += 2 {199+ desc := strings.TrimSpace(segs[i])200+ var code []string201+ for line := range strings.SplitSeq(strings.Trim(segs[i+1], "\n"), "\n") {202+ code = append(code, strings.TrimPrefix(line, " "))203+ }204+ c := strings.TrimSpace(strings.Join(code, "\n"))205+ if desc != "" || c != "" {206+ out = append(out, Example{Desc: desc, Code: c})207+ }208+ }209+ return out210+}211+212+// Summary is the first paragraph of the description.213+func (h Help) Summary() string {214+ first, _, _ := strings.Cut(h.Desc, "\n\n")215+ return strings.Join(strings.Fields(first), " ")216+}217+218+// Signature is the command's usage with its parameters' types and its219+// flags by name: sort-by <...comparator: cell-path|closure> --reverse.220+func (h Help) Signature() string {221+ sig := []string{h.Name}222+ rest := strings.TrimSpace(strings.TrimPrefix(h.Usage, h.Name))223+ for w := range strings.FieldsSeq(rest) {224+ if w == "{flags}" {225+ continue226+ }227+ if p, ok := h.param(w); ok && p.Type != "" {228+ w = w[:len(w)-1] + ": " + typeWords(p.Type) + w[len(w)-1:]229+ }230+ sig = append(sig, w)231+ }232+ for _, f := range h.Flags {233+ name := cmp.Or(f.Long, f.Short)234+ if f.Arg != "" {235+ name += " <" + f.Arg + ">"236+ }237+ sig = append(sig, name)238+ }239+ return strings.Join(sig, " ")240+}241+242+// param is the parameter usage's word w names: <...comparator>,243+// <rows?>.244+func (h Help) param(w string) (Param, bool) {245+ name := strings.Trim(w, "<>()[]?.")246+ for _, p := range h.Params {247+ if strings.Trim(p.Name, ".?") == name {248+ return p, true249+ }250+ }251+ return Param{}, false252+}253+254+// typeWords writes oneof<int, filesize> as int|filesize.255+func typeWords(t string) string {256+ if in, ok := strings.CutPrefix(t, "oneof<"); ok {257+ return strings.ReplaceAll(strings.TrimSuffix(in, ">"), ", ", "|")258+ }259+ return t260+}261+262+// Flag is the command's flag named w, long (--reverse) or short (-r).263+func (h Help) Flag(w string) (Flag, bool) {264+ for _, f := range h.Flags {265+ if w != "" && (f.Long == w || f.Short == w) {266+ return f, true267+ }268+ }269+ return Flag{}, false270+}271+272+// DocsURL is the page of nushell's documentation for command name.273+func DocsURL(name string) string {274+ return "https://www.nushell.sh/commands/docs/" + strings.ReplaceAll(name, " ", "_") + ".html"275+}
A internal/nushell/hover_test.go +94 -0
@@ -0,0 +1,94 @@
1+package nushell2+3+import (4+ "context"5+ "errors"6+ "os/exec"7+ "slices"8+ "strings"9+ "testing"10+)11+12+// hoverAsked are the bytes of testdata/ide/hover.nu that hover-*.json13+// record nu's hover at.14+var hoverAsked = map[string]int{"sort-by": 17, "flag": 30, "into-string": 45, "variable": 76, "none": 15}15+16+func TestParseHover(t *testing.T) {17+ src := string(recorded(t, "hover.nu"))18+ for name, want := range map[string]string{"sort-by": "sort-by", "flag": "--reverse", "into-string": "into string", "variable": "$n"} {19+ h, ok, err := ParseHover(recorded(t, "hover-"+name+".json"))20+ if err != nil || !ok || src[h.From:h.To] != want {21+ t.Errorf("%s: %+v %v %v", name, h, ok, err)22+ }23+ }24+ if h, ok, err := ParseHover(recorded(t, "hover-variable.json")); !ok || err != nil || h.Text != "int" {25+ t.Errorf("a variable's type: %q", h.Text)26+ }27+ if _, ok, err := ParseHover(recorded(t, "hover-none.json")); ok || err != nil {28+ t.Errorf("no word: %v %v", ok, err)29+ }30+ if _, _, err := ParseHover([]byte(`{"other": 1}`)); !errors.Is(err, ErrOld) {31+ t.Errorf("unreadable answer: %v", err)32+ }33+}34+35+func TestParseHelp(t *testing.T) {36+ h, _, _ := ParseHover(recorded(t, "hover-sort-by.json"))37+ help, ok := ParseHelp(h.Text)38+ if !ok || help.Name != "sort-by" || help.Summary() != "Sort by the given cell path or closure." {39+ t.Fatalf("help %+v", help)40+ }41+ if got := help.Signature(); got != "sort-by <...comparator: cell-path|closure> --reverse --ignore-case --natural --custom" {42+ t.Errorf("signature %q", got)43+ }44+ if f, ok := help.Flag("-r"); !ok || f.Long != "--reverse" || f.Desc != "Sort in reverse order." {45+ t.Errorf("-r: %+v", f)46+ }47+ if _, ok := help.Flag("--help"); ok {48+ t.Error("--help is every command's")49+ }50+ if !slices.Equal(help.Types, []string{"list<any> | list<any>", "record | table", "table | table"}) {51+ t.Errorf("types %q", help.Types)52+ }53+ if len(help.Examples) != 6 || help.Examples[0] != (Example{"Sort files by modified date.", "ls | sort-by modified"}) {54+ t.Errorf("examples %q", help.Examples)55+ }56+ h, _, _ = ParseHover(recorded(t, "hover-into-string.json"))57+ help, _ = ParseHelp(h.Text)58+ if got := help.Signature(); got != "into string <...rest: cell-path> --group-digits --decimals <int>" {59+ t.Errorf("signature %q", got)60+ }61+ if help.Name != "into string" || DocsURL(help.Name) != "https://www.nushell.sh/commands/docs/into_string.html" {62+ t.Errorf("docs of %q: %s", help.Name, DocsURL(help.Name))63+ }64+ if _, ok := ParseHelp("int"); ok {65+ t.Error("a variable's type read as help")66+ }67+}68+69+// A command of the script's own, without a description.70+func TestParseHelpOfADef(t *testing.T) {71+ help, ok := ParseHelp("\n### Usage\n```\n greet {flags} <name>\n```\n\n### Flags\n\n `--loud`\\\n `-h`, `--help` - Display the help message for this command\n\n### Parameters\n\n `name: string`\n\n")72+ if !ok || help.Desc != "" || help.Signature() != "greet <name: string> --loud" {73+ t.Errorf("help %+v %q", help, help.Signature())74+ }75+}76+77+// The real nu, when it's the version recorded, says what hover-*.json78+// record.79+func TestNuHover(t *testing.T) {80+ if _, err := exec.LookPath("nu"); err != nil {81+ t.Skip("nu isn't installed")82+ }83+ if v, err := Version(context.Background(), Nu{}); err != nil || v != strings.TrimSpace(string(recorded(t, "version.txt"))) {84+ t.Skipf("nu %s %v isn't the version recorded", v, err)85+ }86+ src := string(recorded(t, "hover.nu"))87+ for name, at := range hoverAsked {88+ got, gotOK, err := HoverAt(context.Background(), Nu{}, src, at)89+ want, wantOK, _ := ParseHover(recorded(t, "hover-"+name+".json"))90+ if err != nil || got != want || gotOK != wantOK {91+ t.Errorf("%s: nu says %+v %v %v, recorded %+v", name, got, gotOK, err, want)92+ }93+ }94+}
A internal/nushell/testdata/ide/hover-flag.json +1 -0
A internal/nushell/testdata/ide/hover-into-string.json +1 -0
@@ -0,0 +1 @@
1+{"hover":"Convert value to a string.\n### Usage\n```\n into string {flags} <...rest>\n```\n\n### Flags\n\n `-h`, `--help` - Display the help message for this command\\\n `-g`, `--group-digits` - Group digits together by the locale specific thousands separator.\\\n `-d`, `--decimals` `<int>` - Decimal digits to which to round.\n\n### Parameters\n\n `...rest: cell-path` - For a data structure input, convert data at the given cell paths.\n\n\n### Input/output types\n\n```\n binary | string\n int | string\n number | string\n string | string\n glob | string\n bool | string\n filesize | string\n datetime | string\n duration | string\n cell-path | string\n range | string\n semver | string\n semver-range | string\n list<any> | list<string>\n table | table\n record | record\n\n```\n### Example(s)\n```\n```\n convert int to string and append three decimal places.\n```\n 5 | into string --decimals 3\n\n```\n convert float to string and round to nearest integer.\n```\n 1.7 | into string --decimals 0\n\n```\n convert float to string.\n```\n 1.7 | into string --decimals 1\n\n```\n convert float to string and limit to 2 decimals.\n```\n 1.734 | into string --decimals 2\n\n```\n convert float to string.\n```\n 4.3 | into string\n\n```\n convert string to string.\n```\n '1234' | into string\n\n```\n convert boolean to string.\n```\n true | into string\n\n```\n convert date to string.\n```\n '2020-10-10 10:00:00 +02:00' | into datetime | into string\n\n```\n convert filepath to string.\n```\n ls Cargo.toml | get name | into string\n\n```\n convert filesize to string.\n```\n 1kB | into string\n\n```\n convert duration to string.\n```\n 9day | into string\n\n```\n convert cell-path to string.\n```\n $.name | into string\n\n```\n convert semver to string.\n```\n '1.2.3' | into semver | into string\n\n","span":{"start":41,"end":52}}
A internal/nushell/testdata/ide/hover-none.json +0 -0

File mode 100644

A internal/nushell/testdata/ide/hover-sort-by.json +1 -0
A internal/nushell/testdata/ide/hover-variable.json +1 -0
A internal/nushell/testdata/ide/hover.nu +2 -0