Upload files to "internal/repl"
This commit is contained in:
1 parent
f098896469
commit
d695d05fbd
3 files changed
+752
No files matched your search
@@ -0,0 +1,305 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/repl/completer.go
|
||||||
|
// Назначение: Автодополнение команд, имён коллекций, полей и операторов.
|
||||||
|
// Поддерживает как собственный API, так и интеграцию с chzyer/readline.
|
||||||
|
|
||||||
|
package repl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/chzyer/readline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Completer предоставляет автодополнение.
|
||||||
|
type Completer struct {
|
||||||
|
repl *Repl
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCompleter создаёт автодополнение.
|
||||||
|
func NewCompleter(r *Repl) *Completer {
|
||||||
|
return &Completer{repl: r}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete возвращает список подсказок для заданного префикса.
|
||||||
|
func (c *Completer) Complete(prefix string) []string {
|
||||||
|
if c == nil || c.repl == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix = strings.TrimSpace(prefix)
|
||||||
|
if prefix == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(prefix)
|
||||||
|
trailingSpace := strings.HasSuffix(prefix, " ")
|
||||||
|
|
||||||
|
if len(parts) == 0 || (len(parts) == 1 && !trailingSpace) {
|
||||||
|
return c.completeCommand(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdName := ""
|
||||||
|
for name := range c.repl.commands {
|
||||||
|
if strings.HasPrefix(prefix, name) {
|
||||||
|
if len(name) > len(cmdName) {
|
||||||
|
cmdName = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmdName == "" {
|
||||||
|
if len(parts) > 0 {
|
||||||
|
cmdName = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
argsPrefix := strings.TrimPrefix(prefix, cmdName)
|
||||||
|
argsPrefix = strings.TrimSpace(argsPrefix)
|
||||||
|
|
||||||
|
argParts := strings.Fields(argsPrefix)
|
||||||
|
lastArg := ""
|
||||||
|
if len(argParts) > 0 && !trailingSpace {
|
||||||
|
lastArg = argParts[len(argParts)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.completeArgs(cmdName, lastArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Completer) completeCommand(prefix string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for name := range c.repl.commands {
|
||||||
|
if strings.HasPrefix(name, prefix) {
|
||||||
|
if _, ok := seen[name]; !ok {
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extra := []string{
|
||||||
|
"help", "clear", "quit", "exit", "pager",
|
||||||
|
}
|
||||||
|
for _, e := range extra {
|
||||||
|
if strings.HasPrefix(e, prefix) {
|
||||||
|
if _, ok := seen[e]; !ok {
|
||||||
|
seen[e] = struct{}{}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Completer) completeArgs(cmdName, lastArg string) []string {
|
||||||
|
switch cmdName {
|
||||||
|
case "use", "drop database", "create slice", "export", "import":
|
||||||
|
return c.completeDatabases(lastArg)
|
||||||
|
case "create collection", "drop collection", "show collections",
|
||||||
|
"show indexes", "show triggers", "show deleted", "count",
|
||||||
|
"stats timestamps", "compress collection":
|
||||||
|
return c.completeCollections(lastArg)
|
||||||
|
case "insert", "find", "update", "delete", "permanent delete", "restore",
|
||||||
|
"findbyindex", "findbytime", "create index", "drop index",
|
||||||
|
"add required", "add unique", "add min", "add max", "add enum",
|
||||||
|
"create trigger", "drop trigger", "enable trigger", "disable trigger",
|
||||||
|
"show timestamps", "doc compression":
|
||||||
|
return c.completeCollectionThenField(cmdName, lastArg)
|
||||||
|
case "migration":
|
||||||
|
return c.completeMigration(lastArg)
|
||||||
|
default:
|
||||||
|
return c.completeOperators(lastArg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Completer) completeDatabases(prefix string) []string {
|
||||||
|
if c.repl.store == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, db := range c.repl.store.ListDatabases() {
|
||||||
|
if strings.HasPrefix(db, prefix) {
|
||||||
|
out = append(out, db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Completer) completeCollections(prefix string) []string {
|
||||||
|
if c.repl.store == nil || c.repl.currentDB == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db, err := c.repl.store.GetDatabase(c.repl.currentDB)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, coll := range db.ListCollections() {
|
||||||
|
if strings.HasPrefix(coll, prefix) {
|
||||||
|
out = append(out, coll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Completer) completeCollectionThenField(cmdName, lastArg string) []string {
|
||||||
|
if c.repl.store == nil || c.repl.currentDB == "" {
|
||||||
|
return c.completeCollections(lastArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := c.repl.store.GetDatabase(c.repl.currentDB)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
colls := db.ListCollections()
|
||||||
|
for _, coll := range colls {
|
||||||
|
if coll == lastArg {
|
||||||
|
return c.completeFields(coll, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cols := c.completeCollections(lastArg); len(cols) > 0 {
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(colls) > 0 {
|
||||||
|
return c.completeFields(colls[0], lastArg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Completer) completeFields(collName, prefix string) []string {
|
||||||
|
if c.repl.store == nil || c.repl.currentDB == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db, err := c.repl.store.GetDatabase(c.repl.currentDB)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
coll, err := db.GetCollection(collName)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldSet := map[string]struct{}{}
|
||||||
|
for _, doc := range coll.GetAllDocuments() {
|
||||||
|
for k := range doc.GetFields() {
|
||||||
|
fieldSet[k] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
for k := range fieldSet {
|
||||||
|
if strings.HasPrefix(k, prefix) {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Completer) completeOperators(prefix string) []string {
|
||||||
|
ops := []string{
|
||||||
|
"BEFORE_INSERT", "AFTER_INSERT",
|
||||||
|
"BEFORE_UPDATE", "AFTER_UPDATE",
|
||||||
|
"BEFORE_DELETE", "AFTER_DELETE",
|
||||||
|
"abort", "skip", "modify", "log", "notify",
|
||||||
|
"unique", "required",
|
||||||
|
"start", "status", "list", "pause", "resume", "cancel", "stats", "config", "queue", "validate",
|
||||||
|
"--description", "--set", "--inc", "--currentDate", "--condition",
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, op := range ops {
|
||||||
|
if strings.HasPrefix(op, prefix) {
|
||||||
|
out = append(out, op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Completer) completeMigration(prefix string) []string {
|
||||||
|
subs := []string{"start", "status", "list", "pause", "resume", "cancel", "stats", "config", "queue", "validate"}
|
||||||
|
var out []string
|
||||||
|
for _, s := range subs {
|
||||||
|
if strings.HasPrefix(s, prefix) {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommonPrefix возвращает общий префикс для набора строк.
|
||||||
|
func CommonPrefix(items []string) string {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
prefix := items[0]
|
||||||
|
for _, s := range items[1:] {
|
||||||
|
for !strings.HasPrefix(s, prefix) {
|
||||||
|
if len(prefix) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
prefix = prefix[:len(prefix)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadlineCompleter строит статическое дерево команд для readline.
|
||||||
|
// Динамические имена коллекций/полей дополняются через DynamicCompleter
|
||||||
|
// (см. ReadlineDynamicFunc ниже), чтобы не пересобирать дерево на каждый ввод.
|
||||||
|
func (c *Completer) ReadlineCompleter() *readline.PrefixCompleter {
|
||||||
|
items := []readline.PrefixCompleterInterface{}
|
||||||
|
|
||||||
|
// Сортируем имена команд для стабильного вывода.
|
||||||
|
names := make([]string, 0, len(c.repl.commands))
|
||||||
|
for name := range c.repl.commands {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
items = append(items, readline.PcItem(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Системные команды.
|
||||||
|
items = append(items, readline.PcItem("help"))
|
||||||
|
items = append(items, readline.PcItem("clear"))
|
||||||
|
items = append(items, readline.PcItem("quit"))
|
||||||
|
items = append(items, readline.PcItem("exit"))
|
||||||
|
items = append(items, readline.PcItem("pager",
|
||||||
|
readline.PcItem("on"),
|
||||||
|
readline.PcItem("off"),
|
||||||
|
))
|
||||||
|
|
||||||
|
return readline.NewPrefixCompleter(items...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadlineDynamicFunc возвращает функцию для readline.Config.DynamicComplete.
|
||||||
|
// Она вызывается на каждый Tab и позволяет дополнять коллекции и поля.
|
||||||
|
func (c *Completer) ReadlineDynamicFunc() func(string, int) []string {
|
||||||
|
return func(line string, pos int) []string {
|
||||||
|
prefix := ""
|
||||||
|
if pos <= len(line) {
|
||||||
|
prefix = line[:pos]
|
||||||
|
}
|
||||||
|
return c.Complete(prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/repl/pager.go
|
||||||
|
// Назначение: Пейджер для постраничного вывода длинных результатов.
|
||||||
|
// Использует внешний pager (less/more) если доступен, иначе встроенный.
|
||||||
|
// Совместимо с Linux и OpenIndiana.
|
||||||
|
|
||||||
|
package repl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pager предоставляет постраничный вывод.
|
||||||
|
type Pager struct {
|
||||||
|
enabled bool
|
||||||
|
threshold int
|
||||||
|
external string
|
||||||
|
lines int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPager создаёт пейджер.
|
||||||
|
func NewPager(enabled bool, threshold int) *Pager {
|
||||||
|
p := &Pager{
|
||||||
|
enabled: enabled,
|
||||||
|
threshold: threshold,
|
||||||
|
lines: 24,
|
||||||
|
}
|
||||||
|
p.external = detectPager()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPager ищет доступный внешний pager.
|
||||||
|
// Порядок: $PAGER -> less -> more -> "" (встроенный).
|
||||||
|
func detectPager() string {
|
||||||
|
if env := os.Getenv("PAGER"); env != "" {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
if path, err := exec.LookPath("less"); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if path, err := exec.LookPath("more"); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled возвращает, включён ли пейджер.
|
||||||
|
func (p *Pager) Enabled() bool {
|
||||||
|
return p != nil && p.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEnabled включает/выключает пейджер.
|
||||||
|
func (p *Pager) SetEnabled(v bool) {
|
||||||
|
if p != nil {
|
||||||
|
p.enabled = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetThreshold устанавливает порог строк.
|
||||||
|
func (p *Pager) SetThreshold(n int) {
|
||||||
|
if p != nil && n > 0 {
|
||||||
|
p.threshold = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page выводит текст через пейджер, если он длиннее порога.
|
||||||
|
func (p *Pager) Page(text string) error {
|
||||||
|
if p == nil || !p.enabled {
|
||||||
|
fmt.Print(text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lineCount := strings.Count(text, "\n")
|
||||||
|
if lineCount < p.threshold {
|
||||||
|
fmt.Print(text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.external != "" {
|
||||||
|
if err := p.pageExternal(text); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// fallback на встроенный
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.pageBuiltin(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageExternal запускает внешний pager.
|
||||||
|
func (p *Pager) pageExternal(text string) error {
|
||||||
|
args := []string{}
|
||||||
|
base := strings.ToLower(p.external)
|
||||||
|
|
||||||
|
if strings.Contains(base, "less") {
|
||||||
|
// -R: сохранять ANSI-цвета, -F: выйти если всё влезает, -X: не чистить экран
|
||||||
|
args = append(args, "-RFX")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(p.external, args...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.WriteString(stdin, text); err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
_ = cmd.Wait()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stdin.Close()
|
||||||
|
|
||||||
|
return cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageBuiltin — встроенный пейджер (fallback).
|
||||||
|
// Управление: Enter — следующая строка, Space — страница, q — выход.
|
||||||
|
func (p *Pager) pageBuiltin(text string) error {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(text))
|
||||||
|
buf := make([]byte, 0, 64*1024)
|
||||||
|
scanner.Buffer(buf, 1024*1024)
|
||||||
|
|
||||||
|
lines := []string{}
|
||||||
|
for scanner.Scan() {
|
||||||
|
lines = append(lines, scanner.Text())
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSize := p.lines - 1
|
||||||
|
if pageSize < 5 {
|
||||||
|
pageSize = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
pos := 0
|
||||||
|
|
||||||
|
for pos < len(lines) {
|
||||||
|
end := pos + pageSize
|
||||||
|
if end > len(lines) {
|
||||||
|
end = len(lines)
|
||||||
|
}
|
||||||
|
for i := pos; i < end; i++ {
|
||||||
|
fmt.Println(lines[i])
|
||||||
|
}
|
||||||
|
pos = end
|
||||||
|
|
||||||
|
if pos >= len(lines) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\033[7m--More--\033[0m (Enter/Space/q) ")
|
||||||
|
ch, err := reader.ReadByte()
|
||||||
|
fmt.Print("\r\033[K")
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch ch {
|
||||||
|
case 'q', 'Q':
|
||||||
|
return nil
|
||||||
|
case '\n', '\r':
|
||||||
|
// одна строка
|
||||||
|
if pos < len(lines) {
|
||||||
|
fmt.Println(lines[pos])
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
case ' ':
|
||||||
|
// страница
|
||||||
|
default:
|
||||||
|
// игнорируем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/repl/table.go
|
||||||
|
// Назначение: Табличный вывод результатов (по умолчанию для find/count/stats и т.п.).
|
||||||
|
// Работает без внешних зависимостей, совместимо с Linux и OpenIndiana.
|
||||||
|
|
||||||
|
package repl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Table — простая ASCII-таблица.
|
||||||
|
type Table struct {
|
||||||
|
headers []string
|
||||||
|
rows [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTable создаёт таблицу с заголовками.
|
||||||
|
func NewTable(headers ...string) *Table {
|
||||||
|
return &Table{
|
||||||
|
headers: append([]string(nil), headers...),
|
||||||
|
rows: make([][]string, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRow добавляет строку.
|
||||||
|
func (t *Table) AddRow(cells ...string) {
|
||||||
|
row := make([]string, len(cells))
|
||||||
|
copy(row, cells)
|
||||||
|
t.rows = append(t.rows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len возвращает число строк.
|
||||||
|
func (t *Table) Len() int {
|
||||||
|
return len(t.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String рендерит таблицу.
|
||||||
|
func (t *Table) String() string {
|
||||||
|
if len(t.headers) == 0 && len(t.rows) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := len(t.headers)
|
||||||
|
for _, r := range t.rows {
|
||||||
|
if len(r) > cols {
|
||||||
|
cols = len(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нормализуем
|
||||||
|
headers := make([]string, cols)
|
||||||
|
copy(headers, t.headers)
|
||||||
|
|
||||||
|
rows := make([][]string, len(t.rows))
|
||||||
|
for i, r := range t.rows {
|
||||||
|
rr := make([]string, cols)
|
||||||
|
copy(rr, r)
|
||||||
|
rows[i] = rr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ширины
|
||||||
|
widths := make([]int, cols)
|
||||||
|
for i, h := range headers {
|
||||||
|
widths[i] = displayWidth(h)
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
for i, c := range r {
|
||||||
|
if w := displayWidth(c); w > widths[i] {
|
||||||
|
widths[i] = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Разделитель
|
||||||
|
sep := buildSeparator(widths)
|
||||||
|
|
||||||
|
// Заголовок
|
||||||
|
sb.WriteString(sep)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(renderRow(headers, widths))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(sep)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Строки
|
||||||
|
for _, r := range rows {
|
||||||
|
sb.WriteString(renderRow(r, widths))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(sep)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSeparator(widths []int) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("+")
|
||||||
|
for _, w := range widths {
|
||||||
|
sb.WriteString(strings.Repeat("-", w+2))
|
||||||
|
sb.WriteString("+")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderRow(cells []string, widths []int) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("|")
|
||||||
|
for i, w := range widths {
|
||||||
|
cell := ""
|
||||||
|
if i < len(cells) {
|
||||||
|
cell = cells[i]
|
||||||
|
}
|
||||||
|
sb.WriteString(" ")
|
||||||
|
sb.WriteString(padRight(cell, w))
|
||||||
|
sb.WriteString(" |")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func padRight(s string, w int) string {
|
||||||
|
dw := displayWidth(s)
|
||||||
|
if dw >= w {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s + strings.Repeat(" ", w-dw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayWidth учитывает широкие руны (грубо).
|
||||||
|
func displayWidth(s string) int {
|
||||||
|
w := 0
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\t' {
|
||||||
|
w += 4
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r < 32 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isWide(r) {
|
||||||
|
w += 2
|
||||||
|
} else {
|
||||||
|
w += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = utf8.RuneCountInString(s)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWide — грубая эвристика для широких символов CJK.
|
||||||
|
func isWide(r rune) bool {
|
||||||
|
return (r >= 0x1100 && r <= 0x115F) ||
|
||||||
|
(r >= 0x2E80 && r <= 0xA4CF) ||
|
||||||
|
(r >= 0xAC00 && r <= 0xD7A3) ||
|
||||||
|
(r >= 0xF900 && r <= 0xFAFF) ||
|
||||||
|
(r >= 0xFE30 && r <= 0xFE4F) ||
|
||||||
|
(r >= 0xFF00 && r <= 0xFF60) ||
|
||||||
|
(r >= 0xFFE0 && r <= 0xFFE6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapToTable преобразует список map в таблицу с сортировкой ключей.
|
||||||
|
func MapToTable(rows []map[string]interface{}) *Table {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return NewTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собираем все ключи
|
||||||
|
keySet := map[string]struct{}{}
|
||||||
|
for _, r := range rows {
|
||||||
|
for k := range r {
|
||||||
|
keySet[k] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(keySet))
|
||||||
|
for k := range keySet {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
// _id всегда первым, если есть
|
||||||
|
if _, ok := keySet["_id"]; ok {
|
||||||
|
sort.SliceStable(keys, func(i, j int) bool {
|
||||||
|
if keys[i] == "_id" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if keys[j] == "_id" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return keys[i] < keys[j]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t := NewTable(keys...)
|
||||||
|
for _, r := range rows {
|
||||||
|
cells := make([]string, len(keys))
|
||||||
|
for i, k := range keys {
|
||||||
|
v, ok := r[k]
|
||||||
|
if !ok {
|
||||||
|
cells[i] = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cells[i] = FormatCell(v)
|
||||||
|
}
|
||||||
|
t.AddRow(cells...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatCell форматирует значение для ячейки.
|
||||||
|
func FormatCell(v interface{}) string {
|
||||||
|
if v == nil {
|
||||||
|
return "NULL"
|
||||||
|
}
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
return x
|
||||||
|
case bool:
|
||||||
|
if x {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
case float64:
|
||||||
|
if x == float64(int64(x)) {
|
||||||
|
return fmt.Sprintf("%d", int64(x))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%g", x)
|
||||||
|
case int, int8, int16, int32, int64:
|
||||||
|
return fmt.Sprintf("%d", x)
|
||||||
|
case uint, uint8, uint16, uint32, uint64:
|
||||||
|
return fmt.Sprintf("%d", x)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", x)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user