Compare commits
	
		
			16 Commits
		
	
	
		
			bd13053773
			...
			master
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						6868bde5e6
	
				 | 
					
					
						|||
| 
						
						
							
						
						d3a046d85d
	
				 | 
					
					
						|||
| 
						
						
							
						
						6622ea0e1c
	
				 | 
					
					
						|||
| 
						
						
							
						
						a492b3b668
	
				 | 
					
					
						|||
| 
						
						
							
						
						280114a5d3
	
				 | 
					
					
						|||
| 
						
						
							
						
						d83517f67b
	
				 | 
					
					
						|||
| 
						
						
							
						
						4f2c2dc8da
	
				 | 
					
					
						|||
| 
						
						
							
						
						55a6693942
	
				 | 
					
					
						|||
| 
						
						
							
						
						d5981249b1
	
				 | 
					
					
						|||
| 
						
						
							
						
						4a7fc55c92
	
				 | 
					
					
						|||
| 
						
						
							
						
						2bd231b84f
	
				 | 
					
					
						|||
| 
						
						
							
						
						fb291b6def
	
				 | 
					
					
						|||
| 
						
						
							
						
						14a15e8b59
	
				 | 
					
					
						|||
| 
						
						
							
						
						0746797c73
	
				 | 
					
					
						|||
| 
						
						
							
						
						ec656d8b2a
	
				 | 
					
					
						|||
| 
						
						
							
						
						a09b11256b
	
				 | 
					
					
						
							
								
								
									
										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,7 +61,13 @@ 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.
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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