initial commit

This commit is contained in:
2026-04-20 21:38:22 +02:00
commit a5c79fb7b5
12 changed files with 821 additions and 0 deletions

8
Rikuri.sublime-project Normal file
View File

@@ -0,0 +1,8 @@
{
"folders": [
{
"name": "",
"path": ".",
}
]
}

41
cli.go Normal file
View File

@@ -0,0 +1,41 @@
// Command-line arguments parsing and formatted console output.
package cli
import (
"os"
"sync"
)
// Terminal capabilites.
type TerminalInfo struct {
IsTTY bool
SupportsColor bool
Width int
}
// Returns cached TerminalInfo for os.Stdin.
var GetStdinInfo = sync.OnceValue(func() TerminalInfo {
return GetTerminalInfo(os.Stdin)
})
// Returns cached TerminalInfo for os.Stdout.
var GetStdoutInfo = sync.OnceValue(func() TerminalInfo {
return GetTerminalInfo(os.Stdout)
})
// Returns cached TerminalInfo for os.Stderr.
var GetStderrInfo = sync.OnceValue(func() TerminalInfo {
return GetTerminalInfo(os.Stderr)
})
type ExitReason uint8
const (
UserError ExitReason = 1
BadUsage = 2
InternalError = 3
)
func ShortCircuit() {
os.Exit(0)
}

227
cli_args.go Normal file
View File

