Merge main
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

This commit is contained in:
2026-10-23 22:50:22 +11:00
6 changed files with 789 additions and 1 deletions
+1
View File
@@ -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
+87
View File
@@ -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
}
+276
View File
@@ -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")
}
}
+88
View File
@@ -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
View File
@@ -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):]
}
}
}
+2 -1
View File
@@ -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
)