Compare commits
19 Commits
d632111c45
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
6868bde5e6
|
|||
|
d3a046d85d
|
|||
|
6622ea0e1c
|
|||
|
a492b3b668
|
|||
|
280114a5d3
|
|||
|
d83517f67b
|
|||
|
4f2c2dc8da
|
|||
|
55a6693942
|
|||
|
d5981249b1
|
|||
|
4a7fc55c92
|
|||
|
2bd231b84f
|
|||
|
fb291b6def
|
|||
|
14a15e8b59
|
|||
|
0746797c73
|
|||
|
ec656d8b2a
|
|||
|
a09b11256b
|
|||
|
bd13053773
|
|||
|
92fd2db1c1
|
|||
|
0db2ff3409
|
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,2 +1,10 @@
|
||||
/acid
|
||||
/acid.1
|
||||
|
||||
/acid.cflags
|
||||
/acid.config
|
||||
/acid.creator
|
||||
/acid.creator.user
|
||||
/acid.cxxflags
|
||||
/acid.files
|
||||
/acid.includes
|
||||
|
||||
5
Makefile
5
Makefile
@@ -5,10 +5,11 @@ version = dev
|
||||
outputs = acid acid.1
|
||||
all: $(outputs)
|
||||
|
||||
acid: acid.go
|
||||
acid: acid.go terminal.go
|
||||
go build -ldflags "-X 'main.projectVersion=$(version)'" -o $@
|
||||
acid.1: acid.adoc
|
||||
asciidoctor -b manpage -a release-version=$(version) -o $@ acid.adoc
|
||||
asciidoctor -b manpage -a release-version=$(version) -o $@ acid.adoc || \
|
||||
a2x -d manpage -f manpage -a release-version=$(version) acid.adoc
|
||||
test: all
|
||||
go test
|
||||
clean:
|
||||
|
||||
18
acid.adoc
18
acid.adoc
@@ -37,6 +37,8 @@ Commands
|
||||
*restart* [_ID_]...::
|
||||
Schedule tasks with the given IDs to be rerun.
|
||||
Run this command without arguments to pick up external database changes.
|
||||
*reload*::
|
||||
Reload configuration.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
@@ -45,7 +47,7 @@ file present in the distribution.
|
||||
|
||||
All paths are currently relative to the directory you launch *acid* from.
|
||||
|
||||
The *notify*, *setup*, and *build* scripts are processed using Go's
|
||||
The *notify*, *setup*, *build*, and *deploy* scripts are processed using Go's
|
||||
_text/template_ package, and take an object describing the task,
|
||||
which has the following fields:
|
||||
|
||||
@@ -65,6 +67,17 @@ which has the following fields:
|
||||
*RunnerName*::
|
||||
Descriptive name of the runner.
|
||||
|
||||
// Intentionally not documenting CreatedUnix, ChangedUnix, DurationSeconds,
|
||||
// which can be derived from the objects.
|
||||
*Created*, *Changed*::
|
||||
`*time.Time` of task creation and last task state change respectively,
|
||||
or nil if not known.
|
||||
*CreatedAgo*, *ChangedAgo*::
|
||||
Abbreviated human-friendly relative elapsed time duration
|
||||
since *Created* and *Changed* respectively.
|
||||
*Duration*::
|
||||
`*time.Duration` of the last run in seconds, or nil if not known.
|
||||
|
||||
*URL*::
|
||||
*acid* link to the task, where its log output can be seen.
|
||||
*RepoURL*::
|
||||
@@ -79,7 +92,8 @@ in *sh*(1) command arguments.
|
||||
|
||||
Runners
|
||||
-------
|
||||
Runners receive the following additional environment variables:
|
||||
Runners and deploy scripts receive the following additional
|
||||
environment variables:
|
||||
|
||||
*ACID_ROOT*:: The same as the base directory for configuration.
|
||||
*ACID_RUNNER*:: The same as *Runner* in script templates.
|
||||
|
||||
@@ -61,4 +61,14 @@ projects:
|
||||
# Project build script.
|
||||
build: |
|
||||
echo Computing line count...
|
||||
find . -not -path '*/.*' -type f -print0 | xargs -0 cat | wc -l
|
||||
mkdir ~/acid-deploy
|
||||
find . -not -path '*/.*' -type f -print0 | xargs -0 cat | wc -l \
|
||||
> ~/acid-deploy/count
|
||||
|
||||
# Project deployment script (runs locally in a temporary directory).
|
||||
deploy: |
|
||||
cat count
|
||||
|
||||
# Time limit in time.ParseDuration format.
|
||||
# The default of one hour should suffice.
|
||||
timeout: 1h
|
||||
|
||||
17
acid_test.go
17
acid_test.go
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"testing"
|
||||
ttemplate "text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTemplateQuote(t *testing.T) {
|
||||
@@ -30,3 +31,19 @@ func TestTemplateQuote(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortDurationString(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
d time.Duration
|
||||
expect string
|
||||
}{
|
||||
{72 * time.Hour, "3d"},
|
||||
{-3 * time.Hour, "-3h"},
|
||||
{12 * time.Minute, "12m"},
|
||||
{time.Millisecond, "0s"},
|
||||
} {
|
||||
if sd := shortDurationString(test.d); sd != test.expect {
|
||||
t.Errorf("%s = %s; want %s\n", test.d, sd, test.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
go.mod
10
go.mod
@@ -3,9 +3,13 @@ module janouch.name/acid
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
golang.org/x/crypto v0.21.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/pkg/sftp v1.13.7
|
||||
golang.org/x/crypto v0.31.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.18.0 // indirect
|
||||
require (
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
)
|
||||
|
||||
69
go.sum
69
go.sum
@@ -1,12 +1,65 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pkg/sftp v1.13.7 h1:uv+I3nNJvlKZIQGSr8JVQLNHFU9YhhNpvC14Y6KgmSM=
|
||||
github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
379
terminal.go
Normal file
379
terminal.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type terminalLine struct {
|
||||
// For simplicity, we assume that all runes take up one cell,
|
||||
// including TAB and non-spacing ones.
|
||||
// The next step would be grouping non-spacing characters,
|
||||
// in particular Unicode modifier letters, with their base.
|
||||
columns []rune
|
||||
|
||||
// updateGroup is the topmost line that has changed since this line
|
||||
// has appeared, for the purpose of update tracking.
|
||||
updateGroup int
|
||||
}
|
||||
|
||||
// terminalWriter does a best-effort approximation of an infinite-size
|
||||
// virtual terminal.
|
||||
type terminalWriter struct {
|
||||
sync.Mutex
|
||||
Tee io.WriteCloser
|
||||
lines []terminalLine
|
||||
|
||||
// Zero-based coordinates within lines.
|
||||
column, line int
|
||||
|
||||
// lineTop is used as the base for positioning commands.
|
||||
lineTop int
|
||||
|
||||
written int
|
||||
byteBuffer []byte
|
||||
runeBuffer []rune
|
||||
}
|
||||
|
||||
func (tw *terminalWriter) log(format string, v ...interface{}) {
|
||||
if os.Getenv("ACID_TERMINAL_DEBUG") != "" {
|
||||
log.Printf("terminal: "+format+"\n", v...)
|
||||
}
|
||||
}
|
||||
|
||||
// SerializeUpdates returns an update block for a client with a given last line,
|
||||
// and the index of the first line in the update block.
|
||||
func (tw *terminalWriter) SerializeUpdates(last int) (string, int) {
|
||||
if last < 0 || last >= len(tw.lines) {
|
||||
return "", last
|
||||
}
|
||||
top := tw.lines[last].updateGroup
|
||||
return string(tw.Serialize(top)), top
|
||||
}
|
||||
|
||||
func (tw *terminalWriter) Serialize(top int) []byte {
|
||||
var b bytes.Buffer
|
||||
for i := top; i < len(tw.lines); i++ {
|
||||
b.WriteString(string(tw.lines[i].columns))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func (tw *terminalWriter) Write(p []byte) (written int, err error) {
|
||||
tw.Lock()
|
||||
defer tw.Unlock()
|
||||
|
||||
// TODO(p): Rather use io.MultiWriter?
|
||||
// Though I'm not sure what to do about closing (FD leaks).
|
||||
// Eventually, any handles would be garbage collected in any case.
|
||||
if tw.Tee != nil {
|
||||
tw.Tee.Write(p)
|
||||
}
|
||||
|
||||
// Enough is enough, writing too much is highly suspicious.
|
||||
ok, remaining := true, 64<<20-tw.written
|
||||
if remaining < 0 {
|
||||
ok, p = false, nil
|
||||
} else if remaining < len(p) {
|
||||
ok, p = false, p[:remaining]
|
||||
}
|
||||
tw.written += len(p)
|
||||
|
||||
// By now, more or less everything should run in UTF-8.
|
||||
//
|
||||
// This might have better performance with a ring buffer,
|
||||
// so as to avoid reallocations.
|
||||
b := append(tw.byteBuffer, p...)
|
||||
if !ok {
|
||||
b = append(b, "\nToo much terminal output\n"...)
|
||||
}
|
||||
for utf8.FullRune(b) {
|
||||
r, len := utf8.DecodeRune(b)
|
||||
b, tw.runeBuffer = b[len:], append(tw.runeBuffer, r)
|
||||
}
|
||||
tw.byteBuffer = b
|
||||
for tw.processRunes() {
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (tw *terminalWriter) processPrint(r rune) {
|
||||
// Extend the buffer vertically.
|
||||
for len(tw.lines) <= tw.line {
|
||||
tw.lines = append(tw.lines,
|
||||
terminalLine{updateGroup: len(tw.lines)})
|
||||
}
|
||||
|
||||
// Refresh update trackers, if necessary.
|
||||
if tw.lines[len(tw.lines)-1].updateGroup > tw.line {
|
||||
for i := tw.line; i < len(tw.lines); i++ {
|
||||
tw.lines[i].updateGroup = min(tw.lines[i].updateGroup, tw.line)
|
||||
}
|
||||
}
|
||||
|
||||
// Emulate `cat -v` for C0 characters.
|
||||
seq := make([]rune, 0, 2)
|
||||
if r < 32 && r != '\t' {
|
||||
seq = append(seq, '^', 64+r)
|
||||
} else {
|
||||
seq = append(seq, r)
|
||||
}
|
||||
|
||||
// Extend the line horizontally and write the rune.
|
||||
for _, r := range seq {
|
||||
line := &tw.lines[tw.line]
|
||||
for len(line.columns) <= tw.column {
|
||||
line.columns = append(line.columns, ' ')
|
||||
}
|
||||
|
||||
line.columns[tw.column] = r
|
||||
tw.column++
|
||||
}
|
||||
}
|
||||
|
||||
func (tw *terminalWriter) processFlush() {
|
||||
tw.column = 0
|
||||
tw.line = len(tw.lines)
|
||||
tw.lineTop = tw.line
|
||||
}
|
||||
|
||||
func (tw *terminalWriter) processParsedCSI(
|
||||
private rune, param, intermediate []rune, final rune) bool {
|
||||
var params []int
|
||||
if len(param) > 0 {
|
||||
for _, p := range strings.Split(string(param), ";") {
|
||||
i, _ := strconv.Atoi(p)
|
||||
params = append(params, i)
|
||||
}
|
||||
}
|
||||
|
||||
if private == '?' && len(intermediate) == 0 &&
|
||||
(final == 'h' || final == 'l') {
|
||||
for _, p := range params {
|
||||
// 25 (DECTCEM): There is no cursor to show or hide.
|
||||
// 7 (DECAWM): We cannot wrap, we're infinite.
|
||||
if !(p == 25 || (p == 7 && final == 'l')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if private != 0 || len(intermediate) > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case final == 'C': // Cursor Forward
|
||||
if len(params) == 0 {
|
||||
tw.column++
|
||||
} else if len(params) >= 1 {
|
||||
tw.column += params[0]
|
||||
}
|
||||
return true
|
||||
case final == 'D': // Cursor Backward
|
||||
if len(params) == 0 {
|
||||
tw.column--
|
||||
} else if len(params) >= 1 {
|
||||
tw.column -= params[0]
|
||||
}
|
||||
if tw.column < 0 {
|
||||
tw.column = 0
|
||||
}
|
||||
return true
|
||||
case final == 'E': // Cursor Next Line
|
||||
if len(params) == 0 {
|
||||
tw.line++
|
||||
} else if len(params) >= 1 {
|
||||
tw.line += params[0]
|
||||
}
|
||||
tw.column = 0
|
||||
return true
|
||||
case final == 'F': // Cursor Preceding Line
|
||||
if len(params) == 0 {
|
||||
tw.line--
|
||||
} else if len(params) >= 1 {
|
||||
tw.line -= params[0]
|
||||
}
|
||||
if tw.line < tw.lineTop {
|
||||
tw.line = tw.lineTop
|
||||
}
|
||||
tw.column = 0
|
||||
return true
|
||||
case final == 'H': // Cursor Position
|
||||
if len(params) == 0 {
|
||||
tw.line = tw.lineTop
|
||||
tw.column = 0
|
||||
} else if len(params) >= 2 && params[0] != 0 && params[1] != 0 {
|
||||
tw.line = tw.lineTop + params[0] - 1
|
||||
tw.column = params[1] - 1
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case final == 'J': // Erase in Display
|
||||
if len(params) == 0 || params[0] == 0 || params[0] == 2 {
|
||||
// We're not going to erase anything, thank you very much.
|
||||
tw.processFlush()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case final == 'K': // Erase in Line
|
||||
if tw.line >= len(tw.lines) {
|
||||
return true
|
||||
}
|
||||
line := &tw.lines[tw.line]
|
||||
if len(params) == 0 || params[0] == 0 {
|
||||
if len(line.columns) > tw.column {
|
||||
line.columns = line.columns[:tw.column]
|
||||
}
|
||||
} else if params[0] == 1 {
|
||||
for i := 0; i < tw.column; i++ {
|
||||
line.columns[i] = ' '
|
||||
}
|
||||
} else if params[0] == 2 {
|
||||
line.columns = nil
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case final == 'm':
|
||||
// Straight up ignoring all attributes, at least for now.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (tw *terminalWriter) processCSI(rb []rune) ([]rune, bool) {
|
||||
if len(rb) < 3 {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
i, private, param, intermediate := 2, rune(0), []rune{}, []rune{}
|
||||
if rb[i] >= 0x3C && rb[i] <= 0x3F {
|
||||
private = rb[i]
|
||||
i++
|
||||
}
|
||||
for i < len(rb) && ((rb[i] >= '0' && rb[i] <= '9') || rb[i] == ';') {
|
||||
param = append(param, rb[i])
|
||||
i++
|
||||
}
|
||||
for i < len(rb) && rb[i] >= 0x20 && rb[i] <= 0x2F {
|
||||
intermediate = append(intermediate, rb[i])
|
||||
i++
|
||||
}
|
||||
if i == len(rb) {
|
||||
return nil, true
|
||||
}
|
||||
if rb[i] < 0x40 || rb[i] > 0x7E {
|
||||
return rb, false
|
||||
}
|
||||
if !tw.processParsedCSI(private, param, intermediate, rb[i]) {
|
||||
tw.log("unhandled CSI %s", string(rb[2:i+1]))
|
||||
return rb, false
|
||||
}
|
||||
return rb[i+1:], true
|
||||
}
|
||||
|
||||
func (tw *terminalWriter) processEscape(rb []rune) ([]rune, bool) {
|
||||
if len(rb) < 2 {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
// Very roughly following https://vt100.net/emu/dec_ansi_parser
|
||||
// but being a bit stricter.
|
||||
switch r := rb[1]; {
|
||||
case r == '[':
|
||||
return tw.processCSI(rb)
|
||||
case r == ']':
|
||||
// TODO(p): Skip this properly, once we actually hit it.
|
||||
tw.log("unhandled OSC")
|
||||
return rb, false
|
||||
case r == 'P':
|
||||
// TODO(p): Skip this properly, once we actually hit it.
|
||||
tw.log("unhandled DCS")
|
||||
return rb, false
|
||||
|
||||
// Only handling sequences we've seen bother us in real life.
|
||||
case r == 'c':
|
||||
// Full reset, use this to flush all output.
|
||||
tw.processFlush()
|
||||
return rb[2:], true
|
||||
case r == 'M':
|
||||
tw.line--
|
||||
return rb[2:], true
|
||||
|
||||
case (r >= 0x30 && r <= 0x4F) || (r >= 0x51 && r <= 0x57) ||
|
||||
r == 0x59 || r == 0x5A || r == 0x5C || (r >= 0x60 && r <= 0x7E):
|
||||
// → esc_dispatch
|
||||
tw.log("unhandled ESC %c", r)
|
||||
return rb, false
|
||||
//return rb[2:], true
|
||||
case r >= 0x20 && r <= 0x2F:
|
||||
// → escape intermediate
|
||||
i := 2
|
||||
for i < len(rb) && rb[i] >= 0x20 && rb[i] <= 0x2F {
|
||||
i++
|
||||
}
|
||||
if i == len(rb) {
|
||||
return nil, true
|
||||
}
|
||||
if rb[i] < 0x30 || rb[i] > 0x7E {
|
||||
return rb, false
|
||||
}
|
||||
// → esc_dispatch
|
||||
tw.log("unhandled ESC %s", string(rb[1:i+1]))
|
||||
return rb, false
|
||||
//return rb[i+1:], true
|
||||
default:
|
||||
// Note that Debian 12 has been seen to produce ESC<U+2026>
|
||||
// and such due to some very blind string processing.
|
||||
return rb, false
|
||||
}
|
||||
}
|
||||
|
||||
func (tw *terminalWriter) processRunes() bool {
|
||||
rb := tw.runeBuffer
|
||||
if len(rb) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
switch rb[0] {
|
||||
case '\a':
|
||||
// Ding dong!
|
||||
case '\b':
|
||||
if tw.column > 0 {
|
||||
tw.column--
|
||||
}
|
||||
case '\n', '\v':
|
||||
tw.line++
|
||||
|
||||
// Forced ONLCR flag, because that's what most shell output expects.
|
||||
fallthrough
|
||||
case '\r':
|
||||
tw.column = 0
|
||||
|
||||
case '\x1b':
|
||||
var ok bool
|
||||
if rb, ok = tw.processEscape(rb); rb == nil {
|
||||
return false
|
||||
} else if ok {
|
||||
tw.runeBuffer = rb
|
||||
return true
|
||||
}
|
||||
|
||||
// Unsuccessful parses get printed for later inspection.
|
||||
fallthrough
|
||||
default:
|
||||
tw.processPrint(rb[0])
|
||||
}
|
||||
tw.runeBuffer = rb[1:]
|
||||
return true
|
||||
}
|
||||
100
terminal_test.go
Normal file
100
terminal_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This could be way more extensive, but we're not aiming for perfection.
|
||||
var tests = []struct {
|
||||
push, want string
|
||||
}{
|
||||
{
|
||||
// Escaping and UTF-8.
|
||||
"\x03\x1bž\bř",
|
||||
"^C^[ř\n",
|
||||
},
|
||||
{
|
||||
// Several kinds of sequences to be ignored.
|
||||
"\x1bc\x1b[?7l\x1b[2J\x1b[0;1mSeaBIOS\rTea",
|
||||
"TeaBIOS\n",
|
||||
},
|
||||
{
|
||||
// New origin and absolute positioning.
|
||||
"Line 1\n\x1bcWine B\nFine 3\x1b[1;6H2\x1b[HL\nL",
|
||||
"Line 1\nLine 2\nLine 3\n",
|
||||
},
|
||||
{
|
||||
// In-line positioning (without corner cases).
|
||||
"A\x1b[CB\x1b[2C?\x1b[DC\x1b[2D\b->",
|
||||
"A B->C\n",
|
||||
},
|
||||
{
|
||||
// Up and down.
|
||||
"\nB\x1bMA\v\vC" + "\x1b[4EG" + "\x1b[FF" + "\x1b[2FD" + "\x1b[EE",
|
||||
" A\nB\nC\nD\nE\nF\nG\n",
|
||||
},
|
||||
{
|
||||
// In-line erasing.
|
||||
"1234\b\b\x1b[K\n5678\b\b\x1b[0K\n" + "abcd\b\b\x1b[1K\nefgh\x1b[2K",
|
||||
"12\n56\n cd\n\n",
|
||||
},
|
||||
}
|
||||
|
||||
func TestTerminal(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
tw := terminalWriter{}
|
||||
if _, err := tw.Write([]byte(test.push)); err != nil {
|
||||
t.Errorf("%#v: %s", test.push, err)
|
||||
continue
|
||||
}
|
||||
have := string(tw.Serialize(0))
|
||||
if have != test.want {
|
||||
t.Errorf("%#v: %#v; want %#v", test.push, have, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalExploded(t *testing.T) {
|
||||
Loop:
|
||||
for _, test := range tests {
|
||||
tw := terminalWriter{}
|
||||
for _, b := range []byte(test.push) {
|
||||
if _, err := tw.Write([]byte{b}); err != nil {
|
||||
t.Errorf("%#v: %s", test.push, err)
|
||||
continue Loop
|
||||
}
|
||||
}
|
||||
have := string(tw.Serialize(0))
|
||||
if have != test.want {
|
||||
t.Errorf("%#v: %#v; want %#v", test.push, have, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalUpdateGroups(t *testing.T) {
|
||||
tw := terminalWriter{}
|
||||
collect := func() (have []int) {
|
||||
for _, line := range tw.lines {
|
||||
have = append(have, line.updateGroup)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 0: A 0 0 0
|
||||
// 1: B X 1 1 1
|
||||
// 2: C Y 1 2 1 1
|
||||
// 3: Z 2 3 2
|
||||
// 4: 3 4
|
||||
tw.Write([]byte("A\nB\nC\x1b[FX\nY\nZ"))
|
||||
have, want := collect(), []int{0, 1, 1, 3}
|
||||
if !slices.Equal(want, have) {
|
||||
t.Errorf("update groups: %+v; want: %+v", have, want)
|
||||
}
|
||||
|
||||
tw.Write([]byte("\x1b[F1\n2\n3"))
|
||||
have, want = collect(), []int{0, 1, 1, 2, 4}
|
||||
if !slices.Equal(want, have) {
|
||||
t.Errorf("update groups: %+v; want: %+v", have, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user