@@ -0,0 +1,227 @@
package cli
import (
"fmt"
"os"
"strings"
"unicode/utf8"
)
// A parsed command-line argument.
type Argument struct {
name string
suffix string
value string
isFlag bool
mayBeCommand bool
hasValue bool
wasUsed bool
err error
}
// Turn an argument into an Argument struct.
func parseArgument(arg string, into Args, acceptFlags bool) Args {
return append(into, Argument{value: arg, mayBeCommand: acceptFlags})
}
// Parse a "long" flag such as --name.
func parseLongFlag(arg string, into Args) Args {
var (
flag = Argument{isFlag: true}
name string
hasSuffix bool
)
name, flag.value, flag.hasValue = strings.Cut(arg[2:], "=")
flag.name, flag.suffix, hasSuffix = strings.Cut(name, ":")
if len(flag.name) == 0 {
flag.err = fmt.Errorf("Flag \"%s\" has no name", arg)
}
if hasSuffix && len(flag.suffix) == 0 {
flag.err = fmt.Errorf("Flag \"%s\" has a colon in its name but no suffix", arg)
}
return append(into, flag)
}
// Parse a "short" flag such as -n.
// -name will be parsed as four boolean flags -n, -a, -m and -e.
func parseShortFlag(arg string, into Args) Args {
name, value, hasValue := strings.Cut(arg[1:], "=")
prefix, suffix, hasSuffix := strings.Cut(name, ":")
prefixes := strings.Split(prefix, "")
if len(prefixes) == 0 {
flag := Argument{
isFlag: true, name: prefix, suffix: suffix, hasValue: hasValue, value: value,
err: fmt.Errorf("Flag \"%s\" has no name", arg),
}
return append(into, flag)
}
for i, p := range prefixes {
flag := Argument{isFlag: true, name: p}
if i == len(prefixes)-1 {
flag.suffix = suffix
flag.hasValue = hasValue
flag.value = value
if hasSuffix && len(suffix) == 0 {
flag.err = fmt.Errorf("Flag group \"%s\" contains a colon but no suffix", arg)
}
}
into = append(into, flag)
}
return into
}
type Args []Argument
// Parse all command-line arguments from os.Args.
func ParseArgs() (arglist Args) {
var acceptFlags bool = true
for i, arg := range os.Args {
if i == 0 {
// skip program name
continue
}
if acceptFlags && strings.HasPrefix(arg, "--") {
if len(arg) > 2 {
arglist = parseLongFlag(arg, arglist)
} else {
// everything after the first double dash "--" is an argument
acceptFlags = false
}
} else if acceptFlags && strings.HasPrefix(arg, "-") {
if len(arg) > 1 {
arglist = parseShortFlag(arg, arglist)
} else {
// a single dash "-" has no special meaning
arglist = parseArgument(arg, arglist, false)
}
} else {
arglist = parseArgument(arg, arglist, acceptFlags)
}
}
return arglist
}
// Print an Argument as a flag.
func sprintFlag(flag Argument) string {
if utf8.RuneCountInString(flag.name) < 2 {
return fmt.Sprintf("\"-%s\"", flag.name)
} else {
return fmt.Sprintf("\"--%s\"", flag.name)
}
}
// Print a flag given its long and short form.
func sprintFlagName(name string, shorthand string) string {
if shorthand == "" {
return fmt.Sprintf("\"--%s\"", name)
} else {
return fmt.Sprintf("\"-%s\" or \"--%s\"", shorthand, name)
}
}
// Return the flag named [name] or [shorthand], or <nil> if none if found.
// Fails if more than one flag is found.
func (args *Args) findSingleFlag(name string, shorthand string) (*Argument, error) {
var (
found *Argument
err error
)
for i := range *args {
arg := &(*args)[i]
if !arg.wasUsed && arg.isFlag && (arg.name == name || arg.name == shorthand) {
if found == nil {
found = arg
} else {
err = fmt.Errorf("Flag %s can only be used once", sprintFlagName(name, shorthand))
}
arg.wasUsed = true
}
}
return found, err
}
// Return wether an argument is considered a help flag. -h, --help and windows-style /? are accepted.
func (arg *Argument) isHelpFlag() bool {
if arg.isFlag {
return arg.name == "h" || arg.name == "help"
} else if arg.mayBeCommand {
return arg.value == "/?"
} else {
return false
}
}
// Return wether a help flag is present in [args].
func (args *Args) HelpFlag() bool {
var (
result bool = false
)
for _, arg := range *args {
if !arg.wasUsed && arg.isHelpFlag() {
arg.wasUsed = true
result = true
}
}
return result
}
// Read a boolean flag. The return value will be [value] if the flag is set.
func (args *Args) BoolFlag(name string, shorthand string, value bool) (bool, error) {
var result bool = !value
flag, err := args.findSingleFlag(name, shorthand)
if flag != nil {
if flag.suffix != "" {
err = fmt.Errorf("Flag %s cannot have a suffix", sprintFlagName(name, shorthand))
} else if flag.hasValue {
err = fmt.Errorf("Flag %s cannot have a value", sprintFlagName(name, shorthand))
} else {
result = value
}
}
return result, err
}
// Read the next argument as a command.
func (args *Args) Command() (cmd string) {
for i := range *args {
arg := &(*args)[i]
if !arg.wasUsed && arg.mayBeCommand {
cmd = arg.value
arg.wasUsed = true
break
}
}
return cmd
}
// Verify that all arguments have been used.
func (args *Args) Done() {
var errs []error
for _, arg := range *args {
if !arg.wasUsed {
if arg.isFlag {
errs = append(errs, fmt.Errorf("Unexpected flag %s", sprintFlag(arg)))
} else {
errs = append(errs, fmt.Errorf("Unexpected argument \"%s\"", arg.value))
}
} else if arg.err != nil {
errs = append(errs, arg.err)
}
}
if errs != nil {
InvalidArgs(errs...)
}
}

60
cli_darwin.go Normal file
View File

