From 8694cb0d18e907dcadff337aa68e9145e92374cc Mon Sep 17 00:00:00 2001 From: Benjamin VAUDOUR Date: Sat, 2 Mar 2024 15:05:33 +0100 Subject: [PATCH] Commit initial --- .gitignore | 1 + LICENSE | 11 ++ README.md | 78 +++++++++ completer.go | 210 +++++++++++++++++++++++ fishline.go | 462 +++++++++++++++++++++++++++++++++++++++++++++++++++ formater.go | 97 +++++++++++ go.mod | 5 + go.sum | 2 + splitter.go | 161 ++++++++++++++++++ 9 files changed, 1027 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 completer.go create mode 100644 fishline.go create mode 100644 formater.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 splitter.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee4c926 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/test diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a3094a --- /dev/null +++ b/LICENSE @@ -0,0 +1,11 @@ +DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE +Version 2, December 2004 + +Copyright (C) 2004 Sam Hocevar + +Everyone is permitted to copy and distribute verbatim or modified copies of this license document, and changing it is allowed as long as the name is changed. + +DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/README.md b/README.md new file mode 100644 index 0000000..56322e5 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# gfishline + +Gfishline est un prompt Linux inspiré de fish. + +Il supporte la coloration syntaxique et l’autocomplétion. + +## Utilisation typique + +Gfishline peut être utilisé de la façon suivante : + +```go +package main + +import ( + "fmt" + "os" + "gitea.zaclys.net/bvaudour/gfishline" +) + +func main() { + prompt := "\033[1;31m> \033[m" + fl := gfishline.New() + + // Les deux lignes ci-dessous permettent d’utiliser la complétion et le formatage par défaut. + // + // Pour une complétion plus élaborée, utilisez fl.SetCompleter(cb) où cb est une fonction de la forme + // `func (word []string, wordIndex []string, isSpaceIndex bool) (choices []string)`. + // + // Pour un formatage plus élaboré, utilisez fl.SetFormater(cb) où cb est une fonction de la forme + // `func (in [string) (out [string])`. + fl.SetCommands("help", "exit", "print") + fl.SetOptions("--help", "-h") + + defer fl.Close() + for { + input := fl.Prompt(prompt) // fl.PromptPassword(prompt) pour masquer ce qui est saisi + if result, ok := input.Ok(); ok { + //@TODO Do some stuff with input + if result == "exit" { + return + } + } else if err, ok := input.Err(); ok { + fmt.Printf("\033[1,31mErreur système : %s\033[m\n", err) + os.Exit(1) + } + } +} +``` + + + +## Raccourcis clavier disponibles + +| Raccourci clavier | Action | +| -------------------------------------- | ------------------------------------------------------------ | +| Ctrl+A, Home | Retour au début de la saisie | +| Ctrl+E, End | Retour à la fin de la saisie | +| Ctrl+B, Left | Déplacement d’un caractère vers la gauche | +| Ctrl+F, Right | Déplacement d’un caractère vers la droite | +| Alt+B, Ctrl+Left | Déplacement d’un mot vers la gauche | +| Alt+F, Ctrl+Right | Déplacement d’un mot vers la droite | +| Ctrl+U | Suppression de tous les caractères du début de la ligne au curseur (curseur non compris) | +| Ctrl+K | Suppression de tous les carctères du curseur (compris) à la fin de la ligne | +| Ctrl+D (si saisie commencée), Del | Suppression du caractère sous le curseur | +| Ctrl+D (si ligne vide) | Termine l’application (EOF) | +| Ctrl+H, Backspace | Suppression du caractère avant le curseur | +| Alt+D, Alt+Del | Suppression du prochain mot | +| Alt+Backspace | Suppression du précédent mot | +| Ctrl+C | Termine l’application (avec erreur) | +| Ctrl+P, Up (si ligne vide au départ) | Remonte à l’historique précédent | +| Ctrl+N, Down (si ligne vide au départ) | Descend dans l’historique | +| Ctrl+R, Up (si ligne non vide) | Recherche dans l’historique (par préfixe de ligne) à partir de la fin | +| Ctrl+S, Down (si ligne non vide) | Recherche dans l’historique (par préfixe de ligne) à partir du début | +| Ctrl+Y | Copie le dernier élément supprimé | +| Alt+Y | Remonte dans l’historique des éléments supprimés et les copie (implique Ctrl+Y précédemment lancé) | +| Ctrl+G, Cancel | Annule les dernières actions temporaires (historique, recherche, copie, historique de copie) | +| Tab | Complétion suivante | +| Shift+Tab | Complétion précédente | diff --git a/completer.go b/completer.go new file mode 100644 index 0000000..eced51e --- /dev/null +++ b/completer.go @@ -0,0 +1,210 @@ +package gfishline + +import ( + "fmt" + "slices" + "strings" + + "gitea.zaclys.com/bvaudour/gob/format" + "gitea.zaclys.com/bvaudour/gob/shell/console" + "gitea.zaclys.com/bvaudour/gob/shell/console/atom" +) + +func coordToIndex(x, y, c, n int) int { + l, r := n/c, n%c + switch { + case r == 0: + return x*l + y + case x < r: + return x*(l+1) + y + case y >= l: + return -1 + default: + return r*(l+1) + (x-r)*l + y + } +} + +func indexToCoord(i, c, n int) (x, y int) { + l, r := n/c, n%c + switch { + case r == 0: + x, y = i/l, i%l + case i < r*(l+1): + x, y = i/(l+1), i%(l+1) + default: + j := i - r*l + x, y = r+j/l, j%l + } + + return +} + +type CompleteFunc func(words []string, wordIndex int, isIndexSpace bool) (choices []string) + +func NoCompletion(words []string, wordIndex int, isIndexSpace bool) (choices []string) { + return +} + +func DefaultCompletion(commands, options []string) CompleteFunc { + return func(words []string, wordIndex int, isIndexSpace bool) (choices []string) { + if isIndexSpace { + if wordIndex <= 0 { + return commands + } + return options + } + + word, sl := words[wordIndex], options + if wordIndex == 0 { + sl = commands + } + + for _, choice := range sl { + if strings.HasPrefix(choice, word) { + choices = append(choices, choice) + } + } + + return + } +} + +type completer struct { + *splitter + wordIndex int + wordPos int + isInit bool + needAdd bool + choices console.Cycler +} + +func newCompleter(buffer string, cursor int, complete CompleteFunc) *completer { + c := completer{ + splitter: newSplitter(buffer, cursor), + isInit: true, + } + + wordIndex, wordCursor, isIndexSpace := c.compute() + if isIndexSpace && wordCursor == 0 && wordIndex > 0 { + isIndexSpace = false + c.cursor-- + } + + choices := complete(c.words, wordIndex, isIndexSpace) + slices.Sort(choices) + if !isIndexSpace { + current := c.words[wordIndex] + if slices.Contains(choices, current) { + choices = append([]string{current}, slices.DeleteFunc(choices, func(e string) bool { return e == current })...) + } + } + choices = slices.Compact(choices) + + c.choices = atom.NewCycler(true, choices...) + c.needAdd, c.wordIndex, c.wordPos = isIndexSpace, wordIndex, wordCursor + + return &c +} + +func (c *completer) update(next string) { + c.setWord(c.wordIndex, next) + + if c.isInit { + c.cursor += 2 + if _, ok := c.space[c.wordIndex+1]; !ok { + c.space[c.wordIndex+1] = " " + } + c.isInit = false + } +} + +func (c *completer) add(next string) { + c.addWord(c.wordIndex, next, c.wordPos) + c.isInit, c.needAdd = false, false +} + +func (c *completer) set() (ok bool) { + var next string + if next, ok = console.FocusedElement(c.choices).Get(); !ok { + return + } + + if c.needAdd { + c.add(next) + } else { + c.update(next) + } + + return +} + +func (c *completer) prev() bool { return c.choices.Prev() && c.set() } + +func (c *completer) next() bool { return c.choices.Next() && c.set() } + +func (c *completer) move(index int) bool { return c.choices.SetCursor(index) && c.set() } + +func (c *completer) formatChoices(currentWord string, terminalWidth, terminalHeight int) string { + var buf strings.Builder + l, cursor := c.choices.Len(), c.choices.Cursor() + if l == 0 { + return "" + } + + var maxLen int + choices := make([]string, l) + for i := 0; i < l; i++ { + word, _ := c.choices.Index(i).Get() + maxLen, choices[i] = max(atom.VisibleWidth(word)+1, maxLen), word + } + + maxCol := min(max(terminalWidth/maxLen, 1), 5) + lines := l / maxCol + if l%maxCol > 0 { + lines++ + } + lineMin, lineMax := 0, lines-1 + var isPartial bool + if isPartial = terminalHeight < lines; isPartial { + _, cursorLine := indexToCoord(cursor, maxCol, l) + if cursorLine < terminalHeight { + lineMax = min(terminalHeight, lines) - 1 + } else { + lineMin, lineMax = cursorLine+1-min(terminalHeight, lines), cursorLine + } + } + + colWith := terminalWidth / maxCol + if colWith > maxLen { + colWith = (colWith + maxLen) >> 1 + } + for y := lineMin; y <= lineMax; y++ { + if y > 0 { + buf.WriteRune('\n') + } + for x := 0; x < maxCol; x++ { + i := coordToIndex(x, y, maxCol, l) + if i >= 0 { + word := choices[i] + realSize, displaySize := len([]rune(word)), atom.VisibleWidth(word) + size := maxLen + realSize - displaySize + word = strings.TrimPrefix(format.Left(word, size), currentWord) + if i == cursor { + buf.WriteString(format.Apply(currentWord, "bg_l_white", "black", "bold", "underline")) + buf.WriteString(format.Apply(word, "bg_l_white", "black")) + } else { + buf.WriteString(format.Apply(currentWord, "bold", "underline")) + buf.WriteString(word) + } + } + } + } + + if isPartial { + position := fmt.Sprintf("lignes %d à %d (%d)", lineMin+1, lineMax+1, lines) + buf.WriteRune('\n') + buf.WriteString(format.Apply(position, "bg_l_cyan", "black")) + } + + return buf.String() +} diff --git a/fishline.go b/fishline.go new file mode 100644 index 0000000..76efeee --- /dev/null +++ b/fishline.go @@ -0,0 +1,462 @@ +package gfishline + +import ( + "os" + "strings" + + "gitea.zaclys.com/bvaudour/gob/format" + "gitea.zaclys.com/bvaudour/gob/option" + "gitea.zaclys.com/bvaudour/gob/shell/console/atom" +) + +type Fishline struct { + st *atom.State + complete option.Option[CompleteFunc] + format option.Option[FormatFunc] + commands []string + options []string + proposal string + currentWord string + completion option.Option[*completer] +} + +func New() *Fishline { + return &Fishline{ + st: atom.NewState(), + } +} + +func (fl *Fishline) SetCompleter(cb CompleteFunc) { + if cb == nil { + fl.complete = option.None[CompleteFunc]() + } else { + fl.complete = option.Some(cb) + } +} + +func (fl *Fishline) SetFormater(cb FormatFunc) { + if cb == nil { + fl.format = option.None[FormatFunc]() + } else { + fl.format = option.Some(cb) + } +} + +func (fl *Fishline) SetCommands(commands ...string) { + fl.commands = commands +} + +func (fl *Fishline) SetOptions(options ...string) { + fl.options = options +} + +func (fl *Fishline) isCompletionMode() bool { + return fl.completion.IsDefined() +} + +func (fl *Fishline) removeCompletion() (ok bool) { + if ok = fl.completion.IsDefined(); ok { + fl.completion = option.None[*completer]() + fl.currentWord = "" + } + + return +} + +func (fl *Fishline) initCompletion() (completion *completer) { + var ok bool + + if completion, ok = fl.completion.Get(); !ok { + buffer, cursor := fl.st.Buffer() + complete, exists := fl.complete.Get() + if !exists { + complete = DefaultCompletion(fl.commands, fl.options) + } + completion = newCompleter(buffer, cursor, complete) + fl.completion = option.Some(completion) + if !completion.needAdd && completion.wordIndex >= 0 && completion.wordIndex < len(completion.words) { + fl.currentWord = completion.words[completion.wordIndex] + } + } + + return completion +} + +func (fl *Fishline) setCompletion(completion *completer) { + fl.st.UnfocusYank() + fl.st.UnfocusHistory() + fl.st.SaveBuffer() + fl.st.RemoveHistorySearch() + + buffer, cursor := completion.buffer() + fl.st.SetBuffer(buffer) + fl.st.SetCursor(cursor) +} + +func (fl *Fishline) completionPrev() (ok bool) { + completion := fl.initCompletion() + + if ok = completion.prev(); ok { + fl.setCompletion(completion) + } + + return +} + +func (fl *Fishline) completionNext() (ok bool) { + completion := fl.initCompletion() + + if ok = completion.next(); ok { + fl.setCompletion(completion) + } + + return +} + +func (fl *Fishline) fixState() bool { + ok1 := fl.st.RemoveSavedBuffer() + ok2 := fl.st.RemoveHistorySearch() + ok3 := fl.st.UnfocusHistory() + ok4 := fl.st.UnfocusYank() + ok5 := fl.removeCompletion() + + return ok1 || ok2 || ok3 || ok4 || ok5 +} + +func (fl *Fishline) cancel() (ok bool) { + if ok = fl.st.Cancel(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) yankPrev() (ok bool) { + if ok = fl.st.YankPrev(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) yank() (ok bool) { + if ok = fl.st.Yank(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) historyPrev() (ok bool) { + if ok = fl.st.HistoryPrev(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) historyNext() (ok bool) { + if ok = fl.st.HistoryNext(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) searchHistoryPrev() (ok bool) { + if ok = fl.st.SearchMotivePrev(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) searchHistoryNext() (ok bool) { + if ok = fl.st.SearchMotiveNext(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) execChar(r atom.Char, isPassword bool) (fix, stop bool) { + if stop = r == '\n' || r == '\r'; stop { + sp := newSplitter(fl.st.Buffer()) + if stop = sp.isComplete(); stop { + fix, fl.proposal = true, "" + return + } + } + + if r != atom.Tab && (r == '\n' || atom.IsPrintable(r)) { + fl.st.Insert(r) + fix = true + if !isPassword { + fl.proposal = fl.st.GetProposal() + } + return + } + + var noBeep bool + var changeProposal int + switch r { + case atom.Bs, atom.C_H: // Back + noBeep, fix, changeProposal = fl.st.Back(), true, 1 + case atom.C_W: // Back word + noBeep, fix, changeProposal = fl.st.BackWord(), true, 1 + case atom.C_D: // Del or reset line + l := fl.st.BufferLen() + if l == 0 { + fl.st.Return() + os.Exit(0) + } else { + noBeep, fix, changeProposal = fl.st.Del(), true, 1 + fl.st.Restart() + } + case atom.C_U: // Remove BOL + noBeep, fix, changeProposal = fl.st.RemoveBegin(), true, 1 + case atom.C_K: // Remove EOL + noBeep, fix, changeProposal = fl.st.RemoveEnd(), true, 1 + case atom.C_A: // Move to BOL + noBeep, fix = fl.st.Begin(), true + fl.proposal = fl.st.GetProposal() + case atom.C_E: // Move to EOL + noBeep, fix = fl.st.End(), true + if !noBeep && fl.proposal != "" { + noBeep, changeProposal = true, -1 + fl.st.SetBuffer(fl.proposal) + } + case atom.C_B: // Move left + noBeep, fix = fl.st.Left(), true + case atom.C_F: // Move right + noBeep, fix = fl.st.Right(), true + if !noBeep && fl.proposal != "" { + noBeep, changeProposal = true, -1 + fl.st.SetBuffer(fl.proposal) + } + case atom.C_T: // Transpose + noBeep, fix, changeProposal = fl.st.Transpose(), true, 1 + case atom.C_P: // Previous history + if !isPassword { + noBeep, changeProposal = fl.historyPrev(), -1 + } + case atom.C_N: // Next history + if !isPassword { + noBeep, changeProposal = fl.historyNext(), -1 + } + case atom.C_S: // Search history + if !isPassword { + noBeep, changeProposal = fl.searchHistoryNext(), -1 + } + case atom.C_R: // Reverse search history + if !isPassword { + noBeep, changeProposal = fl.searchHistoryPrev(), -1 + } + case atom.C_G, atom.Esc: // Cancel history + noBeep, fix, changeProposal = fl.cancel(), true, 1 + case atom.C_Y: // Yank + noBeep, changeProposal = fl.yank(), 1 + case atom.C_C: // Emergency stop + fl.st.Return() + os.Exit(1) + case atom.Tab: + if !isPassword { + noBeep, changeProposal = fl.completionNext(), 1 + } + } + + if !noBeep { + fl.st.Beep() + } else { + if changeProposal > 0 { + fl.proposal = fl.st.GetProposal() + } else if changeProposal < 0 { + fl.proposal = "" + } + } + + return +} + +func (fl *Fishline) execSequence(s atom.Sequence, isPassword bool) (fix, stop bool) { + var noBeep bool + var changeProposal int + switch s { + case atom.Up: // Previous history + if !isPassword { + l := fl.st.BufferLen() + changeProposal = -1 + if l == 0 || fl.st.IsHistoryMode() { + noBeep = fl.historyPrev() + } else { + noBeep = fl.searchHistoryPrev() + } + } + case atom.Down: // Next history + if !isPassword { + l := fl.st.BufferLen() + changeProposal = -1 + if l == 0 || fl.st.IsHistoryMode() { + noBeep = fl.historyNext() + } else { + noBeep = fl.searchHistoryNext() + } + } + case atom.Right: // Move right + noBeep, fix = fl.st.Right(), true + if !noBeep && fl.proposal != "" { + noBeep, changeProposal = true, -1 + fl.st.SetBuffer(fl.proposal) + } + case atom.Left: // Move left + noBeep, fix = fl.st.Left(), true + case atom.Ins: // Toggle insert + fl.st.ToggleReplace() + noBeep = true + case atom.Del: // Delete under cursor + noBeep, fix, changeProposal = fl.st.Del(), true, 1 + case atom.Home: // Move BOL + noBeep, fix = fl.st.Begin(), true + case atom.End: // Move EOL + noBeep, fix = fl.st.End(), true + if !noBeep && fl.proposal != "" { + noBeep, changeProposal = true, -1 + fl.st.SetBuffer(fl.proposal) + } + case atom.A_Bs: // Delete begin of word + noBeep, fix, changeProposal = fl.st.BackWord(), true, 1 + case atom.A_D, atom.A_Del: // Delete end of word + noBeep, fix, changeProposal = fl.st.DelWord(), true, 1 + case atom.C_Right, atom.A_F: // Next word + noBeep, fix = fl.st.NextWord(), true + case atom.C_Left, atom.A_B: // Begin of word + noBeep, fix = fl.st.PrevWord(), true + case atom.A_Y: // Previous yank + noBeep, changeProposal = fl.yankPrev(), 1 + case atom.S_Tab: + noBeep, changeProposal = fl.completionPrev(), 1 + } + + if !noBeep { + fl.st.Beep() + } else { + if changeProposal > 0 { + fl.proposal = fl.st.GetProposal() + } else if changeProposal < 0 { + fl.proposal = "" + } + } + + return +} + +func (fl *Fishline) prompt(p string, isPassword bool) (result option.Result[string]) { + if err, ok := fl.st.SetPrompt(p).Get(); ok { + return option.Err[string](err) + } + + fl.st.Start() + defer fl.st.Stop() + + var refresh func() option.Option[error] + if isPassword { + refresh = func() option.Option[error] { return fl.st.Print("", 0) } + } else { + refresh = func() option.Option[error] { + buffer, cursor := fl.st.Buffer() + var output strings.Builder + if fl.st.IsHistorySearchMode() { + savedBuffer, _ := fl.st.SavedBuffer().Get() + if i := strings.Index(buffer, savedBuffer); i >= 0 { + output.WriteString(buffer[:i]) + output.WriteString(format.Apply(savedBuffer, "bg_l_black")) + output.WriteString(buffer[i+len(savedBuffer):]) + } else { + output.WriteString(buffer) + } + } else { + cb, ok := fl.format.Get() + if !ok { + cb = DefaultFormat(fl.commands, fl.options) + } + f := newFormater(buffer, cursor, cb) + output.WriteString(f.join()) + var suffix string + if fl.proposal != buffer && strings.HasPrefix(fl.proposal, buffer) { + suffix = strings.TrimPrefix(fl.proposal, buffer) + output.WriteString(format.Apply(suffix, "l_black")) + } + if c, ok := fl.completion.Get(); ok { + w, h := fl.st.Width(), fl.st.Height()-1 + wb := atom.VisibleWidth(fl.st.GetPrompt() + buffer + suffix) + hb := wb / w + if wb%w > 0 { + hb++ + } + choices := c.formatChoices(fl.currentWord, w, h-hb) + if choices != "" { + output.WriteString("\n") + output.WriteString(c.formatChoices(fl.currentWord, w, h-hb)) + } + } + } + + //return option.None[error]() + return fl.st.Print(output.String(), cursor) + } + } + refresh() + + var fix, stop, isErr bool + for { + n := fl.st.Next() + if err, ok := n.Err(); ok { + result, isErr = option.Err[string](err), true + break + } + + key, _ := n.Ok() + if s, ok := key.Sequence(); ok { + fix, stop = fl.execSequence(s, isPassword) + } else if c, ok := key.Char(); ok { + fix, stop = fl.execChar(c, isPassword) + } + + if fix { + fl.fixState() + } + + err := refresh() + if e, ok := err.Get(); ok { + result, isErr = option.Err[string](e), true + break + } + + if stop { + break + } + } + + if !isErr { + r, _ := fl.st.Buffer() + result = option.Ok(r) + if !isPassword && len(r) > 0 { + fl.st.AppendHistory(r) + } + } + + fl.st.Clear() + fl.fixState() + fl.st.Return() + + return +} + +// Prompt retourne la chaîne saisie. +func (fl *Fishline) Prompt(p string) option.Result[string] { return fl.prompt(p, false) } + +// PromptPassword agit comme Prompt mais n’affiche pas à l’écran ce qui est saisi. +func (fl *Fishline) PromptPassword(p string) option.Result[string] { return fl.prompt(p, true) } + +// Close ferme proprement le Readline. +func (fl *Fishline) Close() option.Option[error] { return fl.st.Close() } diff --git a/formater.go b/formater.go new file mode 100644 index 0000000..5114775 --- /dev/null +++ b/formater.go @@ -0,0 +1,97 @@ +package gfishline + +import ( + "slices" + "strconv" + "strings" + + "gitea.zaclys.com/bvaudour/gob/format" + "gitea.zaclys.com/bvaudour/gob/shell/scanner" +) + +var ( + specialChars = []string{"=", "*", "+", "-", ">", "<", "|", ">>", "<<"} +) + +func isString(arg string) bool { + if len(arg) > 0 && (arg[0] == '\'' || arg[0] == '"') { + sc := scanner.NewScanner(strings.NewReader(arg)) + return sc.Scan() + } + + return false +} + +func isIncompleteString(arg string) bool { + if len(arg) > 0 && (arg[0] == '\'' || arg[0] == '"') { + sc := scanner.NewScanner(strings.NewReader(arg)) + return !sc.Scan() + } + + return false +} + +func isNumber(arg string) bool { + _, err := strconv.ParseInt(arg, 10, 64) + if err != nil { + _, err = strconv.ParseFloat(arg, 64) + } + + return err == nil +} + +type FormatFunc func(in []string) (out []string) + +func NoFormat(in []string) (out []string) { + return in +} + +func DefaultFormat(commands, options []string) FormatFunc { + return func(in []string) (out []string) { + out = make([]string, len(in)) + for i, word := range in { + var formatOptions []string + switch { + case i == 0 && len(commands) > 0: + if slices.Contains(commands, word) { + formatOptions = append(formatOptions, "bold") + } else if isString(word) { + formatOptions = append(formatOptions, "yellow") + } else { + formatOptions = append(formatOptions, "l_red", "normal") + } + case i > 0 && len(options) > 0 && slices.Contains(options, word): + formatOptions = append(formatOptions, "l_green", "normal") + case isString(word): + formatOptions = append(formatOptions, "yellow") + case isIncompleteString(word): + formatOptions = append(formatOptions, "l_red", "normal") + case isNumber(word): + formatOptions = append(formatOptions, "l_yellow", "normal") + case slices.Contains(specialChars, word): + formatOptions = append(formatOptions, "l_blue", "normal") + } + + if len(formatOptions) > 0 { + out[i] = format.Apply(word, formatOptions...) + } else { + out[i] = word + } + } + + return + } +} + +type formater struct { + *splitter +} + +func newFormater(buffer string, cursor int, cb FormatFunc) *formater { + f := formater{ + splitter: newSplitter(buffer, cursor), + } + f.words = cb(f.words) + + return &f +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0d9fd16 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gitea.zaclys.com/bvaudour/fishline + +go 1.22.0 + +require gitea.zaclys.com/bvaudour/gob v0.0.0-20240302132617-3307dec97ce9 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f30dbe3 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +gitea.zaclys.com/bvaudour/gob v0.0.0-20240302132617-3307dec97ce9 h1:rWwHju2jTBu/V7ALO9tYiNJOqqanEDEpfAtNUDGrImQ= +gitea.zaclys.com/bvaudour/gob v0.0.0-20240302132617-3307dec97ce9/go.mod h1:Gw6x0KNKoXv6AMtRkaI+iWM2enVzwHikUSskuEzWQz4= diff --git a/splitter.go b/splitter.go new file mode 100644 index 0000000..d43d170 --- /dev/null +++ b/splitter.go @@ -0,0 +1,161 @@ +package gfishline + +import ( + "strings" + + "gitea.zaclys.com/bvaudour/gob/shell/scanner" +) + +type splitter struct { + space map[int]string + words []string + cursor int +} + +func newSplitter(buffer string, cursor int) *splitter { + sp := splitter{ + space: make(map[int]string), + cursor: cursor, + } + + sc := scanner.NewRawScanner(strings.NewReader(buffer)) + for sc.Scan() { + sp.words = append(sp.words, sc.Text()) + } + for i, word := range sp.words { + if strings.HasPrefix(buffer, word) { + buffer = strings.TrimPrefix(buffer, word) + } else { + tmp := strings.TrimLeft(buffer, " ") + sp.space[i] = strings.Repeat(" ", len(buffer)-len(tmp)) + buffer = strings.TrimPrefix(tmp, word) + } + } + + if len(buffer) > 0 { + l := len(sp.words) + tmp := strings.TrimPrefix(buffer, " ") + if len(tmp) < len(buffer) { + sp.space[l] = strings.Repeat(" ", len(buffer)-len(tmp)) + buffer = tmp + l++ + } + if len(buffer) > 0 { + var lastWord, lastSpace []rune + for _, r := range buffer { + if r == ' ' { + lastSpace = append(lastSpace, r) + } else { + if len(lastWord) == 0 { + lastWord, lastSpace = append(lastWord, lastSpace...), lastSpace[:0] + } + lastWord = append(lastWord, r) + } + } + sp.words = append(sp.words, string(lastWord)) + if len(lastSpace) > 0 { + sp.space[l] = string(lastSpace) + } + } + } + + return &sp +} + +func (sp *splitter) isComplete() (ok bool) { + l := len(sp.words) + if ok = l == 0; !ok { + sc := scanner.NewScanner(strings.NewReader(sp.words[l-1])) + ok = sc.Scan() + } + + return +} + +func (sp *splitter) join() string { + var buf strings.Builder + + for i, word := range sp.words { + if space, ok := sp.space[i]; ok { + buf.WriteString(space) + } + buf.WriteString(word) + } + if space, ok := sp.space[len(sp.words)]; ok { + buf.WriteString(space) + } + + return buf.String() +} + +func (sp *splitter) buffer() (buffer string, cursor int) { return sp.join(), sp.cursor } + +func (sp *splitter) setWord(index int, word string) { + delta := len([]rune(word)) - len([]rune(sp.words[index])) + + sp.words[index] = word + sp.cursor += delta +} + +func (sp *splitter) addWord(index int, word string, wordPos int) { + delta, l := len([]rune(word))+2, len(sp.words) + + words := make([]string, l+1) + copy(words[:index], sp.words[:index]) + copy(words[index+1:], sp.words[index:]) + words[index] = word + + space := make(map[int]string) + for i, s := range sp.space { + switch { + case i < index: + space[i] = s + case i > index: + space[i+1] = s + default: + space[i] = s[:wordPos] + space[i+1] = " " + s[wordPos:] + } + } + + if _, ok := sp.space[index+1]; !ok { + sp.space[index+1] = " " + } + + sp.words, sp.space = words, space + sp.cursor += delta +} + +func (sp *splitter) compute() (wordIndex, wordCursor int, isIndexSpace bool) { + if isIndexSpace = len(sp.words) == 0 && len(sp.space) == 0; isIndexSpace { + return + } + + i := 0 + for wi, word := range sp.words { + if space, ok := sp.space[i]; ok { + l := len(space) + i += l + if isIndexSpace = i > sp.cursor; isIndexSpace { + wordIndex, wordCursor = wi, sp.cursor-(i-l) + return + } + } + l := len([]rune(word)) + i += l + if i > sp.cursor { + wordIndex, wordCursor, isIndexSpace = wi, sp.cursor-(i-l), false + return + } + } + + l := len(sp.words) + wordCursor = sp.cursor - i + if _, isIndexSpace = sp.space[l]; isIndexSpace { + wordIndex = l + } else { + wordIndex = l - 1 + } + + return +}