first commit

This commit is contained in:
2026-02-25 22:39:00 +03:00
commit 928d17af74
13 changed files with 1351 additions and 0 deletions

328
cmdline/loop.go Normal file
View File

@@ -0,0 +1,328 @@
package cmdline
import (
"errors"
"fmt"
"log/slog"
"os"
"sort"
"strings"
"time"
"unicode"
"github.com/chzyer/readline"
)
type Command struct {
Name string
Aliases []string
Description string
Execute func(args string)
Complete func(argPos int, prefix string) []string
}
type Options struct {
Prompt string
HistoryLimit int
InterruptPrompt string
EOFPrompt string
OnInterrupt func()
OnUnknown func(name string)
Resolve func(name, args string) bool
CommandNames func(prefix string) []string
ArgSuggestions func(name string, argPos int, prefix string) []string
Out *os.File
Log *slog.Logger
}
type Loop struct {
opts Options
done chan struct{}
commands map[string]*Command
started time.Time
}
func NewLoop(opts Options) *Loop {
if strings.TrimSpace(opts.Prompt) == "" {
opts.Prompt = "> "
}
if opts.HistoryLimit <= 0 {
opts.HistoryLimit = 200
}
if strings.TrimSpace(opts.InterruptPrompt) == "" {
opts.InterruptPrompt = "^C"
}
if strings.TrimSpace(opts.EOFPrompt) == "" {
opts.EOFPrompt = "exit"
}
if opts.Out == nil {
opts.Out = os.Stdout
}
if opts.Log == nil {
opts.Log = slog.Default()
}
l := &Loop{
opts: opts,
done: make(chan struct{}),
commands: map[string]*Command{},
started: time.Now(),
}
l.Register(Command{
Name: "help",
Description: "Show available console commands",
Execute: func(string) {
names := l.availableNames("")
_, _ = fmt.Fprintln(opts.Out, "Available commands:")
for _, n := range names {
_, _ = fmt.Fprintln(opts.Out, " - "+n)
}
},
})
l.Register(Command{
Name: "clear",
Aliases: []string{"cls"},
Description: "Clear terminal screen",
Execute: func(string) {
_, _ = fmt.Fprint(opts.Out, "\x1b[H\x1b[2J")
},
})
l.Register(Command{
Name: "uptime",
Description: "Show console loop uptime",
Execute: func(string) {
_, _ = fmt.Fprintf(opts.Out, "uptime: %s\n", time.Since(l.started).Truncate(time.Second))
},
})
l.Register(Command{
Name: "pid",
Description: "Show current process id",
Execute: func(string) {
_, _ = fmt.Fprintf(opts.Out, "pid: %d\n", os.Getpid())
},
})
l.Register(Command{
Name: "echo",
Description: "Print text back to console",
Execute: func(args string) {
_, _ = fmt.Fprintln(opts.Out, args)
},
})
return l
}
func (l *Loop) Register(c Command) {
name := strings.ToLower(strings.TrimSpace(c.Name))
if name == "" {
return
}
if len(c.Aliases) == 0 {
c.Aliases = []string{name}
} else {
seen := map[string]struct{}{}
out := make([]string, 0, len(c.Aliases)+1)
out = append(out, name)
seen[name] = struct{}{}
for _, a := range c.Aliases {
s := strings.ToLower(strings.TrimSpace(a))
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
c.Aliases = out
}
cmdCopy := c
for _, a := range c.Aliases {
l.commands[a] = &cmdCopy
}
}
func (l *Loop) Start() <-chan struct{} {
go func() {
defer close(l.done)
rl, err := readline.NewEx(&readline.Config{
Prompt: l.opts.Prompt,
HistoryLimit: l.opts.HistoryLimit,
InterruptPrompt: l.opts.InterruptPrompt,
EOFPrompt: l.opts.EOFPrompt,
AutoComplete: &completer{loop: l},
})
if err != nil {
l.opts.Log.Warn("console readline init failed", "err", err)
return
}
defer func(rl *readline.Instance) {
_ = rl.Close()
}(rl)
l.opts.Log.Info("console command input enabled")
for {
line, err := rl.Readline()
if errors.Is(err, readline.ErrInterrupt) {
if l.opts.OnInterrupt != nil {
l.opts.OnInterrupt()
}
return
}
if err != nil {
break
}
name, args, ok := splitCommand(line)
if !ok {
continue
}
if cmd, found := l.commands[name]; found {
if cmd.Execute != nil {
cmd.Execute(args)
}
continue
}
if l.opts.Resolve != nil && l.opts.Resolve(name, args) {
continue
}
if l.opts.OnUnknown != nil {
l.opts.OnUnknown(name)
}
}
l.opts.Log.Info("console input stopped")
}()
return l.done
}
type completer struct {
loop *Loop
}
func (c *completer) Do(line []rune, pos int) ([][]rune, int) {
input := string(line[:pos])
trimmed := strings.TrimLeftFunc(input, unicode.IsSpace)
if strings.HasPrefix(trimmed, "/") {
trimmed = strings.TrimPrefix(trimmed, "/")
}
if trimmed == "" {
return toRunes(c.loop.availableNames("")), 0
}
hasTrailingSpace := len(trimmed) > 0 && unicode.IsSpace(rune(trimmed[len(trimmed)-1]))
parts := strings.Fields(trimmed)
if len(parts) == 0 {
return toRunes(completionTail(c.loop.availableNames(""), "")), 0
}
if len(parts) == 1 && !hasTrailingSpace {
prefix := strings.ToLower(parts[0])
return toRunes(completionTail(c.loop.availableNames(prefix), prefix)), len([]rune(prefix))
}
name := strings.ToLower(parts[0])
argPos := 0
prefix := ""
if hasTrailingSpace {
argPos = len(parts) - 1
} else {
argPos = len(parts) - 2
prefix = parts[len(parts)-1]
}
if argPos < 0 {
return nil, 0
}
suggestions := map[string]struct{}{}
if cmd, ok := c.loop.commands[name]; ok && cmd.Complete != nil {
for _, s := range cmd.Complete(argPos, prefix) {
if matchPrefix(s, prefix) {
suggestions[s] = struct{}{}
}
}
} else if c.loop.opts.ArgSuggestions != nil {
for _, s := range c.loop.opts.ArgSuggestions(name, argPos, prefix) {
if matchPrefix(s, prefix) {
suggestions[s] = struct{}{}
}
}
}
if len(suggestions) == 0 {
return nil, 0
}
out := make([]string, 0, len(suggestions))
for s := range suggestions {
out = append(out, s)
}
sort.Strings(out)
return toRunes(completionTail(out, prefix)), len([]rune(prefix))
}
func (l *Loop) availableNames(prefix string) []string {
names := map[string]struct{}{}
for alias := range l.commands {
if matchPrefix(alias, prefix) {
names[alias] = struct{}{}
}
}
if l.opts.CommandNames != nil {
for _, n := range l.opts.CommandNames(prefix) {
s := strings.ToLower(strings.TrimSpace(n))
if s == "" {
continue
}
if matchPrefix(s, prefix) {
names[s] = struct{}{}
}
}
}
out := make([]string, 0, len(names))
for n := range names {
out = append(out, n)
}
sort.Strings(out)
return out
}
func splitCommand(line string) (name, args string, ok bool) {
line = strings.TrimSpace(line)
if line == "" {
return "", "", false
}
if strings.HasPrefix(line, "/") {
line = strings.TrimSpace(strings.TrimPrefix(line, "/"))
}
parts := strings.SplitN(line, " ", 2)
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
return "", "", false
}
name = strings.ToLower(strings.TrimSpace(parts[0]))
if len(parts) > 1 {
args = strings.TrimSpace(parts[1])
}
return name, args, true
}
func matchPrefix(value, prefix string) bool {
if prefix == "" {
return true
}
return strings.HasPrefix(strings.ToLower(value), strings.ToLower(prefix))
}
func completionTail(options []string, prefix string) []string {
out := make([]string, 0, len(options))
prefixRunes := []rune(prefix)
prefixLen := len(prefixRunes)
for _, option := range options {
optRunes := []rune(option)
if prefixLen > 0 && len(optRunes) >= prefixLen &&
strings.EqualFold(string(optRunes[:prefixLen]), prefix) {
out = append(out, string(optRunes[prefixLen:]))
continue
}
out = append(out, option)
}
return out
}
func toRunes(items []string) [][]rune {
out := make([][]rune, 0, len(items))
for _, item := range items {
out = append(out, []rune(item))
}
return out
}