@@ -0,0 +1,60 @@
//go:build darwin
// +build darwin
package cli
/* The code in this file comes from the "github.com/evanw/esbuild/internal/logger" package.
MIT License
Copyright (c) 2020 Evan Wallace
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import (
"os"
"golang.org/x/sys/unix"
)
func GetTerminalInfo(file *os.File) (info TerminalInfo) {
fd := file.Fd()
// Is this file descriptor a terminal?
if _, err := unix.IoctlGetTermios(int(fd), unix.TIOCGETA); err == nil {
info.IsTTY = true
info.SupportsColor = true
// Get the width of the window
if w, err := unix.IoctlGetWinsize(int(fd), unix.TIOCGWINSZ); err == nil {
info.Width = int(w.Col)
}
}
return
}
func errorStyle(f *os.File) {
f.WriteString("\x1b[91m")
}
func resetStyle(f *os.File) {
f.WriteString("\x1b[0m")
}

122
cli_display.go Normal file
View File

@@ -0,0 +1,122 @@
package cli
import (
"bufio"
"fmt"
"os"
"runtime"
"strings"
"sync"
)
type Printer struct {
mutex sync.Mutex
Program string
Debugging bool
Color bool
}
func (printer *Printer) Debug(v ...any) {
if printer.Debugging {
printer.mutex.Lock()
fmt.Fprint(os.Stdout, "dbg ")
_, file, line, ok := runtime.Caller(2)
if ok {
lastSegment := strings.LastIndex(file, "/") + 1
fmt.Fprint(os.Stdout, "@ ", file[lastSegment:], ":", line, " ")
}
fmt.Fprintln(os.Stdout, v...)
printer.mutex.Unlock()
}
}
func fprintIndented(f *os.File, maxWidth int, indent int, value any) {
wrapped := wrap.Indents(indent, wrap.Wrap(fmt.Sprint(value), maxWidth-indent))
for _, line := range wrapped {
fmt.Fprintln(f, line)
}
}
func (printer *Printer) printErrorMessage(msg string, details ...error) {
termInfo := GetStderrInfo()
if termInfo.SupportsColor && printer.Color {
errorStyle(os.Stderr)
}
fmt.Fprintln(os.Stderr, "Error:", msg)
for _, detail := range details {
fprintIndented(os.Stderr, termInfo.Width, 3, detail)
}
if termInfo.SupportsColor && printer.Color {
resetStyle(os.Stderr)
}
}
func (printer *Printer) Error(msg string, details ...error) {
printer.mutex.Lock()
printer.printErrorMessage(msg, details...)
printer.mutex.Unlock()
}
func (printer *Printer) Fatal(msg string, reason ExitReason, details ...error) {
printer.mutex.Lock()
printer.printErrorMessage(msg, details...)
if reason == BadUsage {
fmt.Fprintf(os.Stdout, "run '%s --help' for usage\n", printer.Program)
}
os.Exit(int(reason))
}
var defaultPrinter Printer
func DefaultPrinter() *Printer {
return &defaultPrinter
}
func Debug(v ...any) {
defaultPrinter.Debug(v...)
}
func Error(msg string, details ...error) {
defaultPrinter.Error(msg, details...)
}
func Fatal(msg string, reason ExitReason, details ...error) {
defaultPrinter.Fatal(msg, reason, details...)
}
func InvalidArgs(errs ...error) {
Fatal("invalid command-line arguments", BadUsage, errs...)
}
func Show(msg string) {
termInfo := GetStdoutInfo()
wrapped := wrap.Wrap(msg, termInfo.Width)
for _, line := range wrapped {
fmt.Println(line)
}
}
func ShowUsage(explanation string, examples ...string) {
fmt.Print(explanation, "\n\nUsage:\n")
for _, line := range wrap.Indents(3, examples) {
fmt.Println(line)
}
}
func DescribeOption(name string, description string) {
name = " " + name + " "
termInfo := GetStdoutInfo()
wrapped := wrap.Indentfs(name, len(name), wrap.Wrap(description, termInfo.Width-len(name)))
for _, line := range wrapped {
fmt.Println(line)
}
}
func AskString(prompt string) string {
fmt.Print(prompt)
reader := bufio.NewReader(os.Stdin)
text, _ := reader.ReadString('\n')
return text
}

62
cli_linux.go Normal file
View File

@@ -0,0 +1,62 @@
//go:build linux
// +build linux
package cli
/* The code in this file comes from the "github.com/evanw/esbuild/internal/logger" package.
MIT License
Copyright (c) 2020 Evan Wallace
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import (
"os"
"golang.org/x/sys/unix"
)
const SupportsColorEscapes = true
func GetTerminalInfo(file *os.File) (info TerminalInfo) {
fd := file.Fd()
// Is this file descriptor a terminal?
if _, err := unix.IoctlGetTermios(int(fd), unix.TCGETS); err == nil {
info.IsTTY = true
info.SupportsColor = true
// Get the width of the window
if w, err := unix.IoctlGetWinsize(int(fd), unix.TIOCGWINSZ); err == nil {
info.Width = int(w.Col)
}
}
return
}
func errorStyle(f *os.File) {
f.WriteString("\x1b[91m")
}
func resetStyle(f *os.File) {
f.WriteString("\x1b[0m")
}

14
cli_other.go Normal file
View File

@@ -0,0 +1,14 @@
//go:build !darwin && !linux && !windows
// +build !darwin,!linux,!windows
package cli
import "os"
func GetTerminalInfo(*os.File) TerminalInfo {
return TerminalInfo{Width: 80}
}
func errorStyle(*os.File) { }
func resetStyle(*os.File) { }

76
cli_windows.go Normal file
View File

@@ -0,0 +1,76 @@
//go:build windows
// +build windows
package cli
/* The code in this file comes from the "github.com/evanw/esbuild/internal/logger" package.
MIT License
Copyright (c) 2020 Evan Wallace
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import (
"os"
"syscall"
"unsafe"
)
const SupportsColorEscapes = true
var kernel32 = syscall.NewLazyDLL("kernel32.dll")
var getConsoleMode = kernel32.NewProc("GetConsoleMode")
var getConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo")
type consoleScreenBufferInfo struct {
dwSizeX int16
dwSizeY int16
dwCursorPositionX int16
dwCursorPositionY int16
wAttributes uint16
srWindowLeft int16
srWindowTop int16
srWindowRight int16
srWindowBottom int16
dwMaximumWindowSizeX int16
dwMaximumWindowSizeY int16
}
func GetTerminalInfo(file *os.File) TerminalInfo {
fd := file.Fd()
// Is this file descriptor a terminal?
var unused uint32
isTTY, _, _ := syscall.SyscallN(getConsoleMode.Addr(), fd, uintptr(unsafe.Pointer(&unused)))
// Get the width of the window
var info consoleScreenBufferInfo
syscall.SyscallN(getConsoleScreenBufferInfo.Addr(), fd, uintptr(unsafe.Pointer(&info)))
return TerminalInfo{
IsTTY: isTTY != 0,
Width: int(info.dwSizeX) - 1,
}
}
func errorStyle(*os.File) { }
func resetStyle(*os.File) { }

7
go.mod Normal file
View File

@@ -0,0 +1,7 @@
module louisdevie.fr/rikuri
go 1.25.4
require (
golang.org/x/sys v0.43.0
)

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
github.com/louisdevie/elizalina2 v0.0.0-20251210181249-159b3a136b35 h1:xtJtD7jpUAL/ZUupW85Y5rfpjtgEVtzVC8Q+rhgvrQs=
github.com/louisdevie/elizalina2 v0.0.0-20251210181249-159b3a136b35/go.mod h1:uajSlR/gzlBN5iOPWySVFsXNhWYkVatEzQ7HFR9j46Q=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

126
wrap.go Normal file
View File

@@ -0,0 +1,126 @@
package wrap
import (
"strings"
"unicode"
"unicode/utf8"
)
type measuredString struct {
text string
unicodeSize int
}
func (str *measuredString) pushRune(text string, size int) string {
str.text += text[:size]
str.unicodeSize++
return text[size:]
}
func concat(a, b measuredString) measuredString {
return measuredString{
text: a.text + b.text,
unicodeSize: a.unicodeSize + b.unicodeSize,
}
}
// Given the space remaining on the current line as minWidth and the maximum line size as maxWidth,
// this function will read the first word and return three values:
// 1) the text that should go at the end of the current line,
// 2) the text that should go on the next line,
// 3) the remaining text that has not been consumed.
func wrapWord(text string, minWidth int, maxWidth int) (measuredString, measuredString, string) {
var space, prefix, suffix measuredString
reachedMin := false
reachedEnd := false
// read the first word, stopping at the end of the current line
for !reachedMin && len(text) > 0 {
rune, size := utf8.DecodeRuneInString(text)
if unicode.IsSpace(rune) {
if prefix.unicodeSize == 0 {
text = space.pushRune(text, size)
} else {
reachedMin = true
reachedEnd = true
}
} else {
if space.unicodeSize+prefix.unicodeSize < minWidth {
text = prefix.pushRune(text, size)
} else {
reachedMin = true
}
}
}
// does the entire word fit on the current line ?
if reachedEnd || len(text) == 0 {
return concat(space, prefix), measuredString{}, text
}
// otherwise finish reading the first word
for !reachedEnd && len(text) > 0 {
rune, size := utf8.DecodeRuneInString(text)
if unicode.IsSpace(rune) {
reachedEnd = true
} else {
if prefix.unicodeSize+suffix.unicodeSize < maxWidth {
text = suffix.pushRune(text, size)
} else {
// special case: if the word cannot fit on a single line,
// we put half the word on the current line and the other half on the next line
return concat(space, prefix), suffix, text
}
}
}
// and put the word on the next line
return measuredString{}, concat(prefix, suffix), text
}
// Break a string into lines of at most [width] runes.
func Wrap(text string, width int) (result []string) {
var line measuredString
for len(text) > 0 {
var cont, next measuredString
cont, next, text = wrapWord(text, width-line.unicodeSize, width)
line = concat(line, cont)
if next.unicodeSize > 0 {
result = append(result, line.text)
line = next
}
}
// add any text remaining in the buffer
return append(result, line.text)
}
// Prefix multiple lines of text with a string.
func Indent(prefix string, lines []string) []string {
result := make([]string, len(lines))
for i, line := range lines {
result[i] = prefix + line
}
return result
}
// Prefix multiple lines of text using a different string for the first line.
func Indentf(first string, prefix string, lines []string) []string {
result := make([]string, len(lines))
for i, line := range lines {
if i == 0 {
result[i] = first + line
} else {
result[i] = prefix + line
}
}
return result
}
// Prefix multiple lines of text with a number of spaces.
func Indents(size int, lines []string) []string {
return Indent(strings.Repeat(" ", size), lines)
}
// Prefix multiple lines of text with a number of spaces,
// but using a different string for the first line.
func Indentfs(first string, size int, lines []string) []string {
return Indentf(first, strings.Repeat(" ", size), lines)
}

74
wrap_test.go Normal file
View File

@@ -0,0 +1,74 @@
package wrap_test
import (
"testing"
"github.com/louisdevie/elizalina2/internal/wrap"
)
func assertSameLines(t *testing.T, wrapped []string, expected []string) {
if len(wrapped) != len(expected) {
t.Fatalf("Expected %d lines of text but got %d", len(expected), len(wrapped))
}
for i, e := range expected {
if wrapped[i] != e {
t.Logf("Expected line %d to be \"%s\"", i+1, e)
t.Logf(" but got \"%s\"", wrapped[i])
t.FailNow()
}
}
}
const text = "Or was it because of the involvement of something from beyond science?"
func TestWrapWide(t *testing.T) {
wrapped := wrap.Wrap(text, 80)
expected := []string{"Or was it because of the involvement of something from beyond science?"}
assertSameLines(t, wrapped, expected)
}
func TestWrapMedium(t *testing.T) {
wrapped := wrap.Wrap(text, 44)
expected := []string{"Or was it because of the involvement of", "something from beyond science?"}
assertSameLines(t, wrapped, expected)
}
func TestWrapNarrow(t *testing.T) {
wrapped := wrap.Wrap(text, 10)
expected := []string{"Or was it", "because of", "the involv", "ement of", "something", "from", "beyond", "science?"}
assertSameLines(t, wrapped, expected)
}
func TestWrapWithoutSpace(t *testing.T) {
wrapped := wrap.Wrap("Orwasitbecauseoftheinvolvementofsomethingfrombeyondscience?", 10)
expected := []string{"Orwasitbec", "auseofthei", "nvolvement", "ofsomethin", "gfrombeyon", "dscience?"}
assertSameLines(t, wrapped, expected)
}
func TestIndent(t *testing.T) {
lines := []string{"Or was it because of the", "involvement of something", "from beyond science?"}
indented := wrap.Indent("| ", lines)
expected := []string{"| Or was it because of the", "| involvement of something", "| from beyond science?"}
assertSameLines(t, indented, expected)
}
func TestIndentf(t *testing.T) {
lines := []string{"Or was it because of the", "involvement of something", "from beyond science?"}
indented := wrap.Indentf("-> ", "| ", lines)
expected := []string{"-> Or was it because of the", "| involvement of something", "| from beyond science?"}
assertSameLines(t, indented, expected)
}
func TestIndents(t *testing.T) {
lines := []string{"Or was it because of the", "involvement of something", "from beyond science?"}
indented := wrap.Indents(3, lines)
expected := []string{" Or was it because of the", " involvement of something", " from beyond science?"}
assertSameLines(t, indented, expected)
}
func TestIndentfs(t *testing.T) {
lines := []string{"Or was it because of the", "involvement of something", "from beyond science?"}
indented := wrap.Indentfs("-> ", 3, lines)
expected := []string{"-> Or was it because of the", " involvement of something", " from beyond science?"}
assertSameLines(t, indented, expected)
}