Skip to content

Commit

Permalink
Merge #11201 #11296
Browse files Browse the repository at this point in the history
11201: [cli] Abstract out terminal interactions r=pgavlin a=pgavlin

Replace direct interaction with the terminal with an abstraction. This
abstraction is tightly constrained to the capabilities needed for the
CLI's display. Using this abstraction allows for straightforward testing
of the interactive renderers.

11296: Update YAML to 1.0.2 r=AaronFriel a=AaronFriel



Co-authored-by: Pat Gavlin <pat@pulumi.com>
Co-authored-by: Aaron Friel <mayreply@aaronfriel.com>
  • Loading branch information
3 people committed Nov 8, 2022
3 parents daad9b6 + 2c372de + 22653aa commit 7a244e0
Show file tree
Hide file tree
Showing 109 changed files with 25,094 additions and 276 deletions.
4 changes: 4 additions & 0 deletions changelog/pending/20221108--yaml1-0-2.yaml
@@ -0,0 +1,4 @@
changes:
- type: fix
scope: yaml
description: "[Updates Pulumi YAML to v1.0.2](https://github.com/pulumi/pulumi-yaml/releases/tag/v1.0.2) which fixes a bug encountered using templates with project level config."
79 changes: 79 additions & 0 deletions pkg/backend/display/internal/terminal/info.go
@@ -0,0 +1,79 @@
package terminal

import (
"fmt"
"io"

gotty "github.com/ijc/Gotty"
)

type Info interface {
Parse(attr string, params ...interface{}) (string, error)

ClearLine(out io.Writer)
CursorUp(out io.Writer, count int)
CursorDown(out io.Writer, count int)
}

/* Satisfied by gotty.TermInfo as well as noTermInfo from below */
type termInfo interface {
Parse(attr string, params ...interface{}) (string, error)
}

type noTermInfo int // canary used when no terminfo.

func (ti noTermInfo) Parse(attr string, params ...interface{}) (string, error) {
return "", fmt.Errorf("noTermInfo")
}

type info struct {
termInfo
}

var _ = Info(info{})

func OpenInfo(terminal string) Info {
if i, err := gotty.OpenTermInfo(terminal); err == nil {
return info{i}
}
return info{noTermInfo(0)}
}

func (i info) ClearLine(out io.Writer) {
// el2 (clear whole line) is not exposed by terminfo.

// First clear line from beginning to cursor
if attr, err := i.Parse("el1"); err == nil {
fmt.Fprintf(out, "%s", attr)
} else {
fmt.Fprintf(out, "\x1b[1K")
}
// Then clear line from cursor to end
if attr, err := i.Parse("el"); err == nil {
fmt.Fprintf(out, "%s", attr)
} else {
fmt.Fprintf(out, "\x1b[K")
}
}

func (i info) CursorUp(out io.Writer, count int) {
if count == 0 { // Should never be the case, but be tolerant
return
}
if attr, err := i.Parse("cuu", count); err == nil {
fmt.Fprintf(out, "%s", attr)
} else {
fmt.Fprintf(out, "\x1b[%dA", count)
}
}

func (i info) CursorDown(out io.Writer, count int) {
if count == 0 { // Should never be the case, but be tolerant
return
}
if attr, err := i.Parse("cud", count); err == nil {
fmt.Fprintf(out, "%s", attr)
} else {
fmt.Fprintf(out, "\x1b[%dB", count)
}
}
80 changes: 80 additions & 0 deletions pkg/backend/display/internal/terminal/mock.go
@@ -0,0 +1,80 @@
package terminal

import (
"io"
"sync"
)

type MockTerminal struct {
m sync.Mutex

width, height int
raw bool
info Info

keys chan string

dest io.Writer
}

func NewMockTerminal(dest io.Writer, width, height int, raw bool) *MockTerminal {
return &MockTerminal{
width: width,
height: height,
raw: raw,
info: info{noTermInfo(0)},
keys: make(chan string),
dest: dest,
}
}

func (t *MockTerminal) IsRaw() bool {
return t.raw
}

func (t *MockTerminal) Close() error {
close(t.keys)
return nil
}

func (t *MockTerminal) Size() (width, height int, err error) {
t.m.Lock()
defer t.m.Unlock()

return t.width, t.height, nil
}

func (t *MockTerminal) Write(b []byte) (int, error) {
return t.dest.Write(b)
}

func (t *MockTerminal) ClearLine() {
t.info.ClearLine(t)
}

func (t *MockTerminal) CursorUp(count int) {
t.info.CursorUp(t, count)
}

func (t *MockTerminal) CursorDown(count int) {
t.info.CursorDown(t, count)
}

