Merge main
This commit is contained in:
@@ -14,6 +14,7 @@ imports it inherits them.
|
||||
| `git.unkin.net/unkin/golib/pg` | Postgres: DSN from the environment, pgxpool construction, read/write splitting across a primary and a replica, and the estate's migration runner. |
|
||||
| `git.unkin.net/unkin/golib/pg/pgtest` | A throwaway Postgres container for a consumer's own `_test.go` files. Test-only. |
|
||||
| `git.unkin.net/unkin/golib/keyring` | Encrypts secrets at rest with a key held in the Linux kernel user keyring, scoped to the current boot and uid. |
|
||||
| `git.unkin.net/unkin/golib/colour` | jq-style JSON and yq-style YAML highlighting for CLI output, preserving the input byte for byte, plus the TTY/`NO_COLOR` check. |
|
||||
|
||||
### pg
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package colour highlights JSON the way jq does and YAML the way yq does. It
|
||||
// works on tokens and re-emits the input byte for byte between ANSI escapes, so
|
||||
// formatting, whitespace and key order survive; input it cannot parse is
|
||||
// written through uncoloured.
|
||||
package colour
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Mode is a --colour flag's value: Auto decides from the environment.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
Auto Mode = iota
|
||||
Always
|
||||
Never
|
||||
)
|
||||
|
||||
const reset = "\x1b[0m"
|
||||
|
||||
// Enabled reports whether output to f should be coloured. Auto requires f to
|
||||
// be a terminal and NO_COLOR (https://no-color.org) to be unset or empty, as jq
|
||||
// does, and additionally TERM not to be "dumb".
|
||||
func Enabled(mode Mode, f *os.File) bool {
|
||||
switch mode {
|
||||
case Always:
|
||||
return true
|
||||
case Never:
|
||||
return false
|
||||
}
|
||||
if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" {
|
||||
return false
|
||||
}
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
|
||||
// jqDefault is jq's DEFAULT_COLORS: null, false, true, number, string, array,
|
||||
// object, object key.
|
||||
var jqDefault = [8]string{"0;90", "0;39", "0;39", "0;39", "0;32", "1;39", "1;39", "1;34"}
|
||||
|
||||
const (
|
||||
jqNull = iota
|
||||
jqFalse
|
||||
jqTrue
|
||||
jqNumber
|
||||
jqString
|
||||
jqArray
|
||||
jqObject
|
||||
jqKey
|
||||
)
|
||||
|
||||
// jqColours returns jq's palette with JQ_COLORS applied as jq 1.8.1's
|
||||
// jq_set_colors parses it: colon-separated runs of digits and ';' override the
|
||||
// defaults in order, a trailing empty field is ignored, the eighth field ends at
|
||||
// its first other character and anything after it is ignored, and any other
|
||||
// character in the first seven fields discards the whole variable.
|
||||
func jqColours() [8]string {
|
||||
codes := jqDefault
|
||||
spec := os.Getenv("JQ_COLORS")
|
||||
for i := 0; ; i++ {
|
||||
rest := strings.TrimLeft(spec, "0123456789;")
|
||||
field := spec[:len(spec)-len(rest)]
|
||||
if rest == "" || i+1 == len(codes) {
|
||||
if field != "" {
|
||||
codes[i] = field
|
||||
}
|
||||
break
|
||||
}
|
||||
if rest[0] != ':' {
|
||||
return sgr(jqDefault)
|
||||
}
|
||||
codes[i] = field
|
||||
spec = rest[1:]
|
||||
}
|
||||
return sgr(codes)
|
||||
}
|
||||
|
||||
func sgr(codes [8]string) [8]string {
|
||||
for i, c := range codes {
|
||||
codes[i] = "\x1b[" + c + "m"
|
||||
}
|
||||
return codes
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package colour
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var ansi = regexp.MustCompile("\x1b\\[[0-9;]*m")
|
||||
|
||||
// mark rewrites escapes into readable tags so golden strings stay legible.
|
||||
func mark(s string) string {
|
||||
tags := map[string]string{
|
||||
reset: "</>", yqKey: "<key>", yqString: "<str>", yqNumBool: "<num>",
|
||||
yqAnchor: "<anc>", yqComment: "<com>",
|
||||
"\x1b[0;90m": "<null>", "\x1b[0;39m": "<lit>", "\x1b[0;32m": "<str>",
|
||||
"\x1b[1;39m": "<pun>", "\x1b[1;34m": "<key>",
|
||||
}
|
||||
return ansi.ReplaceAllStringFunc(s, func(e string) string {
|
||||
if t, ok := tags[e]; ok {
|
||||
return t
|
||||
}
|
||||
return e
|
||||
})
|
||||
}
|
||||
|
||||
func render(t *testing.T, f func(*bytes.Buffer, []byte) error, in string) string {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
if err := f(&out, []byte(in)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ansi.ReplaceAllString(out.String(), ""); got != in {
|
||||
t.Fatalf("stripped output differs from input:\n%q\n%q", got, in)
|
||||
}
|
||||
return mark(out.String())
|
||||
}
|
||||
|
||||
func jsonF(b *bytes.Buffer, in []byte) error { return JSON(b, in) }
|
||||
func yamlF(b *bytes.Buffer, in []byte) error { return YAML(b, in) }
|
||||
|
||||
func TestJSON(t *testing.T) {
|
||||
t.Setenv("JQ_COLORS", "")
|
||||
cases := map[string]string{
|
||||
`null`: `<null>null</>`,
|
||||
`false`: `<lit>false</>`,
|
||||
`-1.5e3`: `<lit>-1.5e3</>`,
|
||||
"{\n \"a\\\"b\" : \"x\",\n \"n\": [1, true, {}]\n}\n": "<pun>{</>\n <key>\"a\\\"b\"</> <pun>:</> <str>\"x\"</><pun>,</>\n" +
|
||||
" <key>\"n\"</><pun>:</> <pun>[</><lit>1</><pun>,</> <lit>true</><pun>,</> <pun>{</><pun>}</><pun>]</>\n<pun>}</>\n",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := render(t, jsonF, in); got != want {
|
||||
t.Errorf("JSON(%q)\n got %q\nwant %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJQColors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "\x1b[0;90mnull\x1b[0m",
|
||||
"1;31": "\x1b[1;31mnull\x1b[0m",
|
||||
"1;31:": "\x1b[1;31mnull\x1b[0m",
|
||||
"x": "\x1b[0;90mnull\x1b[0m",
|
||||
"0:0:0:0:0:0:0:0:z": "\x1b[0mnull\x1b[0m",
|
||||
"1:0:0:0:0:0:0:0z!": "\x1b[1mnull\x1b[0m",
|
||||
"1:0:0:0:0:0:0:z": "\x1b[1mnull\x1b[0m",
|
||||
"1:z:0": "\x1b[0;90mnull\x1b[0m",
|
||||
}
|
||||
for env, want := range cases {
|
||||
t.Setenv("JQ_COLORS", env)
|
||||
var out bytes.Buffer
|
||||
if err := JSON(&out, []byte("null")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.String() != want {
|
||||
t.Errorf("JQ_COLORS=%q: got %q want %q", env, out.String(), want)
|
||||
}
|
||||
}
|
||||
// Empty middle fields become a bare ESC[m, as in jq.
|
||||
t.Setenv("JQ_COLORS", "::::::7:4")
|
||||
var out bytes.Buffer
|
||||
_ = JSON(&out, []byte(`{"k":"v"}`))
|
||||
if want := "\x1b[7m{\x1b[0m\x1b[4m\"k\"\x1b[0m\x1b[7m:\x1b[0m\x1b[m\"v\"\x1b[0m\x1b[7m}\x1b[0m"; out.String() != want {
|
||||
t.Errorf("got %q want %q", out.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
const vaultYAML = `---
|
||||
data:
|
||||
# creds
|
||||
password: "s3cr:et" # rotated
|
||||
ttl: 768h
|
||||
count: 0x1F
|
||||
big: 1_000
|
||||
exp: 1e3
|
||||
ratio: -.5
|
||||
on: True
|
||||
gone: ~
|
||||
note: |-
|
||||
line one
|
||||
|
||||
line: two
|
||||
folded: > # c
|
||||
text
|
||||
tags: [a, "b", 3, null]
|
||||
meta: {k: v, "q": 1, u: http://x}
|
||||
'single': 'it''s'
|
||||
empty: []
|
||||
lease:
|
||||
- id: &a abc
|
||||
copy: *a
|
||||
- - nested
|
||||
- !!str 42
|
||||
-
|
||||
...
|
||||
`
|
||||
|
||||
func TestYAML(t *testing.T) {
|
||||
want := `---
|
||||
<key>data</>:
|
||||
<com># creds</>
|
||||
<key>password</>: <str>"s3cr:et"</> <com># rotated</>
|
||||
<key>ttl</>: <str>768h</>
|
||||
<key>count</>: 0x1F
|
||||
<key>big</>: <num>1_000</>
|
||||
<key>exp</>: <str>1e3</>
|
||||
<key>ratio</>: <num>-.5</>
|
||||
<key>on</>: <num>True</>
|
||||
<key>gone</>: ~
|
||||
<key>note</>: |-
|
||||
<str>line one</>
|
||||
|
||||
<str>line: two</>
|
||||
<key>folded</>: > <com># c</>
|
||||
<str>text</>
|
||||
<key>tags</>: [<str>a</>, <str>"b"</>, <num>3</>, null]
|
||||
<key>meta</>: {<key>k</>: <str>v</>, <key>"q"</>: <num>1</>, <key>u</>: <str>http://x</>}
|
||||
<key>'single'</>: <str>'it''s'</>
|
||||
<key>empty</>: []
|
||||
<key>lease</>:
|
||||
- <key>id</>: <anc>&a</> <str>abc</>
|
||||
<key>copy</>: <anc>*a</>
|
||||
- - <str>nested</>
|
||||
- !!str <num>42</>
|
||||
-
|
||||
...
|
||||
`
|
||||
if got := render(t, yamlF, vaultYAML); got != want {
|
||||
t.Errorf("YAML mismatch\n got:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLEdges(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"a: 1\r\nb:\r\n": "<key>a</>: <num>1</>\r\n<key>b</>:\r\n",
|
||||
"k #x: v\n": "<str>k</> <com>#x: v</>\n",
|
||||
"--- a\n": "--- a\n",
|
||||
"x: &anchor\n y: 1\n": "<key>x</>: <anc>&anchor</>\n <key>y</>: <num>1</>\n",
|
||||
"x: !tag\n": "<key>x</>: !tag\n",
|
||||
"x: [a] # c\n": "<key>x</>: [<str>a</>] <com># c</>\n",
|
||||
"x: |\n a\ny: 2\n": "<key>x</>: |\n <str>a</>\n<key>y</>: <num>2</>\n",
|
||||
"\"k\" : v\n": "<key>\"k\"</> : <str>v</>\n",
|
||||
"x: \"a\\\"b\"\n": "<key>x</>: <str>\"a\\\"b\"</>\n",
|
||||
"- [\"a\", \"b\": 1]\n": "- [<str>\"a\"</>, <key>\"b\"</>: <num>1</>]\n",
|
||||
"x: >\n": "<key>x</>: >\n",
|
||||
"- b: |\n x\n c: 2\n": "- <key>b</>: |\n <str>x</>\n <key>c</>: <num>2</>\n",
|
||||
"- - |\n y\n - z: 1\n": "- - |\n <str>y</>\n - <key>z</>: <num>1</>\n",
|
||||
"- certs:\n - pem: |\n -----BEGIN CERTIFICATE-----\n MIIB\n ttl: 768h\n n: 1\n": "- <key>certs</>:\n" +
|
||||
" - <key>pem</>: |\n <str>-----BEGIN CERTIFICATE-----</>\n <str>MIIB</>\n <key>ttl</>: <str>768h</>\n <key>n</>: <num>1</>\n",
|
||||
"w: one two\n\n 42\n# c\nn: 1\n": "<key>w</>: <str>one two</>\n\n <str>42</>\n<com># c</>\n<key>n</>: <num>1</>\n",
|
||||
"q: 'it''s\n ok: y' # c\n": "<key>q</>: <str>'it''s</>\n <str>ok: y'</> <com># c</>\n",
|
||||
"?q: ?v\n?n:\n a: 1\n b: |\n x\nd: 2\n": "<key>?q</>: <str>?v</>\n<key>?n</>:\n <key>a</>: <num>1</>\n" +
|
||||
" <key>b</>: |\n <str>x</>\n<key>d</>: <num>2</>\n",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := render(t, yamlF, in); got != want {
|
||||
t.Errorf("YAML(%q)\n got %q\nwant %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// vault -format=yaml (yaml.v2) folds long strings at 80 columns.
|
||||
func TestYAMLWrapped(t *testing.T) {
|
||||
part := "policy grants read: on secret/data/foo policy grants read: on secret/data/foo"
|
||||
in := "data:\n warning: \"" + part + "\n " + part + "\n \\\"done\\\"\"\n next: 1\n"
|
||||
want := "<key>data</>:\n <key>warning</>: <str>\"" + part + "</>\n <str>" + part +
|
||||
"</>\n <str>\\\"done\\\"\"</>\n <key>next</>: <num>1</>\n"
|
||||
if got := render(t, yamlF, in); got != want {
|
||||
t.Errorf("YAML mismatch\n got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLNumbers(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"1_000": "<num>", "-.5": "<num>", "+1": "<num>", "1.0e+3": "<num>", "-1": "<num>",
|
||||
"1e3": "<str>", "09": "<str>", "_1": "<str>", "1.2.3": "<str>", "+.inf": "<str>",
|
||||
"99999999999999999999": "<str>", "1.5e400": "<str>",
|
||||
"0o17": "", "012": "", "-012": "", "0x1F": "", "0b101": "", ".inf": "", "-.Inf": "", ".nan": "",
|
||||
}
|
||||
for v, tag := range cases {
|
||||
want := "<key>k</>: " + tag + v
|
||||
if tag != "" {
|
||||
want += "</>"
|
||||
}
|
||||
if got := render(t, yamlF, "k: "+v+"\n"); got != want+"\n" {
|
||||
t.Errorf("%s: got %q want %q", v, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassthrough(t *testing.T) {
|
||||
for _, f := range []func(*bytes.Buffer, []byte) error{jsonF, yamlF} {
|
||||
in := "{\"a\": [1,\n"
|
||||
var out bytes.Buffer
|
||||
if err := f(&out, []byte(in)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.String() != in {
|
||||
t.Errorf("invalid input not passed through: %q", out.String())
|
||||
}
|
||||
}
|
||||
table := "Key Value\n--- -----\npassword s3cret\n"
|
||||
var out bytes.Buffer
|
||||
_ = YAML(&out, []byte(table))
|
||||
if ansi.MatchString(out.String()) && ansi.ReplaceAllString(out.String(), "") != table {
|
||||
t.Errorf("table output mangled: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
type failWriter struct{}
|
||||
|
||||
func (failWriter) Write([]byte) (int, error) { return 0, errors.New("boom") }
|
||||
|
||||
func TestWriteError(t *testing.T) {
|
||||
for _, in := range []string{"{}", "{", "a: 1", "a: ["} {
|
||||
if JSON(failWriter{}, []byte(in)) == nil || YAML(failWriter{}, []byte(in)) == nil {
|
||||
t.Errorf("write error swallowed for %q", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnabled(t *testing.T) {
|
||||
f, err := os.Create(filepath.Join(t.TempDir(), "out"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
t.Setenv("NO_COLOR", "")
|
||||
t.Setenv("TERM", "xterm")
|
||||
if Enabled(Auto, f) {
|
||||
t.Error("Auto coloured a regular file")
|
||||
}
|
||||
if !Enabled(Always, f) || Enabled(Never, f) {
|
||||
t.Error("Always/Never not honoured")
|
||||
}
|
||||
tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Skip("no controlling terminal")
|
||||
}
|
||||
defer func() { _ = tty.Close() }()
|
||||
if !Enabled(Auto, tty) {
|
||||
t.Error("Auto did not colour a terminal")
|
||||
}
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
if Enabled(Auto, tty) || !Enabled(Always, tty) {
|
||||
t.Error("NO_COLOR not honoured, or overrode Always")
|
||||
}
|
||||
t.Setenv("NO_COLOR", "")
|
||||
t.Setenv("TERM", "dumb")
|
||||
if Enabled(Auto, tty) {
|
||||
t.Error("TERM=dumb coloured")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package colour
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
// JSON writes in to w with jq's colours, honouring JQ_COLORS. Brackets, braces,
|
||||
// commas and colons take their container's colour, as in jq.
|
||||
func JSON(w io.Writer, in []byte) error {
|
||||
if !json.Valid(in) {
|
||||
_, err := w.Write(in)
|
||||
return err
|
||||
}
|
||||
c := jqColours()
|
||||
var out bytes.Buffer
|
||||
var stack []byte
|
||||
paint := func(col string, tok []byte) {
|
||||
out.WriteString(col)
|
||||
out.Write(tok)
|
||||
out.WriteString(reset)
|
||||
}
|
||||
container := func() string {
|
||||
if len(stack) > 0 && stack[len(stack)-1] == '{' {
|
||||
return c[jqObject]
|
||||
}
|
||||
return c[jqArray]
|
||||
}
|
||||
for i := 0; i < len(in); {
|
||||
switch b := in[i]; b {
|
||||
case ' ', '\t', '\r', '\n':
|
||||
out.WriteByte(b)
|
||||
i++
|
||||
case '{', '[':
|
||||
stack = append(stack, b)
|
||||
paint(container(), in[i:i+1])
|
||||
i++
|
||||
case '}', ']':
|
||||
paint(container(), in[i:i+1])
|
||||
stack = stack[:len(stack)-1]
|
||||
i++
|
||||
case ',', ':':
|
||||
paint(container(), in[i:i+1])
|
||||
i++
|
||||
case '"':
|
||||
j := i + 1
|
||||
for in[j] != '"' {
|
||||
if in[j] == '\\' {
|
||||
j++
|
||||
}
|
||||
j++
|
||||
}
|
||||
j++
|
||||
col := c[jqString]
|
||||
if k := skipSpace(in, j); k < len(in) && in[k] == ':' {
|
||||
col = c[jqKey]
|
||||
}
|
||||
paint(col, in[i:j])
|
||||
i = j
|
||||
default:
|
||||
j := i
|
||||
for j < len(in) && !bytes.ContainsRune([]byte(" \t\r\n,]}"), rune(in[j])) {
|
||||
j++
|
||||
}
|
||||
col := c[jqNumber]
|
||||
switch string(in[i:j]) {
|
||||
case "null":
|
||||
col = c[jqNull]
|
||||
case "true":
|
||||
col = c[jqTrue]
|
||||
case "false":
|
||||
col = c[jqFalse]
|
||||
}
|
||||
paint(col, in[i:j])
|
||||
i = j
|
||||
}
|
||||
}
|
||||
_, err := w.Write(out.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
func skipSpace(in []byte, i int) int {
|
||||
for i < len(in) && (in[i] == ' ' || in[i] == '\t' || in[i] == '\r' || in[i] == '\n') {
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
package colour
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// yq's palette (pkg/yqlib/color_print.go). Null, tags, flow punctuation,
|
||||
// block-scalar indicators and document markers stay uncoloured, as in yq.
|
||||
const (
|
||||
yqKey = "\x1b[36m"
|
||||
yqString = "\x1b[32m"
|
||||
yqNumBool = "\x1b[95m"
|
||||
yqAnchor = "\x1b[93m"
|
||||
yqComment = "\x1b[90m"
|
||||
)
|
||||
|
||||
// Kinds of scalar left open at the end of a line.
|
||||
const (
|
||||
openNone = 0
|
||||
openBlock = '|'
|
||||
openPlain = 'p'
|
||||
)
|
||||
|
||||
// YAML writes in to w with yq's colours. It handles block mappings and
|
||||
// sequences, plain and quoted scalars (including multi-line ones), '|' and
|
||||
// '>' blocks, single-line flow collections, anchors, aliases and comments.
|
||||
func YAML(w io.Writer, in []byte) error {
|
||||
if !validYAML(in) {
|
||||
_, err := w.Write(in)
|
||||
return err
|
||||
}
|
||||
var out bytes.Buffer
|
||||
// open is the scalar continuing onto the next line: a block or plain
|
||||
// scalar owned by the node at column parent, or an unterminated quote.
|
||||
open, parent := byte(openNone), 0
|
||||
for _, line := range strings.SplitAfter(string(in), "\n") {
|
||||
body := strings.TrimRight(line, "\r\n")
|
||||
eol := line[len(body):]
|
||||
trimmed := strings.TrimLeft(body, " ")
|
||||
indent := len(body) - len(trimmed)
|
||||
out.WriteString(body[:indent])
|
||||
switch {
|
||||
case open == '"' || open == '\'':
|
||||
if end := quoteClose(trimmed, open); end < 0 {
|
||||
paint(&out, yqString, trimmed)
|
||||
} else {
|
||||
paint(&out, yqString, trimmed[:end])
|
||||
comment(&out, trimmed[end:])
|
||||
open = openNone
|
||||
}
|
||||
case open != openNone && (trimmed == "" || indent > parent && (open == openBlock || trimmed[0] != '#')):
|
||||
paint(&out, yqString, trimmed)
|
||||
case trimmed == "---" || trimmed == "..." || strings.HasPrefix(trimmed, "--- "):
|
||||
out.WriteString(trimmed)
|
||||
open = openNone
|
||||
default:
|
||||
var col int
|
||||
col, open = yamlNode(&out, trimmed)
|
||||
parent = indent + col
|
||||
}
|
||||
out.WriteString(eol)
|
||||
}
|
||||
_, err := w.Write(out.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
func validYAML(in []byte) bool {
|
||||
dec := yaml.NewDecoder(bytes.NewReader(in))
|
||||
for {
|
||||
var n yaml.Node
|
||||
err := dec.Decode(&n)
|
||||
if errors.Is(err, io.EOF) {
|
||||
return true
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func paint(out *bytes.Buffer, col, s string) {
|
||||
if col == "" || s == "" {
|
||||
out.WriteString(s)
|
||||
return
|
||||
}
|
||||
out.WriteString(col)
|
||||
out.WriteString(s)
|
||||
out.WriteString(reset)
|
||||
}
|
||||
|
||||
// yamlNode colours one line's content after its indentation. It returns the
|
||||
// column, relative to s, of the innermost node (the last '-' or the key) and
|
||||
// the kind of scalar left open at the end of the line.
|
||||
func yamlNode(out *bytes.Buffer, s string) (int, byte) {
|
||||
col, off := 0, 0
|
||||
for strings.HasPrefix(s, "- ") || s == "-" {
|
||||
n := len(s) - len(strings.TrimLeft(s[1:], " ")) // dash plus spaces
|
||||
out.WriteString(s[:n])
|
||||
col, off, s = off, off+n, s[n:]
|
||||
}
|
||||
if s == "" {
|
||||
return col, openNone
|
||||
}
|
||||
if s[0] == '#' {
|
||||
paint(out, yqComment, s)
|
||||
return col, openNone
|
||||
}
|
||||
if k := keyEnd(s); k > 0 {
|
||||
col = off
|
||||
paint(out, yqKey, s[:k])
|
||||
rest := s[k:]
|
||||
colon := strings.IndexByte(rest, ':') + 1
|
||||
out.WriteString(rest[:colon])
|
||||
rest = rest[colon:]
|
||||
value := strings.TrimLeft(rest, " ")
|
||||
out.WriteString(rest[:len(rest)-len(value)])
|
||||
s = value
|
||||
}
|
||||
return col, yamlValue(out, s)
|
||||
}
|
||||
|
||||
// keyEnd returns the length of a mapping key at the start of s, or 0 when s is
|
||||
// not a key/value pair.
|
||||
func keyEnd(s string) int {
|
||||
end := 0
|
||||
switch {
|
||||
case s[0] == '"' || s[0] == '\'':
|
||||
end = quoteEnd(s)
|
||||
case s[0] == '?' && (len(s) == 1 || s[1] == ' '):
|
||||
return 0
|
||||
case strings.IndexByte("[{&*!|>%@`", s[0]) >= 0:
|
||||
return 0
|
||||
default:
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == ':' && (i+1 == len(s) || s[i+1] == ' ') {
|
||||
return len(strings.TrimRight(s[:i], " "))
|
||||
}
|
||||
if s[i] == '#' && i > 0 && s[i-1] == ' ' {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
rest := strings.TrimLeft(s[end:], " ")
|
||||
if strings.HasPrefix(rest, ":") && (len(rest) == 1 || rest[1] == ' ') {
|
||||
return end
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// quoteEnd returns the index just past the quoted scalar starting s, or len(s)
|
||||
// when it is unterminated on this line.
|
||||
func quoteEnd(s string) int {
|
||||
if end := quoteClose(s[1:], s[0]); end >= 0 {
|
||||
return end + 1
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// quoteClose returns the index just past the quote q closing a scalar whose
|
||||
// opening quote precedes s, or -1 when s does not close it.
|
||||
func quoteClose(s string, q byte) int {
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch {
|
||||
case q == '"' && s[i] == '\\':
|
||||
i++
|
||||
case s[i] == q && q == '\'' && i+1 < len(s) && s[i+1] == '\'':
|
||||
i++
|
||||
case s[i] == q:
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// yamlValue colours a scalar or flow collection with any trailing comment and
|
||||
// returns the kind of scalar it leaves open.
|
||||
func yamlValue(out *bytes.Buffer, s string) byte {
|
||||
if s == "" {
|
||||
return openNone
|
||||
}
|
||||
for s[0] == '&' || s[0] == '!' {
|
||||
n := strings.IndexByte(s, ' ')
|
||||
if n < 0 {
|
||||
n = len(s)
|
||||
}
|
||||
if s[0] == '&' {
|
||||
paint(out, yqAnchor, s[:n])
|
||||
} else {
|
||||
out.WriteString(s[:n])
|
||||
}
|
||||
v := strings.TrimLeft(s[n:], " ")
|
||||
out.WriteString(s[n : len(s)-len(v)])
|
||||
if s = v; s == "" {
|
||||
return openNone
|
||||
}
|
||||
}
|
||||
switch s[0] {
|
||||
case '#':
|
||||
paint(out, yqComment, s)
|
||||
return openNone
|
||||
case '|', '>':
|
||||
n := strings.IndexByte(s, ' ')
|
||||
if n < 0 {
|
||||
n = len(s)
|
||||
}
|
||||
out.WriteString(s[:n])
|
||||
comment(out, s[n:])
|
||||
return openBlock
|
||||
case '[', '{':
|
||||
flow(out, s)
|
||||
return openNone
|
||||
}
|
||||
end, open := len(s), byte(openPlain)
|
||||
switch s[0] {
|
||||
case '"', '\'':
|
||||
if end, open = quoteClose(s[1:], s[0])+1, openNone; end == 0 {
|
||||
end, open = len(s), s[0]
|
||||
}
|
||||
case '*':
|
||||
open = openNone
|
||||
default:
|
||||
if i := strings.Index(s, " #"); i >= 0 {
|
||||
end, open = i, openNone
|
||||
}
|
||||
}
|
||||
val := strings.TrimRight(s[:end], " ")
|
||||
paint(out, scalarColour(val), val)
|
||||
comment(out, s[len(val):])
|
||||
return open
|
||||
}
|
||||
|
||||
// comment writes rest, colouring a trailing "# ..." comment.
|
||||
func comment(out *bytes.Buffer, rest string) {
|
||||
i := strings.IndexByte(rest, '#')
|
||||
if i < 0 {
|
||||
out.WriteString(rest)
|
||||
return
|
||||
}
|
||||
out.WriteString(rest[:i])
|
||||
paint(out, yqComment, rest[i:])
|
||||
}
|
||||
|
||||
func scalarColour(v string) string {
|
||||
switch v[0] {
|
||||
case '"', '\'':
|
||||
return yqString
|
||||
case '*':
|
||||
return yqAnchor
|
||||
}
|
||||
switch v {
|
||||
case "null", "Null", "NULL", "~", ".inf", ".Inf", ".INF", "-.inf", "-.Inf", "-.INF", ".nan", ".NaN", ".NAN":
|
||||
return ""
|
||||
case "true", "True", "TRUE", "false", "False", "FALSE":
|
||||
return yqNumBool
|
||||
}
|
||||
if col, ok := number(v); ok {
|
||||
return col
|
||||
}
|
||||
return yqString
|
||||
}
|
||||
|
||||
// number mirrors goccy/go-yaml v1.19.2 token.toNumber, which yq's lexer uses:
|
||||
// decimal ints and floats get the number colour; hex, octal (0o or a leading
|
||||
// 0) and binary ints are typed but yq leaves them uncoloured.
|
||||
func number(v string) (string, bool) {
|
||||
if v == "" || v[0] == '_' || strings.Count(v, ".") > 1 {
|
||||
return "", false
|
||||
}
|
||||
neg := v[0] == '-'
|
||||
n := strings.ReplaceAll(strings.TrimPrefix(strings.TrimPrefix(v, "+"), "-"), "_", "")
|
||||
base, col := 10, ""
|
||||
switch {
|
||||
case strings.HasPrefix(n, "0x"):
|
||||
n, base = n[2:], 16
|
||||
case strings.HasPrefix(n, "0o"):
|
||||
n, base = n[2:], 8
|
||||
case strings.HasPrefix(n, "0b"):
|
||||
n, base = n[2:], 2
|
||||
case strings.HasPrefix(n, "0") && len(n) > 1 && !strings.Contains(v, "."):
|
||||
base = 8
|
||||
case strings.Contains(v, "."):
|
||||
if neg {
|
||||
n = "-" + n
|
||||
}
|
||||
_, err := strconv.ParseFloat(n, 64)
|
||||
return yqNumBool, err == nil
|
||||
default:
|
||||
col = yqNumBool
|
||||
}
|
||||
var err error
|
||||
if neg {
|
||||
_, err = strconv.ParseInt("-"+n, base, 64)
|
||||
} else {
|
||||
_, err = strconv.ParseUint(n, base, 64)
|
||||
}
|
||||
return col, err == nil
|
||||
}
|
||||
|
||||
// flow colours a single-line flow collection: punctuation plain, keys and
|
||||
// scalars as in block style.
|
||||
func flow(out *bytes.Buffer, s string) {
|
||||
for len(s) > 0 {
|
||||
switch c := s[0]; {
|
||||
case strings.IndexByte("[]{},: ", c) >= 0:
|
||||
out.WriteByte(c)
|
||||
s = s[1:]
|
||||
case c == '#':
|
||||
paint(out, yqComment, s)
|
||||
return
|
||||
default:
|
||||
end, scope := len(s), s
|
||||
if c == '"' || c == '\'' {
|
||||
end = quoteEnd(s)
|
||||
} else if i := strings.IndexAny(s, ",]}"); i >= 0 {
|
||||
end, scope = i, s[:i]
|
||||
}
|
||||
tok := s[:end]
|
||||
if k := keyEnd(scope); k > 0 {
|
||||
paint(out, yqKey, tok[:k])
|
||||
s = s[k:]
|
||||
continue
|
||||
}
|
||||
tok = strings.TrimRight(tok, " ")
|
||||
paint(out, scalarColour(tok), tok)
|
||||
s = s[len(tok):]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ require (
|
||||
github.com/testcontainers/testcontainers-go v0.44.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -63,5 +65,4 @@ require (
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user