aboutsummaryrefslogtreecommitdiff
path: root/terminal.go
blob: 4660c1b62a08331f9a6a98510287d2c01dd52966 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
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...)
	}
}

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 = 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
}