func (t *MockTerminal) ReadKey() (string, error) {
k, ok := <-t.keys
if !ok {
return "", io.EOF
}
return k, nil
}

func (t *MockTerminal) SetSize(width, height int) {
t.m.Lock()
defer t.m.Unlock()

t.width, t.height = width, height
}

func (t *MockTerminal) SendKey(key string) {
t.keys <- key
}
202 changes: 202 additions & 0 deletions pkg/backend/display/internal/terminal/term.go
@@ -0,0 +1,202 @@
package terminal

import (
"bytes"
"errors"
"fmt"
"io"
"os"

"github.com/muesli/cancelreader"
"golang.org/x/term"
)

type Terminal interface {
io.WriteCloser

IsRaw() bool
Size() (width, height int, err error)

ClearLine()
CursorUp(count int)
CursorDown(count int)

ReadKey() (string, error)
}

var ErrNotATerminal = errors.New("not a terminal")

type terminal struct {
fd int
info Info
raw bool
save *term.State

out io.Writer
in cancelreader.CancelReader
}

func Open(in io.Reader, out io.Writer, raw bool) (Terminal, error) {
type fileLike interface {
Fd() uintptr
}

outFile, ok := out.(fileLike)
if !ok {
return nil, ErrNotATerminal
}
outFd := int(outFile.Fd())

width, height, err := term.GetSize(outFd)
if err != nil {
return nil, fmt.Errorf("getting dimensions: %w", err)
}
if width == 0 || height == 0 {
return nil, fmt.Errorf("unusable dimensions (%v x %v)", width, height)
}

termType := os.Getenv("TERM")
if termType == "" {
termType = "vt102"
}
info := OpenInfo(termType)

var save *term.State
var inFile cancelreader.CancelReader
if raw {
if save, err = term.MakeRaw(outFd); err != nil {
return nil, fmt.Errorf("enabling raw mode: %w", err)
}
if inFile, err = cancelreader.NewReader(in); err != nil {
return nil, ErrNotATerminal
}
}

return &terminal{
fd: outFd,
info: info,
raw: raw,
save: save,
out: out,
in: inFile,
}, nil
}

func (t *terminal) IsRaw() bool {
return t.raw
}

func (t *terminal) Close() error {
t.in.Cancel()
if t.save != nil {
return term.Restore(t.fd, t.save)
}
return nil
}

func (t *terminal) Size() (width, height int, err error) {
return term.GetSize(t.fd)
}

func (t *terminal) Write(b []byte) (int, error) {
if !t.raw {
return t.out.Write(b)
}

written := 0
for {
newline := bytes.IndexByte(b, '\n')
if newline == -1 {
w, err := t.out.Write(b)
written += w
return written, err
}

w, err := t.out.Write(b[:newline])
written += w
if err != nil {
return written, err
}

if _, err = t.out.Write([]byte{'\r', '\n'}); err != nil {
return written, err
}
written++

b = b[newline+1:]
}
}

func (t *terminal) ClearLine() {
t.info.ClearLine(t.out)
}

func (t *terminal) CursorUp(count int) {
t.info.CursorUp(t.out, count)
}

func (t *terminal) CursorDown(count int) {
t.info.CursorDown(t.out, count)
}

func (t *terminal) ReadKey() (string, error) {
if t.in == nil {
return "", io.EOF
}

type stateFunc func(b byte) (stateFunc, string)

var stateIntermediate stateFunc
stateIntermediate = func(b byte) (stateFunc, string) {
if b >= 0x20 && b < 0x30 {
return stateIntermediate, ""
}
switch b {
case 'A':
return nil, "up"
case 'B':
return nil, "down"
default:
return nil, "<control>"
}
}
var stateParameter stateFunc
stateParameter = func(b byte) (stateFunc, string) {
if b >= 0x30 && b < 0x40 {
return stateParameter, ""
}
return stateIntermediate(b)
}
stateBracket := func(b byte) (stateFunc, string) {
if b == '[' {
return stateParameter, ""
}
return nil, "<control>"
}
stateEscape := func(b byte) (stateFunc, string) {
if b == 0x1b {
return stateBracket, ""
}
if b == 3 {
return nil, "ctrl+c"
}
return nil, string([]byte{b})
}

state := stateEscape
for {
var b [1]byte
if _, err := t.in.Read(b[:]); err != nil {
if errors.Is(err, cancelreader.ErrCanceled) {
err = io.EOF
}
return "", err
}

next, key := state(b[0])
if next == nil {
return key, nil
}
state = next
}
}

0 comments on commit 7a244e0

Please sign in to comment.