From 2dbcb23a423527043ea2775782cb114f20c2adf2 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 14 Aug 2026 14:10:04 +0300 Subject: [PATCH 1/7] feat(monarch): add grammar-driven Monarch language bundle generation Generate Monaco tokenizers and configurations from CEL, Go template, and JSONPath vocabularies. Keep editor highlighting aligned with evaluator grammars and registered functions, with deterministic output and lexer-backed conformance validation. --- genmonarch/build.go | 82 ++++++ genmonarch/conformance.go | 227 +++++++++++++++ genmonarch/conformance_validate.go | 35 +++ genmonarch/grammar/CEL.g4 | 207 ++++++++++++++ genmonarch/grammar/antlr_parse.go | 272 ++++++++++++++++++ genmonarch/grammar/antlr_regex.go | 295 +++++++++++++++++++ genmonarch/grammar/antlr_test.go | 95 +++++++ genmonarch/grammar/cel.go | 235 ++++++++++++++++ genmonarch/grammar/cel_test.go | 168 +++++++++++ genmonarch/grammar/gotemplate.go | 231 +++++++++++++++ genmonarch/grammar/gotemplate_test.go | 45 +++ genmonarch/grammar/suite_test.go | 13 + genmonarch/lang_cel.go | 216 ++++++++++++++ genmonarch/lang_embedded.go | 161 +++++++++++ genmonarch/lang_gotemplate.go | 167 +++++++++++ genmonarch/lang_jsonpath.go | 68 +++++ genmonarch/monarch.go | 338 ++++++++++++++++++++++ genmonarch/render.go | 127 +++++++++ genmonarch/spec.go | 104 +++++++ genmonarch/suite_test.go | 13 + genmonarch/vocab_cel.go | 158 +++++++++++ genmonarch/vocab_cel_test.go | 99 +++++++ genmonarch/vocab_gotemplate.go | 390 ++++++++++++++++++++++++++ genmonarch/vocab_gotemplate_test.go | 73 +++++ 24 files changed, 3819 insertions(+) create mode 100644 genmonarch/build.go create mode 100644 genmonarch/conformance.go create mode 100644 genmonarch/conformance_validate.go create mode 100644 genmonarch/grammar/CEL.g4 create mode 100644 genmonarch/grammar/antlr_parse.go create mode 100644 genmonarch/grammar/antlr_regex.go create mode 100644 genmonarch/grammar/antlr_test.go create mode 100644 genmonarch/grammar/cel.go create mode 100644 genmonarch/grammar/cel_test.go create mode 100644 genmonarch/grammar/gotemplate.go create mode 100644 genmonarch/grammar/gotemplate_test.go create mode 100644 genmonarch/grammar/suite_test.go create mode 100644 genmonarch/lang_cel.go create mode 100644 genmonarch/lang_embedded.go create mode 100644 genmonarch/lang_gotemplate.go create mode 100644 genmonarch/lang_jsonpath.go create mode 100644 genmonarch/monarch.go create mode 100644 genmonarch/render.go create mode 100644 genmonarch/spec.go create mode 100644 genmonarch/suite_test.go create mode 100644 genmonarch/vocab_cel.go create mode 100644 genmonarch/vocab_cel_test.go create mode 100644 genmonarch/vocab_gotemplate.go create mode 100644 genmonarch/vocab_gotemplate_test.go diff --git a/genmonarch/build.go b/genmonarch/build.go new file mode 100644 index 000000000..9f2ee1f88 --- /dev/null +++ b/genmonarch/build.go @@ -0,0 +1,82 @@ +package genmonarch + +import ( + "fmt" + + "github.com/google/cel-go/cel" + + "github.com/flanksource/gomplate/v3/genmonarch/grammar" +) + +// Bundle is everything the npm package ships: one Monarch definition and one +// language configuration per language id, plus the shared function catalogue +// and the conformance corpus. +type Bundle struct { + Spec Spec + Languages map[string]Language + Configurations map[string]Configuration + Conformance []ConformanceCase + // Order lists the language ids deterministically, for stable file output. + Order []string +} + +// Build assembles every language from gomplate's own grammars and registries. +// docs supplies the Markdown references the conformance corpus draws snippets +// from, keyed by filename. extraCEL layers a host's own CEL options in, so a +// binary that registers extra functions can generate a bundle that knows them. +func Build(docs map[string]string, extraCEL ...cel.EnvOption) (*Bundle, error) { + celGrammar, err := grammar.ParseCEL() + if err != nil { + return nil, fmt.Errorf("reading the CEL grammar: %w", err) + } + celSpec, err := ExtractCEL(extraCEL...) + if err != nil { + return nil, fmt.Errorf("reading the CEL environment: %w", err) + } + goSpec, err := ExtractGoTemplate() + if err != nil { + return nil, fmt.Errorf("reading the go-template functions: %w", err) + } + + bundle := &Bundle{ + Spec: Spec{CEL: celSpec, GoTemplate: goSpec}, + Languages: map[string]Language{}, + Configurations: map[string]Configuration{}, + } + + celLang, celConfig := BuildCEL(celGrammar, celSpec) + bundle.add(celLang, celConfig) + + goLang, goConfig, err := BuildGoTemplate(goSpec, goSpec.Delimiters) + if err != nil { + return nil, fmt.Errorf("building the gomplate language: %w", err) + } + bundle.add(goLang, goConfig) + + for _, host := range []Host{HostYAML, HostJSON, HostText} { + lang, config, err := BuildEmbedded(host, goSpec, goSpec.Delimiters) + if err != nil { + return nil, fmt.Errorf("building the %s host language: %w", host, err) + } + bundle.add(lang, config) + } + + jsonPathLang, jsonPathConfig := BuildJSONPath() + bundle.add(jsonPathLang, jsonPathConfig) + + bundle.Conformance, err = BuildConformance(docs) + if err != nil { + return nil, fmt.Errorf("building the conformance corpus: %w", err) + } + if err := ValidateCorpus(bundle.Conformance); err != nil { + return nil, fmt.Errorf("validating the conformance corpus: %w", err) + } + + return bundle, nil +} + +func (b *Bundle) add(lang Language, config Configuration) { + b.Languages[lang.ID] = lang + b.Configurations[lang.ID] = config + b.Order = append(b.Order, lang.ID) +} diff --git a/genmonarch/conformance.go b/genmonarch/conformance.go new file mode 100644 index 000000000..94f866fd4 --- /dev/null +++ b/genmonarch/conformance.go @@ -0,0 +1,227 @@ +package genmonarch + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "github.com/antlr4-go/antlr/v4" + celparser "github.com/google/cel-go/parser/gen" +) + +// ConformanceCase is one snippet with the token boundaries the language's real +// lexer produces. +// +// The generator sits next to the parsers gomplate evaluates with, so it can +// produce the oracle rather than a snapshot of the tokenizer's own output. The +// browser test replays these through Monaco and asserts the boundaries agree. +// +// Boundaries, not token classes: Monarch says `namespace` and `function` where +// the lexer only says IDENTIFIER, so the classes are not comparable. The +// boundaries are, and they are where the subtle bugs live -- a triple-quoted +// string cut short, `0x1f` truncated to `0`, `123u` split into a number and an +// identifier. +type ConformanceCase struct { + Language string `json:"language"` + Source string `json:"source"` + // Boundaries are 0-based offsets where a token starts, excluding + // whitespace, in ascending order. + Boundaries []int `json:"boundaries"` + // Origin records where the snippet came from, so a failure is traceable. + Origin string `json:"origin"` +} + +// celEdgeCases are the lexical corners no documentation example happens to +// cover. Each one is a shape that a hand-written tokenizer gets wrong. +var celEdgeCases = []string{ + "`escaped.identifier-1`", + `"""triple "quoted" string"""`, + `'''triple 'quoted' string'''`, + `r"raw\dstring"`, + `R'raw\dstring'`, + `r"""raw triple \d"""`, + `b"bytes"`, + `B'bytes'`, + `"\x41A\U0001F600\101"`, + `123u + 0x1fU + 0x1f + 1.5e-3 + .5`, + `a.?b.orValue("x")`, + `m[?"k"]`, + `cond ? "yes" : "no"`, + `[1, 2, 3].fold(e, acc, acc + e)`, + `k8s.isHealthy(pod) && pod.status.?phase.orValue("") == "Running"`, + `"a" + // trailing comment`, +} + +// goTemplateEdgeCases exercise delimiters, trimming and comments. +var goTemplateEdgeCases = []string{ + `{{ .name | strings.ToUpper }}`, + `{{- if .enabled -}}on{{- else -}}off{{- end -}}`, + `{{/* a comment with {{ braces }} in it */}}`, + "{{ $x := coll.Dict \"a\" 1 }}{{ $x }}", + "{{ printf \"%s-%d\" .name 3 }}", + "{{ `raw string` }}", +} + +var jsonPathEdgeCases = []string{ + `$.store.book[0].title`, + `$..author`, + `$.store.book[?(@.price < 10)]`, + `$.items[*]`, + `$['quoted key']`, + `$.items[0:2]`, +} + +// fencedBlock matches a fenced code block in the Markdown references. +var fencedBlock = regexp.MustCompile("(?s)```[a-zA-Z]*\n(.*?)```") + +// BuildConformance assembles the corpus. docs maps a filename to its contents; +// the caller reads them so this stays testable without touching the disk. +func BuildConformance(docs map[string]string) ([]ConformanceCase, error) { + var cases []ConformanceCase + + for _, source := range celEdgeCases { + c, err := celCase(source, "edge-case") + if err != nil { + return nil, err + } + cases = append(cases, c) + } + + for _, name := range sortedKeys(docs) { + for _, source := range celSnippetsFrom(docs[name]) { + c, err := celCase(source, name) + if err != nil { + // A snippet the real lexer rejects is prose, not code. + continue + } + cases = append(cases, c) + } + } + + for _, source := range goTemplateEdgeCases { + cases = append(cases, ConformanceCase{ + Language: GoTemplateLanguageID, + Source: source, + Origin: "edge-case", + }) + } + for _, source := range jsonPathEdgeCases { + cases = append(cases, ConformanceCase{ + Language: JSONPathLanguageID, + Source: source, + Origin: "edge-case", + }) + } + + return dedupeCases(cases), nil +} + +// celCase lexes a snippet with cel-go's own ANTLR lexer and records where each +// token starts. +func celCase(source, origin string) (ConformanceCase, error) { + boundaries, err := celTokenBoundaries(source) + if err != nil { + return ConformanceCase{}, err + } + return ConformanceCase{ + Language: CELLanguageID, + Source: source, + Boundaries: boundaries, + Origin: origin, + }, nil +} + +// celTokenBoundaries runs the generated CEL lexer and returns the start offset +// of every non-whitespace token. +func celTokenBoundaries(source string) ([]int, error) { + lexer := celparser.NewCELLexer(antlr.NewInputStream(source)) + lexer.RemoveErrorListeners() + + failed := &lexErrorListener{} + lexer.AddErrorListener(failed) + + var boundaries []int + for { + token := lexer.NextToken() + if token == nil || token.GetTokenType() == antlr.TokenEOF { + break + } + if token.GetTokenType() == celparser.CELLexerWHITESPACE { + continue + } + boundaries = append(boundaries, token.GetStart()) + } + if failed.err != nil { + return nil, failed.err + } + if len(boundaries) == 0 { + return nil, fmt.Errorf("no tokens in %q", source) + } + return boundaries, nil +} + +// lexErrorListener records the first lexical error, so a snippet the real lexer +// rejects never reaches the corpus. +type lexErrorListener struct { + *antlr.DefaultErrorListener + err error +} + +func (l *lexErrorListener) SyntaxError(_ antlr.Recognizer, _ any, line, column int, msg string, _ antlr.RecognitionException) { + if l.err == nil { + l.err = fmt.Errorf("%d:%d: %s", line, column, msg) + } +} + +// celSnippetsFrom pulls single-line CEL expressions out of a Markdown +// reference. Comment-only and prose lines are skipped; anything the lexer +// rejects is dropped by the caller. +func celSnippetsFrom(markdown string) []string { + var out []string + for _, block := range fencedBlock.FindAllStringSubmatch(markdown, -1) { + for _, line := range strings.Split(block[1], "\n") { + line = strings.TrimSpace(line) + // `//` opens a CEL comment, and the references use it to show the + // expected value, so keep only what precedes it. + if i := strings.Index(line, "//"); i >= 0 { + line = strings.TrimSpace(line[:i]) + } + if len(line) < 3 || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "$") { + continue + } + // Anything with template or shell syntax is not a CEL expression. + if strings.ContainsAny(line, "{}") && strings.Contains(line, "{{") { + continue + } + out = append(out, line) + } + } + return out +} + +func dedupeCases(cases []ConformanceCase) []ConformanceCase { + sort.SliceStable(cases, func(i, j int) bool { + if cases[i].Language != cases[j].Language { + return cases[i].Language < cases[j].Language + } + return cases[i].Source < cases[j].Source + }) + out := cases[:0] + for i, c := range cases { + if i > 0 && c.Language == cases[i-1].Language && c.Source == cases[i-1].Source { + continue + } + out = append(out, c) + } + return out +} + +func sortedKeys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/genmonarch/conformance_validate.go b/genmonarch/conformance_validate.go new file mode 100644 index 000000000..4a9b6a2e7 --- /dev/null +++ b/genmonarch/conformance_validate.go @@ -0,0 +1,35 @@ +package genmonarch + +import ( + "context" + "fmt" + "text/template" + + "github.com/ohler55/ojg/jp" + + gomplate "github.com/flanksource/gomplate/v3" +) + +// ValidateCorpus parses every non-CEL snippet with the parser that actually +// evaluates it, so the corpus cannot drift into asserting behaviour for input +// gomplate would reject. +// +// CEL snippets are validated as they are built: celTokenBoundaries fails on any +// lexical error. +func ValidateCorpus(cases []ConformanceCase) error { + funcs := gomplate.CreateFuncs(context.Background()) + + for _, c := range cases { + switch c.Language { + case GoTemplateLanguageID: + if _, err := template.New("conformance").Funcs(funcs).Parse(c.Source); err != nil { + return fmt.Errorf("go template %q (%s): %w", c.Source, c.Origin, err) + } + case JSONPathLanguageID: + if _, err := jp.ParseString(c.Source); err != nil { + return fmt.Errorf("jsonpath %q (%s): %w", c.Source, c.Origin, err) + } + } + } + return nil +} diff --git a/genmonarch/grammar/CEL.g4 b/genmonarch/grammar/CEL.g4 new file mode 100644 index 000000000..ee53a844b --- /dev/null +++ b/genmonarch/grammar/CEL.g4 @@ -0,0 +1,207 @@ +// Copyright 2018 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +grammar CEL; + +// Grammar Rules +// ============= + +start + : e=expr EOF + ; + +expr + : e=conditionalOr (op='?' e1=conditionalOr ':' e2=expr)? + ; + +conditionalOr + : e=conditionalAnd (ops+='||' e1+=conditionalAnd)* + ; + +conditionalAnd + : e=relation (ops+='&&' e1+=relation)* + ; + +relation + : calc + | relation op=('<'|'<='|'>='|'>'|'=='|'!='|'in') relation + ; + +calc + : unary + | calc op=('*'|'/'|'%') calc + | calc op=('+'|'-') calc + ; + +unary + : member # MemberExpr + | (ops+='!')+ member # LogicalNot + | (ops+='-')+ member # Negate + ; + +member + : primary # PrimaryExpr + | member op='.' (opt='?')? id=escapeIdent # Select + | member op='.' id=IDENTIFIER open='(' args=exprList? ')' # MemberCall + | member op='[' (opt='?')? index=expr ']' # Index + ; + +primary + : leadingDot='.'? id=IDENTIFIER # Ident + | leadingDot='.'? id=IDENTIFIER (op='(' args=exprList? ')') # GlobalCall + | '(' e=expr ')' # Nested + | op='[' elems=listInit? ','? ']' # CreateList + | op='{' entries=mapInitializerList? ','? '}' # CreateStruct + | leadingDot='.'? ids+=IDENTIFIER (ops+='.' ids+=IDENTIFIER)* + op='{' entries=fieldInitializerList? ','? '}' # CreateMessage + | literal # ConstantLiteral + ; + +exprList + : e+=expr (',' e+=expr)* + ; + +listInit + : elems+=optExpr (',' elems+=optExpr)* + ; + +fieldInitializerList + : fields+=optField cols+=':' values+=expr (',' fields+=optField cols+=':' values+=expr)* + ; + +optField + : (opt='?')? escapeIdent + ; + +mapInitializerList + : keys+=optExpr cols+=':' values+=expr (',' keys+=optExpr cols+=':' values+=expr)* + ; + +escapeIdent + : id=IDENTIFIER # SimpleIdentifier + | id=ESC_IDENTIFIER # EscapedIdentifier +; + +optExpr + : (opt='?')? e=expr + ; + +literal + : sign=MINUS? tok=NUM_INT # Int + | tok=NUM_UINT # Uint + | sign=MINUS? tok=NUM_FLOAT # Double + | tok=STRING # String + | tok=BYTES # Bytes + | tok=CEL_TRUE # BoolTrue + | tok=CEL_FALSE # BoolFalse + | tok=NUL # Null + ; + +// Lexer Rules +// =========== + +EQUALS : '=='; +NOT_EQUALS : '!='; +IN: 'in'; +LESS : '<'; +LESS_EQUALS : '<='; +GREATER_EQUALS : '>='; +GREATER : '>'; +LOGICAL_AND : '&&'; +LOGICAL_OR : '||'; + +LBRACKET : '['; +RPRACKET : ']'; +LBRACE : '{'; +RBRACE : '}'; +LPAREN : '('; +RPAREN : ')'; +DOT : '.'; +COMMA : ','; +MINUS : '-'; +EXCLAM : '!'; +QUESTIONMARK : '?'; +COLON : ':'; +PLUS : '+'; +STAR : '*'; +SLASH : '/'; +PERCENT : '%'; +CEL_TRUE : 'true'; +CEL_FALSE : 'false'; +NUL : 'null'; + +fragment BACKSLASH : '\\'; +fragment LETTER : 'A'..'Z' | 'a'..'z' ; +fragment DIGIT : '0'..'9' ; +fragment EXPONENT : ('e' | 'E') ( '+' | '-' )? DIGIT+ ; +fragment HEXDIGIT : ('0'..'9'|'a'..'f'|'A'..'F') ; +fragment RAW : 'r' | 'R'; + +fragment ESC_SEQ + : ESC_CHAR_SEQ + | ESC_BYTE_SEQ + | ESC_UNI_SEQ + | ESC_OCT_SEQ + ; + +fragment ESC_CHAR_SEQ + : BACKSLASH ('a'|'b'|'f'|'n'|'r'|'t'|'v'|'"'|'\''|'\\'|'?'|'`') + ; + +fragment ESC_OCT_SEQ + : BACKSLASH ('0'..'3') ('0'..'7') ('0'..'7') + ; + +fragment ESC_BYTE_SEQ + : BACKSLASH ( 'x' | 'X' ) HEXDIGIT HEXDIGIT + ; + +fragment ESC_UNI_SEQ + : BACKSLASH 'u' HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT + | BACKSLASH 'U' HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT + ; + +WHITESPACE : ( '\t' | ' ' | '\r' | '\n'| '\u000C' )+ -> channel(HIDDEN) ; +COMMENT : '//' (~'\n')* -> channel(HIDDEN) ; + +NUM_FLOAT + : ( DIGIT+ ('.' DIGIT+) EXPONENT? + | DIGIT+ EXPONENT + | '.' DIGIT+ EXPONENT? + ) + ; + +NUM_INT + : ( DIGIT+ | '0x' HEXDIGIT+ ); + +NUM_UINT + : DIGIT+ ( 'u' | 'U' ) + | '0x' HEXDIGIT+ ( 'u' | 'U' ) + ; + +STRING + : '"' (ESC_SEQ | ~('\\'|'"'|'\n'|'\r'))* '"' + | '\'' (ESC_SEQ | ~('\\'|'\''|'\n'|'\r'))* '\'' + | '"""' (ESC_SEQ | ~('\\'))*? '"""' + | '\'\'\'' (ESC_SEQ | ~('\\'))*? '\'\'\'' + | RAW '"' ~('"'|'\n'|'\r')* '"' + | RAW '\'' ~('\''|'\n'|'\r')* '\'' + | RAW '"""' .*? '"""' + | RAW '\'\'\'' .*? '\'\'\'' + ; + +BYTES : ('b' | 'B') STRING; + +IDENTIFIER : (LETTER | '_') ( LETTER | DIGIT | '_')*; +ESC_IDENTIFIER : '`' (LETTER | DIGIT | '_' | '.' | '-' | '/' | ' ')+ '`'; \ No newline at end of file diff --git a/genmonarch/grammar/antlr_parse.go b/genmonarch/grammar/antlr_parse.go new file mode 100644 index 000000000..acc0ab791 --- /dev/null +++ b/genmonarch/grammar/antlr_parse.go @@ -0,0 +1,272 @@ +// Package grammar extracts lexical vocabulary from the grammars of the parsers +// gomplate actually runs, so the editor tokenizers stay in step with them +// instead of being transcribed by hand. +package grammar + +import ( + "fmt" + "strings" + "unicode/utf8" +) + +// node is a parsed ANTLR rule body. Every node renders to JS regex source. +type node interface{ isNode() } + +type ( + // altNode is `a | b | c`. + altNode struct{ alts []node } + // seqNode is juxtaposition: `a b c`. + seqNode struct{ items []node } + // repeatNode is a suffixed element: `a+`, `a*?`, `a?`. + repeatNode struct { + item node + op string + } + // litNode is a quoted literal: `'0x'`. + litNode struct{ text string } + // rangeNode is `'a'..'z'`. + rangeNode struct{ lo, hi rune } + // notNode is `~(...)`. + notNode struct{ item node } + // refNode is a reference to another lexer rule or fragment. + refNode struct{ name string } + // anyNode is `.`. + anyNode struct{} +) + +func (*altNode) isNode() {} +func (*seqNode) isNode() {} +func (*repeatNode) isNode() {} +func (*litNode) isNode() {} +func (*rangeNode) isNode() {} +func (*notNode) isNode() {} +func (*refNode) isNode() {} +func (*anyNode) isNode() {} + +// TranslateRuleBody converts one ANTLR lexer rule body into JS regex source, +// inlining any referenced rule from rules (transitively). +func TranslateRuleBody(body string, rules map[string]string) (string, error) { + p := &parser{src: []rune(body)} + root, err := p.parseAlt() + if err != nil { + return "", err + } + if p.peek() != 0 { + return "", fmt.Errorf("unexpected %q at offset %d in %q", p.peek(), p.pos, body) + } + return render(root, rules, nil) +} + +type parser struct { + src []rune + pos int +} + +func (p *parser) peek() rune { + p.skipSpace() + if p.pos >= len(p.src) { + return 0 + } + return p.src[p.pos] +} + +func (p *parser) skipSpace() { + for p.pos < len(p.src) && (p.src[p.pos] == ' ' || p.src[p.pos] == '\t' || p.src[p.pos] == '\n' || p.src[p.pos] == '\r') { + p.pos++ + } +} + +func (p *parser) parseAlt() (node, error) { + var alts []node + for { + s, err := p.parseSeq() + if err != nil { + return nil, err + } + alts = append(alts, s) + if p.peek() != '|' { + break + } + p.pos++ + } + if len(alts) == 1 { + return alts[0], nil + } + return &altNode{alts: alts}, nil +} + +func (p *parser) parseSeq() (node, error) { + var items []node + for { + c := p.peek() + if c == 0 || c == '|' || c == ')' { + break + } + e, err := p.parseElem() + if err != nil { + return nil, err + } + items = append(items, e) + } + if len(items) == 0 { + return nil, fmt.Errorf("empty alternative at offset %d", p.pos) + } + if len(items) == 1 { + return items[0], nil + } + return &seqNode{items: items}, nil +} + +func (p *parser) parseElem() (node, error) { + atom, err := p.parseAtom() + if err != nil { + return nil, err + } + switch p.peek() { + case '+', '*', '?': + op := string(p.src[p.pos]) + p.pos++ + if p.pos < len(p.src) && p.src[p.pos] == '?' && op != "?" { + op += "?" + p.pos++ + } + return &repeatNode{item: atom, op: op}, nil + } + return atom, nil +} + +func (p *parser) parseAtom() (node, error) { + switch c := p.peek(); { + case c == '\'': + lit, err := p.parseLiteral() + if err != nil { + return nil, err + } + if !p.hasPrefix("..") { + return &litNode{text: lit}, nil + } + p.pos += 2 + hi, err := p.parseLiteral() + if err != nil { + return nil, err + } + lo, _ := utf8.DecodeRuneInString(lit) + hiR, _ := utf8.DecodeRuneInString(hi) + return &rangeNode{lo: lo, hi: hiR}, nil + case c == '(': + p.pos++ + inner, err := p.parseAlt() + if err != nil { + return nil, err + } + if p.peek() != ')' { + return nil, fmt.Errorf("unclosed group at offset %d", p.pos) + } + p.pos++ + return inner, nil + case c == '~': + p.pos++ + inner, err := p.parseAtom() + if err != nil { + return nil, err + } + return ¬Node{item: inner}, nil + case c == '.': + p.pos++ + return &anyNode{}, nil + case isIdentStart(c): + start := p.pos + for p.pos < len(p.src) && isIdentPart(p.src[p.pos]) { + p.pos++ + } + return &refNode{name: string(p.src[start:p.pos])}, nil + default: + return nil, fmt.Errorf("unexpected %q at offset %d", c, p.pos) + } +} + +func (p *parser) hasPrefix(s string) bool { + p.skipSpace() + return strings.HasPrefix(string(p.src[p.pos:]), s) +} + +// parseLiteral consumes a single-quoted ANTLR literal and unescapes it. +func (p *parser) parseLiteral() (string, error) { + p.skipSpace() + if p.pos >= len(p.src) || p.src[p.pos] != '\'' { + return "", fmt.Errorf("expected a quoted literal at offset %d", p.pos) + } + p.pos++ + var b strings.Builder + for p.pos < len(p.src) { + c := p.src[p.pos] + switch c { + case '\'': + p.pos++ + return b.String(), nil + case '\\': + p.pos++ + if p.pos >= len(p.src) { + return "", fmt.Errorf("dangling escape at offset %d", p.pos) + } + r, err := unescape(p.src, &p.pos) + if err != nil { + return "", err + } + b.WriteRune(r) + default: + b.WriteRune(c) + p.pos++ + } + } + return "", fmt.Errorf("unterminated literal at offset %d", p.pos) +} + +// unescape decodes the escape following a backslash, advancing *pos past it. +func unescape(src []rune, pos *int) (rune, error) { + c := src[*pos] + *pos++ + switch c { + case 'n': + return '\n', nil + case 'r': + return '\r', nil + case 't': + return '\t', nil + case 'f': + return '\f', nil + case 'b': + return '\b', nil + case '\\', '\'', '"': + return c, nil + case 'u': + // ANTLR writes \uXXXX or \u{XXXX}. + digits := "" + if *pos < len(src) && src[*pos] == '{' { + *pos++ + for *pos < len(src) && src[*pos] != '}' { + digits += string(src[*pos]) + *pos++ + } + *pos++ // closing brace + } else { + for i := 0; i < 4 && *pos < len(src); i++ { + digits += string(src[*pos]) + *pos++ + } + } + var r rune + if _, err := fmt.Sscanf(digits, "%x", &r); err != nil { + return 0, fmt.Errorf("bad unicode escape \\u%s: %w", digits, err) + } + return r, nil + default: + return c, nil + } +} + +func isIdentStart(c rune) bool { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + +func isIdentPart(c rune) bool { return isIdentStart(c) || (c >= '0' && c <= '9') } diff --git a/genmonarch/grammar/antlr_regex.go b/genmonarch/grammar/antlr_regex.go new file mode 100644 index 000000000..721607de1 --- /dev/null +++ b/genmonarch/grammar/antlr_regex.go @@ -0,0 +1,295 @@ +package grammar + +import ( + "fmt" + "sort" + "strings" +) + +// render turns a parsed rule body into JS regex source. Referenced rules are +// inlined from rules; stack carries the inlining chain so a cyclic grammar +// fails loudly instead of recursing forever. +func render(n node, rules map[string]string, stack []string) (string, error) { + // An alternation of single characters and ranges is far more useful as one + // character class: it reads better and it is the only form `~` can negate. + // A lone literal stays as-is -- `'='` is `=`, not `[=]`. + switch n.(type) { + case *altNode, *rangeNode: + if set, ok := charSet(n, rules, stack); ok { + return "[" + set + "]", nil + } + } + + switch t := n.(type) { + case *litNode: + return escapeLiteral(t.text), nil + + case *rangeNode: + return "[" + escapeClass(t.lo) + "-" + escapeClass(t.hi) + "]", nil + + case *anyNode: + return `[\s\S]`, nil + + case *altNode: + alts := longestPrefixFirst(t.alts, rules, stack) + parts := make([]string, 0, len(alts)) + for _, a := range alts { + s, err := render(a, rules, stack) + if err != nil { + return "", err + } + parts = append(parts, s) + } + return "(?:" + strings.Join(parts, "|") + ")", nil + + case *seqNode: + var b strings.Builder + for _, it := range t.items { + s, err := render(it, rules, stack) + if err != nil { + return "", err + } + b.WriteString(s) + } + return b.String(), nil + + case *repeatNode: + inner, err := render(t.item, rules, stack) + if err != nil { + return "", err + } + if needsGroupBeforeQuantifier(inner) { + inner = "(?:" + inner + ")" + } + return inner + t.op, nil + + case *notNode: + set, ok := charSet(t.item, rules, stack) + if !ok { + return "", fmt.Errorf("`~` can only negate a set of single characters") + } + return "[^" + set + "]", nil + + case *refNode: + body, ok := rules[t.name] + if !ok { + return "", fmt.Errorf("unknown rule reference %q", t.name) + } + for _, seen := range stack { + if seen == t.name { + return "", fmt.Errorf("cycle in rule references: %s -> %s", strings.Join(stack, " -> "), t.name) + } + } + sub, err := parseBody(body) + if err != nil { + return "", fmt.Errorf("rule %s: %w", t.name, err) + } + return render(sub, rules, append(stack, t.name)) + + default: + return "", fmt.Errorf("unsupported node %T", n) + } +} + +// longestPrefixFirst reorders alternatives so the one with the longest +// fixed-width leading segment is tried first. +// +// ANTLR picks the longest match among ambiguous alternatives; JS regex picks the +// first that matches. Left in declaration order, CEL's STRING rule would tokenize +// `"""x"""` as an empty string followed by junk, because `'"' ... '"'` is +// declared before `'"""' ... '"""'`. +func longestPrefixFirst(alts []node, rules map[string]string, stack []string) []node { + out := make([]node, len(alts)) + copy(out, alts) + sort.SliceStable(out, func(i, j int) bool { + return fixedPrefixLen(out[i], rules, stack) > fixedPrefixLen(out[j], rules, stack) + }) + return out +} + +// fixedPrefixLen counts the characters an alternative is guaranteed to consume +// before its first variable-width element. A character class counts as one. +func fixedPrefixLen(n node, rules map[string]string, stack []string) int { + switch t := n.(type) { + case *litNode: + return len([]rune(t.text)) + case *rangeNode, *anyNode, *notNode: + return 1 + case *altNode: + if _, ok := charSet(t, rules, stack); ok { + return 1 + } + return 0 // a genuine branch contributes no guaranteed prefix + case *seqNode: + total := 0 + for _, it := range t.items { + n := fixedPrefixLen(it, rules, stack) + total += n + if n == 0 { + break // variable width from here on + } + } + return total + case *refNode: + body, ok := rules[t.name] + if !ok { + return 0 + } + for _, seen := range stack { + if seen == t.name { + return 0 + } + } + sub, err := parseBody(body) + if err != nil { + return 0 + } + return fixedPrefixLen(sub, rules, append(stack, t.name)) + default: + return 0 // repeatNode and anything else is variable width + } +} + +func parseBody(body string) (node, error) { + p := &parser{src: []rune(body)} + n, err := p.parseAlt() + if err != nil { + return nil, err + } + if p.peek() != 0 { + return nil, fmt.Errorf("unexpected %q at offset %d", p.peek(), p.pos) + } + return n, nil +} + +// charSet renders n as the body of a character class, reporting false when n is +// anything other than single characters and ranges. References are resolved so +// that `LETTER | DIGIT | '_'` collapses to `A-Za-z0-9_` rather than staying an +// alternation of three separate classes. +func charSet(n node, rules map[string]string, stack []string) (string, bool) { + switch t := n.(type) { + case *litNode: + if len([]rune(t.text)) != 1 { + return "", false + } + return escapeClass([]rune(t.text)[0]), true + case *rangeNode: + return escapeClass(t.lo) + "-" + escapeClass(t.hi), true + case *altNode: + var b strings.Builder + for _, a := range t.alts { + s, ok := charSet(a, rules, stack) + if !ok { + return "", false + } + b.WriteString(s) + } + return b.String(), true + case *refNode: + body, ok := rules[t.name] + if !ok { + return "", false + } + for _, seen := range stack { + if seen == t.name { + return "", false + } + } + sub, err := parseBody(body) + if err != nil { + return "", false + } + return charSet(sub, rules, append(stack, t.name)) + default: + return "", false + } +} + +// needsGroupBeforeQuantifier reports whether src must be wrapped before a +// quantifier binds to it. A single char, an escape, a character class or an +// existing group already binds as a unit. +func needsGroupBeforeQuantifier(src string) bool { + switch { + case len(src) == 1: + return false + case strings.HasPrefix(src, "(?:") && strings.HasSuffix(src, ")") && balanced(src): + return false + case strings.HasPrefix(src, "[") && strings.HasSuffix(src, "]") && classIsWhole(src): + return false + case len(src) == 2 && src[0] == '\\': + return false + default: + return true + } +} + +// balanced reports whether the outermost `(` of src closes at its final `)`. +func balanced(src string) bool { + depth := 0 + for i, r := range src { + switch r { + case '(': + depth++ + case ')': + depth-- + if depth == 0 && i != len(src)-1 { + return false + } + } + } + return depth == 0 +} + +// classIsWhole reports whether src is a single character class, i.e. its +// opening `[` closes only at the final `]`. +func classIsWhole(src string) bool { + for i := 1; i < len(src)-1; i++ { + if src[i] == '\\' { + i++ + continue + } + if src[i] == ']' { + return false + } + } + return true +} + +// regexMeta are the characters that must be escaped outside a character class. +// `/` is deliberately absent: it is only special inside a JS regex *literal*, +// and Monarch patterns are strings handed to `new RegExp`. +const regexMeta = `\.+*?()|[]{}^$` + +func escapeLiteral(s string) string { + var b strings.Builder + for _, r := range s { + b.WriteString(escapeRune(r, regexMeta)) + } + return b.String() +} + +// escapeClass escapes a rune for use inside a character class, where the only +// special characters are `\`, `]`, `^` and `-`. +func escapeClass(r rune) string { return escapeRune(r, `\]^-`) } + +func escapeRune(r rune, meta string) string { + switch r { + case '\n': + return `\n` + case '\r': + return `\r` + case '\t': + return `\t` + case '\f': + return `\f` + case '\b': + return `\b` + } + if r < 0x20 || r == 0x7f { + return fmt.Sprintf(`\u%04X`, r) + } + if strings.ContainsRune(meta, r) { + return `\` + string(r) + } + return string(r) +} diff --git a/genmonarch/grammar/antlr_test.go b/genmonarch/grammar/antlr_test.go new file mode 100644 index 000000000..49a7f43e6 --- /dev/null +++ b/genmonarch/grammar/antlr_test.go @@ -0,0 +1,95 @@ +package grammar + +import ( + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("antlr rule bodies translate to JS regex", func() { + // Each case is a real fragment or rule body from CEL.g4 (or a minimal + // reduction of one), paired with the regex a Monarch tokenizer needs. + // Alternations of single characters and ranges collapse into one character + // class -- both to keep the emitted regex readable and because `~(...)` + // negation is only expressible as a class. + cases := []struct { + name string + body string + want string + }{ + {"literal", `'=='`, `==`}, + {"literal needing escape", `'.'`, `\.`}, + {"single-char alternation collapses", `'e' | 'E'`, `[eE]`}, + {"char range", `'0'..'9'`, `[0-9]`}, + {"range alternation collapses", `'A'..'Z' | 'a'..'z'`, `[A-Za-z]`}, + {"multi-char alternation cannot collapse", `'0x' | 'ab'`, `(?:0x|ab)`}, + {"one or more", `'0'..'9'+`, `[0-9]+`}, + {"optional group", `( '+' | '-' )?`, `[+\-]?`}, + {"negated char set", `~('\\'|'"'|'\n'|'\r')`, `[^\\"\n\r]`}, + {"any char non-greedy", `.*?`, `[\s\S]*?`}, + {"sequence", `'0x' HEXDIGIT+`, `0x[0-9a-fA-F]+`}, + {"redundant group is dropped", `('.' '0'..'9'+)`, `\.[0-9]+`}, + {"group is kept when a quantifier binds to it", `('.' '0'..'9'+)?`, `(?:\.[0-9]+)?`}, + {"quantifier binds directly to a class", `HEXDIGIT+`, `[0-9a-fA-F]+`}, + } + + for _, tc := range cases { + ginkgo.It(tc.name, func() { + got, err := TranslateRuleBody(tc.body, celFragmentsForTest()) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(tc.want)) + }) + } + + ginkgo.It("expands fragment references transitively", func() { + // ESC_BYTE_SEQ : BACKSLASH ( 'x' | 'X' ) HEXDIGIT HEXDIGIT + got, err := TranslateRuleBody(`ESC_BYTE_SEQ`, celFragmentsForTest()) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(`\\[xX][0-9a-fA-F][0-9a-fA-F]`)) + }) + + ginkgo.It("fails loudly on an unknown reference rather than emitting a broken regex", func() { + _, err := TranslateRuleBody(`NOT_A_RULE+`, map[string]string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("NOT_A_RULE")) + }) + + ginkgo.It("fails loudly on a cyclic reference instead of recursing forever", func() { + _, err := TranslateRuleBody(`A`, map[string]string{"A": `'x' B`, "B": `'y' A`}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cycle")) + }) + + // ANTLR resolves an ambiguous alternation by longest match; JS regex takes + // the first alternative that matches. Translating order-for-order silently + // produces a tokenizer that stops early -- and an anchored test would not + // catch it, because backtracking hides the bug when the whole input must + // match. Monarch matches a prefix, so the order has to be corrected. + ginkgo.It("orders alternatives so the longest fixed prefix is tried first", func() { + got, err := TranslateRuleBody(`'"' 'a'* '"' | '"""' 'a'* '"""'`, map[string]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(`(?:"""a*"""|"a*")`)) + }) + + ginkgo.It("prefers the hex branch over the bare-digit branch", func() { + got, err := TranslateRuleBody(`'0'..'9'+ | '0x' '0'..'9'+`, map[string]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(`(?:0x[0-9]+|[0-9]+)`)) + }) + + ginkgo.It("counts a leading character class as one character of prefix", func() { + // `RAW '"""'` must outrank `RAW '"'` just as `'"""'` outranks `'"'`. + got, err := TranslateRuleBody(`RAW '"' 'a'* '"' | RAW '"""' 'a'* '"""'`, + map[string]string{"RAW": `'r' | 'R'`}) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(`(?:[rR]"""a*"""|[rR]"a*")`)) + }) +}) + +// celFragmentsForTest is the subset of CEL.g4 fragments the cases above lean on. +func celFragmentsForTest() map[string]string { + return map[string]string{ + "BACKSLASH": `'\\'`, + "HEXDIGIT": `('0'..'9'|'a'..'f'|'A'..'F')`, + "ESC_BYTE_SEQ": `BACKSLASH ( 'x' | 'X' ) HEXDIGIT HEXDIGIT`, + } +} diff --git a/genmonarch/grammar/cel.go b/genmonarch/grammar/cel.go new file mode 100644 index 000000000..bcf1086be --- /dev/null +++ b/genmonarch/grammar/cel.go @@ -0,0 +1,235 @@ +package grammar + +import ( + _ "embed" + "fmt" + "sort" + "strings" + "unicode" +) + +// celGrammarSource is cel-go's own ANTLR grammar, vendored so the tokenizer is +// generated from the same lexical rules the parser enforces. cel_test.go asserts +// it stays byte-identical to the copy in the resolved cel-go module. +// +//go:embed CEL.g4 +var celGrammarSource string + +// CELGrammarSource returns the vendored grammar text. +func CELGrammarSource() string { return celGrammarSource } + +// CELGrammar is the lexical vocabulary of CEL, extracted from CEL.g4. +type CELGrammar struct { + // Operators are the punctuation tokens, ordered longest-first so a + // tokenizer trying them in sequence never lets `<` shadow `<=`. + Operators []string + // Keywords are the literal word tokens: in, true, false, null. + Keywords []string + // Patterns maps a composite token rule (STRING, NUM_INT, IDENTIFIER, ...) + // to JS regex source. + Patterns map[string]string +} + +// compositeRules are the token rules a tokenizer needs as regexes rather than +// as literal strings. Everything else in the lexer section is a single literal +// and lands in Operators or Keywords. +var compositeRules = []string{ + "WHITESPACE", "COMMENT", + "NUM_FLOAT", "NUM_INT", "NUM_UINT", + "STRING", "BYTES", + "IDENTIFIER", "ESC_IDENTIFIER", +} + +// ParseCEL extracts the lexical vocabulary from the vendored CEL.g4. +func ParseCEL() (*CELGrammar, error) { + rules, order, fragments, err := parseANTLRRules(celGrammarSource) + if err != nil { + return nil, err + } + + g := &CELGrammar{Patterns: map[string]string{}} + for _, name := range order { + // Fragments exist only to be inlined -- BACKSLASH is not an operator. + if fragments[name] { + continue + } + body := rules[name] + if lit, ok := soleLiteral(body); ok { + if isWord(lit) { + g.Keywords = append(g.Keywords, lit) + } else { + g.Operators = append(g.Operators, lit) + } + } + } + + for _, name := range compositeRules { + body, ok := rules[name] + if !ok { + return nil, fmt.Errorf("CEL.g4 no longer defines the %s rule", name) + } + pattern, err := TranslateRuleBody(body, rules) + if err != nil { + return nil, fmt.Errorf("rule %s: %w", name, err) + } + g.Patterns[name] = pattern + } + + // Longest first, then lexicographic so the output is stable across runs. + sort.SliceStable(g.Operators, func(i, j int) bool { + if len(g.Operators[i]) != len(g.Operators[j]) { + return len(g.Operators[i]) > len(g.Operators[j]) + } + return g.Operators[i] < g.Operators[j] + }) + sort.Strings(g.Keywords) + return g, nil +} + +// parseANTLRRules splits an ANTLR grammar into `NAME -> body`, keeping only the +// lexer rules (an initial capital) and fragments. order preserves the order of +// declaration, which is the lexer's own precedence; fragments records which of +// them are inline-only. +func parseANTLRRules(src string) (rules map[string]string, order []string, fragments map[string]bool, err error) { + rules = map[string]string{} + fragments = map[string]bool{} + + for _, chunk := range splitTopLevel(stripComments(src), ';') { + chunk = strings.TrimSpace(chunk) + if chunk == "" { + continue + } + colon := indexTopLevel(chunk, ':') + if colon < 0 { + continue // options {...}, grammar header, etc. + } + name := strings.TrimSpace(chunk[:colon]) + isFragment := strings.HasPrefix(name, "fragment") + if isFragment { + name = strings.TrimSpace(strings.TrimPrefix(name, "fragment")) + } + if name == "" || !unicode.IsUpper(rune(name[0])) || strings.ContainsAny(name, " \t\n") { + continue // parser rule, or not a rule at all + } + body := stripAction(strings.TrimSpace(chunk[colon+1:])) + if body == "" { + continue + } + rules[name] = body + order = append(order, name) + fragments[name] = isFragment + } + + if len(rules) == 0 { + return nil, nil, nil, fmt.Errorf("no lexer rules found; is CEL.g4 intact?") + } + return rules, order, fragments, nil +} + +// stripAction removes a trailing ANTLR action such as `-> channel(HIDDEN)`. +func stripAction(body string) string { + if i := indexTopLevel(body, '-'); i >= 0 && strings.HasPrefix(body[i:], "->") { + return strings.TrimSpace(body[:i]) + } + return body +} + +// soleLiteral reports whether body is exactly one quoted literal, returning it. +func soleLiteral(body string) (string, bool) { + p := &parser{src: []rune(body)} + if p.peek() != '\'' { + return "", false + } + lit, err := p.parseLiteral() + if err != nil || p.peek() != 0 { + return "", false + } + return lit, true +} + +func isWord(s string) bool { + for _, r := range s { + if !unicode.IsLetter(r) && r != '_' { + return false + } + } + return s != "" +} + +// stripComments removes `//` and `/* */` comments that are not inside a quoted +// literal -- the COMMENT rule itself contains a literal `'//'`. +func stripComments(src string) string { + var b strings.Builder + runes := []rune(src) + for i := 0; i < len(runes); i++ { + switch { + case runes[i] == '\'': + j := scanLiteral(runes, i) + b.WriteString(string(runes[i:j])) + i = j - 1 + case runes[i] == '/' && i+1 < len(runes) && runes[i+1] == '/': + for i < len(runes) && runes[i] != '\n' { + i++ + } + b.WriteRune('\n') + case runes[i] == '/' && i+1 < len(runes) && runes[i+1] == '*': + i += 2 + for i+1 < len(runes) && (runes[i] != '*' || runes[i+1] != '/') { + i++ + } + i++ + default: + b.WriteRune(runes[i]) + } + } + return b.String() +} + +// scanLiteral returns the index just past the quoted literal starting at i. +func scanLiteral(runes []rune, i int) int { + j := i + 1 + for j < len(runes) { + if runes[j] == '\\' { + j += 2 + continue + } + if runes[j] == '\'' { + return j + 1 + } + j++ + } + return len(runes) +} + +// splitTopLevel splits on sep, ignoring separators inside quoted literals. +func splitTopLevel(src string, sep rune) []string { + var out []string + runes := []rune(src) + start := 0 + for i := 0; i < len(runes); i++ { + if runes[i] == '\'' { + i = scanLiteral(runes, i) - 1 + continue + } + if runes[i] == sep { + out = append(out, string(runes[start:i])) + start = i + 1 + } + } + return append(out, string(runes[start:])) +} + +// indexTopLevel finds target outside any quoted literal, or -1. +func indexTopLevel(src string, target rune) int { + runes := []rune(src) + for i := 0; i < len(runes); i++ { + if runes[i] == '\'' { + i = scanLiteral(runes, i) - 1 + continue + } + if runes[i] == target { + return i + } + } + return -1 +} diff --git a/genmonarch/grammar/cel_test.go b/genmonarch/grammar/cel_test.go new file mode 100644 index 000000000..9d7417727 --- /dev/null +++ b/genmonarch/grammar/cel_test.go @@ -0,0 +1,168 @@ +package grammar + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("CEL.g4", func() { + ginkgo.It("stays byte-identical to the copy cel-go ships", func() { + // The whole point of generating from the grammar is that a cel-go bump + // which changes the lexer fails here instead of silently desyncing the + // editor from the parser. + upstream, err := upstreamCELGrammarPath() + if err != nil { + ginkgo.Skip("cel-go module source not available: " + err.Error()) + } + want, err := os.ReadFile(upstream) + Expect(err).ToNot(HaveOccurred()) + Expect(CELGrammarSource()).To(Equal(string(want)), + "vendored CEL.g4 is stale -- copy %s over genmonarch/grammar/CEL.g4 and re-run `make monarch`", upstream) + }) + + ginkgo.Context("parsed into a lexical vocabulary", func() { + var g *CELGrammar + + ginkgo.BeforeEach(func() { + var err error + g, err = ParseCEL() + Expect(err).ToNot(HaveOccurred()) + }) + + ginkgo.It("extracts word keywords separately from operators", func() { + Expect(g.Keywords).To(ConsistOf("in", "true", "false", "null")) + }) + + ginkgo.It("orders operators longest-first so `<=` is never shadowed by `<`", func() { + Expect(g.Operators).To(ContainElements("==", "!=", "<=", ">=", "&&", "||", "?", ":", "%")) + Expect(indexOf(g.Operators, "<=")).To(BeNumerically("<", indexOf(g.Operators, "<"))) + Expect(indexOf(g.Operators, ">=")).To(BeNumerically("<", indexOf(g.Operators, ">"))) + }) + + ginkgo.It("excludes inline-only fragments from the operator set", func() { + // `fragment BACKSLASH : '\\'` is a building block of the escape + // sequences, not a CEL operator. + Expect(g.Operators).ToNot(ContainElement(`\`)) + }) + + ginkgo.It("matches the longest string form rather than stopping at the shortest", func() { + // Unanchored, as a Monarch tokenizer consumes it. + for _, tc := range []struct{ input, want string }{ + {`"""hello"""`, `"""hello"""`}, + {`'''hello'''`, `'''hello'''`}, + {`r"""a\db"""`, `r"""a\db"""`}, + {`"plain"`, `"plain"`}, + } { + Expect(firstMatch(g.Patterns["STRING"], tc.input)).To(Equal(tc.want)) + } + }) + + ginkgo.It("matches a hex integer whole rather than stopping after the leading 0", func() { + Expect(firstMatch(g.Patterns["NUM_INT"], "0x1f")).To(Equal("0x1f")) + Expect(firstMatch(g.Patterns["NUM_UINT"], "0x1fu")).To(Equal("0x1fu")) + }) + + ginkgo.It("translates the simple token rules", func() { + Expect(g.Patterns["COMMENT"]).To(Equal(`//[^\n]*`)) + Expect(g.Patterns["IDENTIFIER"]).To(Equal(`[A-Za-z_][A-Za-z0-9_]*`)) + Expect(g.Patterns["WHITESPACE"]).To(Equal(`[\t \r\n\f]+`)) + }) + + ginkgo.It("recovers back-tick escaped identifiers", func() { + // Easy to miss by hand -- `k8s.labels` may be written `` `k8s.labels` ``. + Expect(g.Patterns).To(HaveKey("ESC_IDENTIFIER")) + Expect(matches(g.Patterns["ESC_IDENTIFIER"], "`some.escaped-id/v1`")).To(BeTrue()) + }) + + ginkgo.It("emits patterns that are all valid regular expressions", func() { + for name, pattern := range g.Patterns { + _, err := regexp.Compile(pattern) + Expect(err).ToNot(HaveOccurred(), "rule %s produced invalid regex %q", name, pattern) + } + }) + + ginkgo.DescribeTable("the STRING rule covers every CEL literal form", + func(literal string) { + Expect(matches(g.Patterns["STRING"], literal)).To(BeTrue()) + }, + ginkgo.Entry("double quoted", `"hello"`), + ginkgo.Entry("single quoted", `'hello'`), + ginkgo.Entry("triple double quoted", `"""hello"""`), + ginkgo.Entry("triple single quoted", `'''hello'''`), + ginkgo.Entry("raw", `r"a\db"`), + ginkgo.Entry("raw upper", `R'a\db'`), + ginkgo.Entry("raw triple", `r"""a\db"""`), + ginkgo.Entry("hex escape", `"\x41"`), + ginkgo.Entry("unicode escape", `"A"`), + ginkgo.Entry("long unicode escape", `"\U0001F600"`), + ginkgo.Entry("octal escape", `"\101"`), + ) + + ginkgo.DescribeTable("the numeric rules separate int, uint and float", + func(rule, literal string) { + Expect(matches(g.Patterns[rule], literal)).To(BeTrue()) + }, + ginkgo.Entry("decimal int", "NUM_INT", "123"), + ginkgo.Entry("hex int", "NUM_INT", "0x1f"), + ginkgo.Entry("uint suffix", "NUM_UINT", "123u"), + ginkgo.Entry("hex uint", "NUM_UINT", "0x1fU"), + ginkgo.Entry("float", "NUM_FLOAT", "1.5"), + ginkgo.Entry("float exponent", "NUM_FLOAT", "1e-3"), + ginkgo.Entry("leading dot float", "NUM_FLOAT", ".5e3"), + ) + + ginkgo.It("marks bytes literals distinctly from strings", func() { + Expect(matches(g.Patterns["BYTES"], `b"abc"`)).To(BeTrue()) + Expect(matches(g.Patterns["BYTES"], `B'abc'`)).To(BeTrue()) + }) + }) +}) + +// firstMatch returns what pattern consumes from the start of s, the way a +// Monarch tokenizer applies it -- anchored at the start only, so an alternation +// ordered wrongly shows up as a short match instead of being hidden by +// backtracking. +func firstMatch(pattern, s string) string { + re, err := regexp.Compile("^(?:" + pattern + ")") + if err != nil { + return "" + } + return re.FindString(s) +} + +// matches reports whether pattern matches the whole of s. +func matches(pattern, s string) bool { + re, err := regexp.Compile("^(?:" + pattern + ")$") + if err != nil { + return false + } + return re.MatchString(s) +} + +func indexOf(haystack []string, needle string) int { + for i, s := range haystack { + if s == needle { + return i + } + } + return -1 +} + +// upstreamCELGrammarPath locates CEL.g4 inside the resolved cel-go module. +func upstreamCELGrammarPath() (string, error) { + out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/google/cel-go").Output() + if err != nil { + return "", err + } + dir := strings.TrimSpace(string(out)) + if dir == "" { + return "", os.ErrNotExist + } + return filepath.Join(dir, "parser", "gen", "CEL.g4"), nil +} diff --git a/genmonarch/grammar/gotemplate.go b/genmonarch/grammar/gotemplate.go new file mode 100644 index 000000000..9e2490e26 --- /dev/null +++ b/genmonarch/grammar/gotemplate.go @@ -0,0 +1,231 @@ +package grammar + +import ( + "fmt" + "go/ast" + "go/constant" + "go/token" + "sort" + "strconv" + + "golang.org/x/tools/go/packages" +) + +// GoTemplateGrammar is the lexical vocabulary of Go's text/template, read out +// of the standard library's own lexer and function table. All three are +// unexported there, so they are recovered from the AST -- the same approach +// gencel already uses to read gomplate's own sources. +type GoTemplateGrammar struct { + // Keywords are the action keywords: if, range, end, ... + Keywords []string + // Builtins are the functions text/template supplies itself. + Builtins []string + // LeftDelim and RightDelim open and close an action. + LeftDelim, RightDelim string + // LeftComment and RightComment open and close a comment inside an action. + LeftComment, RightComment string + // TrimMarker abuts a delimiter to trim adjacent whitespace. + TrimMarker string +} + +// ParseGoTemplate recovers the text/template vocabulary from the standard +// library sources of the toolchain building this package. +func ParseGoTemplate() (*GoTemplateGrammar, error) { + pkgs, err := packages.Load( + &packages.Config{Mode: packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedName}, + "text/template", "text/template/parse", + ) + if err != nil { + return nil, fmt.Errorf("loading text/template sources: %w", err) + } + files := map[string][]*ast.File{} + for _, p := range pkgs { + if len(p.Errors) > 0 { + return nil, fmt.Errorf("loading %s: %v", p.PkgPath, p.Errors[0]) + } + files[p.PkgPath] = p.Syntax + } + + g := &GoTemplateGrammar{} + + // parse.key maps every action keyword to its item type. + keywords, err := mapLiteralKeys(files["text/template/parse"], "key") + if err != nil { + return nil, fmt.Errorf("text/template/parse: %w", err) + } + // `.` is in the table as itemDot; it is a token, not a keyword. + for _, k := range keywords { + if k != "." { + g.Keywords = append(g.Keywords, k) + } + } + + // builtins() returns the FuncMap literal of everything text/template + // provides without any user Funcs call. + builtins, err := funcReturnedMapKeys(files["text/template"], "builtins") + if err != nil { + return nil, fmt.Errorf("text/template: %w", err) + } + g.Builtins = builtins + + consts, err := stringConsts(files["text/template/parse"], + "leftDelim", "rightDelim", "leftComment", "rightComment") + if err != nil { + return nil, fmt.Errorf("text/template/parse: %w", err) + } + g.LeftDelim, g.RightDelim = consts["leftDelim"], consts["rightDelim"] + g.LeftComment, g.RightComment = consts["leftComment"], consts["rightComment"] + + marker, err := runeConst(files["text/template/parse"], "trimMarker") + if err != nil { + return nil, fmt.Errorf("text/template/parse: %w", err) + } + g.TrimMarker = string(marker) + + sort.Strings(g.Keywords) + sort.Strings(g.Builtins) + return g, g.validate() +} + +// validate fails loudly if the standard library moved something, rather than +// letting an empty vocabulary silently produce a tokenizer that highlights +// nothing. +func (g *GoTemplateGrammar) validate() error { + switch { + case len(g.Keywords) == 0: + return fmt.Errorf("no action keywords found in text/template/parse") + case len(g.Builtins) == 0: + return fmt.Errorf("no builtin functions found in text/template") + case g.LeftDelim == "" || g.RightDelim == "": + return fmt.Errorf("action delimiters not found in text/template/parse") + case g.LeftComment == "" || g.RightComment == "": + return fmt.Errorf("comment delimiters not found in text/template/parse") + case g.TrimMarker == "": + return fmt.Errorf("trim marker not found in text/template/parse") + } + return nil +} + +// mapLiteralKeys returns the string keys of a package-level map literal. +func mapLiteralKeys(files []*ast.File, name string) ([]string, error) { + lit, err := findValueSpec(files, name) + if err != nil { + return nil, err + } + composite, ok := lit.(*ast.CompositeLit) + if !ok { + return nil, fmt.Errorf("%s is not a composite literal", name) + } + return compositeKeys(composite, name) +} + +// funcReturnedMapKeys returns the string keys of the map literal a +// zero-argument function returns directly. +func funcReturnedMapKeys(files []*ast.File, funcName string) ([]string, error) { + for _, f := range files { + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != funcName || fn.Body == nil { + continue + } + for _, stmt := range fn.Body.List { + ret, ok := stmt.(*ast.ReturnStmt) + if !ok || len(ret.Results) != 1 { + continue + } + composite, ok := ret.Results[0].(*ast.CompositeLit) + if !ok { + continue + } + return compositeKeys(composite, funcName) + } + } + } + return nil, fmt.Errorf("no function %s returning a map literal", funcName) +} + +func compositeKeys(composite *ast.CompositeLit, context string) ([]string, error) { + var keys []string + for _, elt := range composite.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + lit, ok := kv.Key.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + s, err := strconv.Unquote(lit.Value) + if err != nil { + return nil, fmt.Errorf("%s: unquoting key %s: %w", context, lit.Value, err) + } + keys = append(keys, s) + } + if len(keys) == 0 { + return nil, fmt.Errorf("%s has no string keys", context) + } + return keys, nil +} + +// findValueSpec locates the value assigned to a package-level identifier. +func findValueSpec(files []*ast.File, name string) (ast.Expr, error) { + for _, f := range files { + for _, decl := range f.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + for _, spec := range gen.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, ident := range vs.Names { + if ident.Name == name && i < len(vs.Values) { + return vs.Values[i], nil + } + } + } + } + } + return nil, fmt.Errorf("no declaration of %s", name) +} + +// stringConsts reads the values of untyped string constants. +func stringConsts(files []*ast.File, names ...string) (map[string]string, error) { + out := map[string]string{} + for _, name := range names { + expr, err := findValueSpec(files, name) + if err != nil { + return nil, err + } + lit, ok := expr.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return nil, fmt.Errorf("%s is not a string literal", name) + } + s, err := strconv.Unquote(lit.Value) + if err != nil { + return nil, fmt.Errorf("unquoting %s: %w", name, err) + } + out[name] = s + } + return out, nil +} + +// runeConst reads the value of an untyped rune constant. +func runeConst(files []*ast.File, name string) (rune, error) { + expr, err := findValueSpec(files, name) + if err != nil { + return 0, err + } + lit, ok := expr.(*ast.BasicLit) + if !ok || lit.Kind != token.CHAR { + return 0, fmt.Errorf("%s is not a rune literal", name) + } + v := constant.MakeFromLiteral(lit.Value, token.CHAR, 0) + r, ok := constant.Int64Val(constant.ToInt(v)) + if !ok { + return 0, fmt.Errorf("%s is not a constant rune", name) + } + return rune(r), nil +} diff --git a/genmonarch/grammar/gotemplate_test.go b/genmonarch/grammar/gotemplate_test.go new file mode 100644 index 000000000..3bf4a2619 --- /dev/null +++ b/genmonarch/grammar/gotemplate_test.go @@ -0,0 +1,45 @@ +package grammar + +import ( + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("text/template vocabulary read from the standard library", func() { + var g *GoTemplateGrammar + + ginkgo.BeforeEach(func() { + var err error + g, err = ParseGoTemplate() + Expect(err).ToNot(HaveOccurred()) + }) + + ginkgo.It("recovers every action keyword from parse.key", func() { + Expect(g.Keywords).To(ConsistOf( + "block", "break", "continue", "define", "else", "end", + "if", "range", "nil", "template", "with", + )) + }) + + ginkgo.It("treats `.` as a token rather than a keyword", func() { + // parse.key carries "." as itemDot, but highlighting it as a keyword + // would colour every field access. + Expect(g.Keywords).ToNot(ContainElement(".")) + }) + + ginkgo.It("recovers the builtin functions text/template supplies", func() { + Expect(g.Builtins).To(ConsistOf( + "and", "call", "html", "index", "slice", "js", "len", "not", "or", + "print", "printf", "println", "urlquery", + "eq", "ge", "gt", "le", "lt", "ne", + )) + }) + + ginkgo.It("recovers the delimiters and the trim marker", func() { + Expect(g.LeftDelim).To(Equal("{{")) + Expect(g.RightDelim).To(Equal("}}")) + Expect(g.LeftComment).To(Equal("/*")) + Expect(g.RightComment).To(Equal("*/")) + Expect(g.TrimMarker).To(Equal("-")) + }) +}) diff --git a/genmonarch/grammar/suite_test.go b/genmonarch/grammar/suite_test.go new file mode 100644 index 000000000..1c88088f0 --- /dev/null +++ b/genmonarch/grammar/suite_test.go @@ -0,0 +1,13 @@ +package grammar + +import ( + "testing" + + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGrammar(t *testing.T) { + RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "genmonarch/grammar") +} diff --git a/genmonarch/lang_cel.go b/genmonarch/lang_cel.go new file mode 100644 index 000000000..80d5d6c9b --- /dev/null +++ b/genmonarch/lang_cel.go @@ -0,0 +1,216 @@ +package genmonarch + +import ( + "sort" + "strings" + + "github.com/flanksource/gomplate/v3/genmonarch/grammar" +) + +// CELLanguageID is the Monaco language id for CEL expressions. +const CELLanguageID = "cel" + +// identifier is CEL's IDENTIFIER rule. It is read from the grammar rather than +// written here so it cannot disagree with the parser. +const identifierRule = "IDENTIFIER" + +// BuildCEL assembles the CEL tokenizer from the lexical grammar and the live +// function catalogue. +func BuildCEL(g *grammar.CELGrammar, spec CELSpec) (Language, Configuration) { + ident := g.Patterns[identifierRule] + + // Macros and functions are highlighted only in call position. A bare + // `map` or `filter` is a perfectly ordinary variable name in CEL, and + // colouring it as a keyword would be wrong more often than right. + callAhead := `(?=\s*\()` + + lang := Language{ + ID: CELLanguageID, + DefaultToken: "", + TokenPostfix: ".cel", + Brackets: []Bracket{ + {Open: "{", Close: "}", Token: "delimiter.curly"}, + {Open: "[", Close: "]", Token: "delimiter.square"}, + {Open: "(", Close: ")", Token: "delimiter.parenthesis"}, + }, + Attributes: map[string][]string{ + "keywords": reservedWords(spec.Keywords), + "constants": {"true", "false", "null"}, + "typeKeywords": spec.Types, + "macros": macroNames(spec.Macros), + "namespaces": spec.Namespaces, + "globalFunctions": leafNames(spec.GlobalNames()), + "memberFunctions": spec.MemberNames(), + "operators": g.Operators, + }, + } + + states := NewStates() + + states.Add("root", + Include("@whitespace"), + + // Bytes before strings, and both before identifiers: `b"x"` starts with + // a letter, so IDENTIFIER would otherwise claim the prefix. + Match(g.Patterns["BYTES"], "string.bytes"), + Match(g.Patterns["STRING"], "string"), + Match(g.Patterns["ESC_IDENTIFIER"], "identifier.escaped"), + + // Float before uint before int: `1.5` must not tokenize as `1`, and + // `123u` must not tokenize as `123` followed by an identifier. + Match(g.Patterns["NUM_FLOAT"], "number.float"), + Match(g.Patterns["NUM_UINT"], "number.uint"), + Match(g.Patterns["NUM_INT"], "number"), + + // Optional-typed access, before the plain `.` and `[` operators. The + // field after `.?` is still a field, so it is matched here rather than + // being left to fall through to the bare-identifier rule. + Rule{ + Regex: `(\.\?)(` + ident + `)` + callAhead, + Action: Action{Cases: NewCases(). + Groups("$2@macros", "operator.optional", "keyword.macro"). + Groups("$2@memberFunctions", "operator.optional", "function.member"). + Groups("$2@globalFunctions", "operator.optional", "function"). + Default(Action{Tokens: []string{"operator.optional", "variable.field"}}), + }, + }, + MatchGroups(`(\.\?)(`+ident+`)`, "operator.optional", "variable.field"), + Match(`\.\?`, "operator.optional"), + Match(`\[\?`, "operator.optional"), + Match(`\?\.`, "operator.optional"), + + // `ns.fn(` / `x.member(` / `x.field` + Rule{ + Regex: `(` + ident + `)(\.)(` + ident + `)` + callAhead, + Action: Action{Cases: NewCases(). + Groups("$1@namespaces", "namespace", "delimiter", "function"). + Groups("$3@macros", "identifier", "delimiter", "keyword.macro"). + Groups("$3@memberFunctions", "identifier", "delimiter", "function.member"). + Groups("$3@globalFunctions", "identifier", "delimiter", "function"). + Default(Action{Tokens: []string{"identifier", "delimiter", "identifier"}}), + }, + }, + // A member call. Receiver-style macros (`x.fold(...)`, `x.all(...)`) + // are macros, not functions, and are checked first. + Rule{ + Regex: `(\.)(` + ident + `)` + callAhead, + Action: Action{Cases: NewCases(). + Groups("$2@macros", "delimiter", "keyword.macro"). + Groups("$2@memberFunctions", "delimiter", "function.member"). + Groups("$2@globalFunctions", "delimiter", "function"). + Default(Action{Tokens: []string{"delimiter", "variable.field"}}), + }, + }, + MatchGroups(`(\.)(`+ident+`)`, "delimiter", "variable.field"), + + // A bare name in call position. + Rule{ + Regex: `(` + ident + `)` + callAhead, + Action: Action{Cases: NewCases(). + Token("$1@macros", "keyword.macro"). + Token("$1@globalFunctions", "function"). + Token("$1@keywords", "keyword"). + Default(Action{Token: "identifier"}), + }, + }, + + // A bare name anywhere else. + Rule{ + Regex: ident, + Action: Action{Cases: NewCases(). + Token("@constants", "keyword.constant"). + Token("@keywords", "keyword"). + Token("@typeKeywords", "type"). + Default(Action{Token: "identifier"}), + }, + }, + + Match(`[{}()\[\]]`, "@brackets"), + Match(operatorPattern(g.Operators), "operator"), + Match(`[,;]`, "delimiter"), + ) + + states.Add("whitespace", + Match(g.Patterns["WHITESPACE"], "white"), + Match(g.Patterns["COMMENT"], "comment"), + ) + + lang.Tokenizer = states + + config := Configuration{ + Comments: &Comments{LineComment: "//"}, + Brackets: [][2]string{{"{", "}"}, {"[", "]"}, {"(", ")"}}, + AutoClosingPairs: []Pair{ + {Open: "{", Close: "}"}, {Open: "[", Close: "]"}, {Open: "(", Close: ")"}, + {Open: `"`, Close: `"`}, {Open: "'", Close: "'"}, + }, + SurroundingPairs: []Pair{ + {Open: "{", Close: "}"}, {Open: "[", Close: "]"}, {Open: "(", Close: ")"}, + {Open: `"`, Close: `"`}, {Open: "'", Close: "'"}, + }, + // A dotted name is one word, so completing `k8s.isHealthy` replaces the + // whole thing instead of appending to the namespace. + WordPattern: `[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*`, + } + return lang, config +} + +// operatorPattern renders the operator literals as one alternation, longest +// first so `<` never shadows `<=`. Brackets are excluded: they are matched +// separately so Monaco can pair and colour them. +func operatorPattern(operators []string) string { + var parts []string + for _, op := range operators { + if strings.ContainsAny(op, "{}()[]") { + continue + } + parts = append(parts, escapeRegexLiteral(op)) + } + return "(?:" + strings.Join(parts, "|") + ")" +} + +// reservedWords drops the literals that are better highlighted as constants. +func reservedWords(keywords []string) []string { + var out []string + for _, k := range keywords { + switch k { + case "true", "false", "null": + default: + out = append(out, k) + } + } + return out +} + +func macroNames(macros []Macro) []string { + seen := map[string]bool{} + var out []string + for _, m := range macros { + if !seen[m.Name] { + seen[m.Name] = true + out = append(out, m.Name) + } + } + sort.Strings(out) + return out +} + +// leafNames reduces `k8s.isHealthy` to `isHealthy` and keeps un-namespaced +// names as they are. A tokenizer matches the leaf after the namespace has +// already been consumed as its own capture group. +func leafNames(names []string) []string { + seen := map[string]bool{} + var out []string + for _, name := range names { + leaf := name + if i := strings.LastIndex(name, "."); i >= 0 { + leaf = name[i+1:] + } + if !seen[leaf] { + seen[leaf] = true + out = append(out, leaf) + } + } + sort.Strings(out) + return out +} diff --git a/genmonarch/lang_embedded.go b/genmonarch/lang_embedded.go new file mode 100644 index 000000000..00317b7d5 --- /dev/null +++ b/genmonarch/lang_embedded.go @@ -0,0 +1,161 @@ +package genmonarch + +import "fmt" + +// Host is a language that gomplate templates are embedded in. Real Mission +// Control configuration is YAML with `{{ }}` actions inside it, and neither +// half is readable when the editor only understands the other. +type Host string + +const ( + HostYAML Host = "yaml" + HostJSON Host = "json" + HostText Host = "text" +) + +// EmbeddedLanguageID is the Monaco language id for a host with templates in it. +func EmbeddedLanguageID(h Host) string { return string(h) + "-gomplate" } + +// BuildEmbedded assembles a host language whose every state can open a template +// action. +// +// Monarch cannot delegate to another *registered* language mid-state, so the +// host tokenizer is inlined here rather than composed at runtime. It is +// deliberately light: enough structure to read a config file, with the template +// actions -- the part gomplate owns -- fully tokenized by the shared states. +func BuildEmbedded(h Host, spec GoTemplateSpec, d Delimiters) (Language, Configuration, error) { + states, entry, err := actionStates(spec, d) + if err != nil { + return Language{}, Configuration{}, err + } + + hostRules, hostStates, err := hostTokenizer(h) + if err != nil { + return Language{}, Configuration{}, err + } + + // The action entry rules come first so `{{` wins over any host rule that + // would otherwise swallow it as ordinary text. + root := append([]Rule{directiveRule()}, entry...) + root = append(root, hostRules...) + + lang := Language{ + ID: EmbeddedLanguageID(h), + DefaultToken: "", + TokenPostfix: "." + EmbeddedLanguageID(h), + Brackets: []Bracket{ + {Open: "{", Close: "}", Token: "delimiter.curly"}, + {Open: "[", Close: "]", Token: "delimiter.square"}, + {Open: "(", Close: ")", Token: "delimiter.parenthesis"}, + }, + Attributes: goTemplateAttributes(spec), + Tokenizer: NewStates().Add("root", root...), + } + for _, name := range hostStates.Names() { + // A host state must also be able to open an action: a template can + // appear inside a quoted YAML scalar just as easily as at top level. + lang.Tokenizer.Add(name, append(append([]Rule{}, entry...), hostStates.Rules(name)...)...) + } + for _, name := range states.Names() { + lang.Tokenizer.Add(name, states.Rules(name)...) + } + + config := Configuration{ + Comments: hostComments(h), + Brackets: [][2]string{{"{", "}"}, {"[", "]"}}, + AutoClosingPairs: []Pair{ + {Open: "{", Close: "}"}, {Open: "[", Close: "]"}, + {Open: `"`, Close: `"`}, {Open: "'", Close: "'"}, + }, + WordPattern: `[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*`, + } + return lang, config, nil +} + +func hostComments(h Host) *Comments { + switch h { + case HostYAML: + return &Comments{LineComment: "#"} + default: + return nil + } +} + +// hostTokenizer returns the host's root rules and any extra states it needs. +// +// Two Monarch constraints shape these rules: +// +// - Every capture group in a rule must participate in the match. An optional +// group is `undefined` when it does not, and Monarch throws while summing +// the group lengths. So alternatives get their own rules instead. +// - A quoted scalar has to be a state, not a single pattern, or a template +// inside it (`name: "{{ .app }}-web"`, the common case in real configs) is +// swallowed whole as a string. +func hostTokenizer(h Host) ([]Rule, *States, error) { + states := NewStates() + + switch h { + case HostYAML: + states.Add("yamlDouble", quotedScalar(`"`)...) + states.Add("yamlSingle", quotedScalar(`'`)...) + return []Rule{ + Match(`#.*$`, "comment"), + Match(`^---\s*$`, "keyword.directive"), + Match(`^\.\.\.\s*$`, "keyword.directive"), + // A mapping key, with and without a leading list marker. + MatchGroups(`^(\s*)(-\s+)([^-\s#"'][^:#]*?)(\s*)(:)(?=\s|$)`, + "white", "delimiter.list", "type.yaml", "white", "delimiter"), + MatchGroups(`^(\s*)([^-\s#"'][^:#]*?)(\s*)(:)(?=\s|$)`, + "white", "type.yaml", "white", "delimiter"), + Match(`^\s*-\s`, "delimiter.list"), + Match(`[&*][A-Za-z0-9_-]+`, "variable.anchor"), + Match(`!!?[A-Za-z0-9_/-]*`, "type"), + Match(`[|>][-+]?`, "keyword.scalar"), + Push(`"`, "string", "@yamlDouble"), + Push(`'`, "string", "@yamlSingle"), + Match(`\b(?:true|false|null|~|yes|no|on|off)\b`, "keyword.constant"), + Match(`[+-]?(?:0[xX][0-9a-fA-F]+|(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?)\b`, "number"), + Match(`[{}\[\]]`, "@brackets"), + Match(`,`, "delimiter"), + Match(`[\s\S]`, ""), + }, states, nil + + case HostJSON: + states.Add("jsonString", quotedScalar(`"`)...) + return []Rule{ + // A key is a string immediately followed by a colon. Templates can + // appear in values, so only the key form is matched whole here. + MatchGroups(`("(?:[^"\\{]|\\.)*")(\s*)(:)`, "type.json", "white", "delimiter"), + Push(`"`, "string", "@jsonString"), + Match(`\b(?:true|false|null)\b`, "keyword.constant"), + Match(`-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?`, "number"), + Match(`[{}\[\]]`, "@brackets"), + Match(`[,:]`, "delimiter"), + Match(`[\s\S]`, ""), + }, states, nil + + case HostText: + return []Rule{Match(`[\s\S]`, "source")}, states, nil + + default: + return nil, nil, fmt.Errorf("unknown host language %q", h) + } +} + +// quotedScalar is the body of a quoted string state. The caller prepends the +// template entry rules, so `{{` breaks out of the string and back in again. +// +// The run rule deliberately stops at `{` so the entry rules get a chance at +// `{{`; a lone brace then falls through to the catch-all. +func quotedScalar(quote string) []Rule { + inClass := quote + if quote == `\` || quote == `]` || quote == `^` || quote == `-` { + inClass = `\` + quote + } + return []Rule{ + Pop(escapeRegexLiteral(quote), "string"), + Match(`\\.`, "string.escape"), + Match(`[^`+inClass+`\\{]+`, "string"), + Match(`[\s\S]`, "string"), + } +} diff --git a/genmonarch/lang_gotemplate.go b/genmonarch/lang_gotemplate.go new file mode 100644 index 000000000..741a04828 --- /dev/null +++ b/genmonarch/lang_gotemplate.go @@ -0,0 +1,167 @@ +package genmonarch + +import ( + "fmt" + "sort" + "strings" +) + +// GoTemplateLanguageID is the Monaco language id for a bare gomplate template. +const GoTemplateLanguageID = "gomplate" + +// goTemplateIdent is text/template's identifier shape. Unlike CEL, this is not +// in a published grammar -- the lexer accepts any alphanumeric run -- so it is +// stated once here. +const goTemplateIdent = `[A-Za-z_][A-Za-z0-9_]*` + +// actionStates are the tokenizer states that apply *inside* `{{ ... }}`. They +// are built once and shared by the bare gomplate language and every embedded +// host variant, so a fix to template highlighting lands in all of them at once. +// +// The state names are prefixed so they cannot collide with a host language's +// own states when the two are spliced into one tokenizer. +func actionStates(spec GoTemplateSpec, d Delimiters) (states *States, entry []Rule, err error) { + if d.Left == "" || d.Right == "" { + return nil, nil, fmt.Errorf("template delimiters must not be empty") + } + left, right := escapeRegexLiteral(d.Left), escapeRegexLiteral(d.Right) + trim := escapeRegexLiteral(d.TrimMarker) + leftComment, rightComment := escapeRegexLiteral(d.LeftComment), escapeRegexLiteral(d.RightComment) + + // A comment opens with the delimiter immediately followed by `/*`, so it + // has to be tried before a plain action. + entry = []Rule{ + Push(left+trim+"?"+leftComment, "comment", "@tmplComment"), + Push(left+trim+"?", "delimiter.template", "@tmplAction"), + } + + states = NewStates() + + states.Add("tmplComment", + Pop(rightComment+trim+"?"+right, "comment"), + Match(`[\s\S]`, "comment"), + ) + + states.Add("tmplAction", + Pop(trim+"?"+right, "delimiter.template"), + Match(`[ \t\r\n]+`, "white"), + + // Strings: interpreted, raw (back-quoted) and rune literals. + Match(`"(?:[^"\\]|\\.)*"`, "string"), + Match("`[^`]*`", "string"), + Match(`'(?:[^'\\]|\\.)*'`, "string"), + + Match(`[+-]?(?:0[xX][0-9a-fA-F]+|(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?)`, "number"), + + // `$`, `$x`, `$x :=` + Match(`\$`+goTemplateIdent, "variable"), + Match(`\$`, "variable"), + + // A leading dot is a field path, never a namespace. + MatchGroups(`(\.)(`+goTemplateIdent+`)`, "delimiter", "variable.field"), + Match(`\.`, "variable.field"), + + // `strings.ToUpper` -- only when the prefix is a real namespace. + Rule{ + Regex: `(` + goTemplateIdent + `)(\.)(` + goTemplateIdent + `)`, + Action: Action{Cases: NewCases(). + Groups("$1@namespaces", "namespace", "delimiter", "function"). + Default(Action{Tokens: []string{"identifier", "delimiter", "identifier"}}), + }, + }, + + Rule{ + Regex: goTemplateIdent, + Action: Action{Cases: NewCases(). + Token("@keywords", "keyword"). + Token("@builtins", "function.builtin"). + Token("@functions", "function"). + Default(Action{Token: "identifier"}), + }, + }, + + Match(`[()\[\]]`, "@brackets"), + Match(`\|`, "operator.pipe"), + Match(`:=|=`, "operator"), + Match(`,`, "delimiter"), + ) + + return states, entry, nil +} + +// BuildGoTemplate assembles the bare gomplate language: literal text with +// `{{ ... }}` actions embedded in it. +func BuildGoTemplate(spec GoTemplateSpec, d Delimiters) (Language, Configuration, error) { + states, entry, err := actionStates(spec, d) + if err != nil { + return Language{}, Configuration{}, err + } + + root := append([]Rule{directiveRule()}, entry...) + // Everything that is not an action is literal output. + root = append(root, Match(`[\s\S]`, "source")) + + lang := Language{ + ID: GoTemplateLanguageID, + DefaultToken: "source", + TokenPostfix: ".gomplate", + Brackets: []Bracket{ + {Open: "(", Close: ")", Token: "delimiter.parenthesis"}, + {Open: "[", Close: "]", Token: "delimiter.square"}, + }, + Attributes: goTemplateAttributes(spec), + Tokenizer: NewStates().Add("root", root...), + } + for _, name := range states.Names() { + lang.Tokenizer.Add(name, states.Rules(name)...) + } + + config := Configuration{ + Comments: &Comments{BlockComment: [2]string{d.Left + d.LeftComment, d.RightComment + d.Right}}, + Brackets: [][2]string{{d.Left, d.Right}, {"(", ")"}, {"[", "]"}}, + AutoClosingPairs: []Pair{ + {Open: d.Left, Close: " " + d.Right}, {Open: "(", Close: ")"}, + {Open: `"`, Close: `"`}, {Open: "`", Close: "`"}, + }, + SurroundingPairs: []Pair{ + {Open: "(", Close: ")"}, {Open: `"`, Close: `"`}, {Open: "`", Close: "`"}, + }, + WordPattern: `[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*`, + } + return lang, config, nil +} + +// directiveRule highlights gomplate's own delimiter header, which reconfigures +// the parser from inside the template (see Template.parseHeader). +func directiveRule() Rule { + return Match(`^#\s*gotemplate:.*$`, "comment.directive") +} + +func goTemplateAttributes(spec GoTemplateSpec) map[string][]string { + var topLevel []string + for _, f := range spec.Functions { + if f.Namespace == "" { + topLevel = append(topLevel, f.Name) + } + } + sort.Strings(topLevel) + + return map[string][]string{ + "keywords": spec.Keywords, + "builtins": spec.Builtins, + "namespaces": spec.Namespaces, + "functions": topLevel, + } +} + +// namespacedLeaves lists the method names under each namespace, for completion. +func namespacedLeaves(spec GoTemplateSpec) []string { + var out []string + for _, f := range spec.Functions { + if f.Namespace != "" { + out = append(out, strings.TrimPrefix(f.Name, f.Namespace+".")) + } + } + sort.Strings(out) + return dedupe(out) +} diff --git a/genmonarch/lang_jsonpath.go b/genmonarch/lang_jsonpath.go new file mode 100644 index 000000000..7276a5f81 --- /dev/null +++ b/genmonarch/lang_jsonpath.go @@ -0,0 +1,68 @@ +package genmonarch + +// JSONPathLanguageID is the Monaco language id for JSONPath expressions. +const JSONPathLanguageID = "jsonpath" + +// jsonPathFilters are the operators ojg's parser accepts inside `[?(...)]`. +// +// Unlike CEL and text/template, the JSONPath dialect gomplate evaluates +// (github.com/ohler55/ojg/jp, via coll.JSONPath) is a hand-written Go lexer with +// no grammar to read, so this vocabulary is declared rather than derived. The +// conformance corpus compensates: every snippet is round-tripped through the +// real jp.ParseString, so a token listed here that the parser rejects fails the +// build. +var jsonPathFilters = []string{ + "==", "!=", "<=", ">=", "&&", "||", "=~", + "<", ">", "!", "+", "-", "*", "/", +} + +// BuildJSONPath assembles the JSONPath tokenizer. +func BuildJSONPath() (Language, Configuration) { + lang := Language{ + ID: JSONPathLanguageID, + DefaultToken: "", + TokenPostfix: ".jsonpath", + Brackets: []Bracket{ + {Open: "[", Close: "]", Token: "delimiter.square"}, + {Open: "(", Close: ")", Token: "delimiter.parenthesis"}, + }, + Attributes: map[string][]string{ + "filterOperators": jsonPathFilters, + }, + Tokenizer: NewStates().Add("root", + Match(`\$`, "variable.root"), + Match(`@`, "variable.current"), + + // Recursive descent before the plain child separator, or `..name` + // tokenizes as two separate steps. + Match(`\.\.`, "operator.descendant"), + Match(`\*`, "operator.wildcard"), + MatchGroups(`(\.)([A-Za-z_][A-Za-z0-9_]*)`, "delimiter", "variable.field"), + Match(`\.`, "delimiter"), + + // A filter opens with `?(`; the union/slice forms are plain brackets. + Match(`\?\(`, "keyword.filter"), + Match(`[\[\]()]`, "@brackets"), + + Match(`"(?:[^"\\]|\\.)*"`, "string"), + Match(`'(?:[^'\\]|\\.)*'`, "string"), + Match(`\b(?:true|false|null)\b`, "keyword.constant"), + Match(`-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`, "number"), + + Match(`(?:==|!=|<=|>=|&&|\|\||=~|[<>!+\-*/])`, "operator"), + Match(`:`, "operator.slice"), + Match(`,`, "delimiter"), + Match(`[A-Za-z_][A-Za-z0-9_]*`, "variable.field"), + ), + } + + config := Configuration{ + Brackets: [][2]string{{"[", "]"}, {"(", ")"}}, + AutoClosingPairs: []Pair{ + {Open: "[", Close: "]"}, {Open: "(", Close: ")"}, + {Open: `"`, Close: `"`}, {Open: "'", Close: "'"}, + }, + WordPattern: `[A-Za-z_][A-Za-z0-9_]*`, + } + return lang, config +} diff --git a/genmonarch/monarch.go b/genmonarch/monarch.go new file mode 100644 index 000000000..ee9d13aeb --- /dev/null +++ b/genmonarch/monarch.go @@ -0,0 +1,338 @@ +package genmonarch + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" +) + +// Language is a Monaco Monarch language definition. It marshals to the shape +// monaco.languages.setMonarchTokensProvider expects. +type Language struct { + ID string `json:"-"` + DefaultToken string `json:"defaultToken"` + TokenPostfix string `json:"tokenPostfix"` + Start string `json:"start,omitempty"` + Brackets []Bracket `json:"brackets,omitempty"` + // Attributes are the named word lists a rule refers to as `@name`. Order + // within a list is irrelevant; the lists are sorted for a stable diff. + Attributes map[string][]string `json:"-"` + // Tokenizer holds the states. `root` is the entry state. + Tokenizer *States `json:"tokenizer"` +} + +// Bracket is a bracket pair Monaco should match and colour. +type Bracket struct { + Open string `json:"open"` + Close string `json:"close"` + Token string `json:"token"` +} + +// MarshalJSON flattens Attributes alongside the fixed fields, which is how +// Monarch expects word lists to appear. +func (l Language) MarshalJSON() ([]byte, error) { + type alias Language // avoid recursing into this method + base, err := json.Marshal(alias(l)) + if err != nil { + return nil, err + } + + fields := map[string]json.RawMessage{} + if err := json.Unmarshal(base, &fields); err != nil { + return nil, err + } + for name, words := range l.Attributes { + sorted := append([]string(nil), words...) + sort.Strings(sorted) + raw, err := json.Marshal(dedupe(sorted)) + if err != nil { + return nil, err + } + if _, clash := fields[name]; clash { + return nil, fmt.Errorf("attribute %q collides with a reserved Monarch field", name) + } + fields[name] = raw + } + return marshalOrdered(fields, orderedKeys(fields)) +} + +// States is an ordered set of tokenizer states. Both the state order and the +// rule order inside a state are significant: Monarch takes the first rule that +// matches, so a shorter pattern declared first silently shadows a longer one. +type States struct { + names []string + states map[string][]Rule +} + +// NewStates returns an empty, ordered state set. +func NewStates() *States { + return &States{states: map[string][]Rule{}} +} + +// Add appends a state. Adding the same name twice appends to it. +func (s *States) Add(name string, rules ...Rule) *States { + if _, seen := s.states[name]; !seen { + s.names = append(s.names, name) + } + s.states[name] = append(s.states[name], rules...) + return s +} + +// Names lists the states in declaration order. +func (s *States) Names() []string { return append([]string(nil), s.names...) } + +// Rules returns the rules of a state. +func (s *States) Rules(name string) []Rule { return s.states[name] } + +func (s *States) MarshalJSON() ([]byte, error) { + fields := map[string]json.RawMessage{} + for name, rules := range s.states { + raw, err := json.Marshal(rules) + if err != nil { + return nil, fmt.Errorf("state %s: %w", name, err) + } + fields[name] = raw + } + return marshalOrdered(fields, s.names) +} + +// Rule is one tokenizer rule: a pattern with an action, or an include of +// another state. +type Rule struct { + // Regex is JS regex source, without delimiters. + Regex string + // Action fires when Regex matches. + Action Action + // Include names a state to splice in, e.g. `@whitespace`. When set, the + // rest of the rule is ignored. + Include string +} + +func (r Rule) MarshalJSON() ([]byte, error) { + if r.Include != "" { + return json.Marshal(map[string]string{"include": r.Include}) + } + action, err := r.Action.MarshalJSON() + if err != nil { + return nil, err + } + return marshalTuple(r.Regex, action) +} + +// Match builds a rule that emits a single token. +func Match(regex, token string) Rule { + return Rule{Regex: regex, Action: Action{Token: token}} +} + +// MatchGroups builds a rule that emits one token per capture group. +func MatchGroups(regex string, tokens ...string) Rule { + return Rule{Regex: regex, Action: Action{Tokens: tokens}} +} + +// Push builds a rule that emits a token and enters another state. +func Push(regex, token, next string) Rule { + return Rule{Regex: regex, Action: Action{Token: token, Next: next}} +} + +// Pop builds a rule that emits a token and returns to the previous state. +func Pop(regex, token string) Rule { + return Rule{Regex: regex, Action: Action{Token: token, Next: "@pop"}} +} + +// Include splices another state's rules in at this position. +func Include(state string) Rule { return Rule{Include: state} } + +// Action is what a rule does when its pattern matches. +type Action struct { + // Token is the single token class to emit. + Token string + // Tokens is one token class per capture group; mutually exclusive with Token. + Tokens []string + // Next is the state to enter: a state name, `@pop`, or `@push`. + Next string + // Cases selects between actions by testing the match against word lists. + Cases *Cases +} + +func (a Action) MarshalJSON() ([]byte, error) { + switch { + case a.Cases != nil: + return json.Marshal(map[string]*Cases{"cases": a.Cases}) + case a.Tokens != nil && a.Next != "": + return nil, fmt.Errorf("a grouped action cannot also switch state") + case a.Tokens != nil: + return json.Marshal(a.Tokens) + case a.Next != "": + return json.Marshal(map[string]string{"token": a.Token, "next": a.Next}) + default: + return json.Marshal(a.Token) + } +} + +// Cases is an ordered set of guarded actions. Monarch evaluates the guards in +// order, so `@default` belongs last and a more specific guard must precede a +// broader one. +type Cases struct { + guards []string + actions map[string]Action +} + +// NewCases returns an empty, ordered case set. +func NewCases() *Cases { return &Cases{actions: map[string]Action{}} } + +// When appends a guard, e.g. `@keywords` or `$1@namespaces`. +func (c *Cases) When(guard string, action Action) *Cases { + if _, seen := c.actions[guard]; !seen { + c.guards = append(c.guards, guard) + } + c.actions[guard] = action + return c +} + +// Token appends a guard emitting a single token class. +func (c *Cases) Token(guard, token string) *Cases { + return c.When(guard, Action{Token: token}) +} + +// Groups appends a guard emitting one token class per capture group. +func (c *Cases) Groups(guard string, tokens ...string) *Cases { + return c.When(guard, Action{Tokens: tokens}) +} + +// Default appends the fallback, which must be added last. +func (c *Cases) Default(action Action) *Cases { return c.When("@default", action) } + +func (c *Cases) MarshalJSON() ([]byte, error) { + fields := map[string]json.RawMessage{} + for guard, action := range c.actions { + raw, err := action.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("case %s: %w", guard, err) + } + fields[guard] = raw + } + return marshalOrdered(fields, c.guards) +} + +// Configuration is a Monaco language configuration: the editor behaviours that +// are not tokenization. +type Configuration struct { + Comments *Comments `json:"comments,omitempty"` + Brackets [][2]string `json:"brackets,omitempty"` + AutoClosingPairs []Pair `json:"autoClosingPairs,omitempty"` + SurroundingPairs []Pair `json:"surroundingPairs,omitempty"` + // WordPattern decides what counts as one word for completion. Dotted names + // such as `k8s.isHealthy` must match as a single word or completing them + // inserts a duplicated namespace. + WordPattern string `json:"wordPattern,omitempty"` +} + +// Comments declares how comments are written. +type Comments struct { + LineComment string `json:"lineComment,omitempty"` + BlockComment [2]string `json:"blockComment,omitempty"` +} + +// Pair is a pair of strings the editor closes or surrounds a selection with. +type Pair struct { + Open string `json:"open"` + Close string `json:"close"` +} + +// marshalOrdered writes a JSON object with its keys in the given order, so a +// regenerated file diffs cleanly and Monarch's order-sensitive constructs keep +// their meaning. +func marshalOrdered(fields map[string]json.RawMessage, order []string) ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + first := true + for _, key := range order { + raw, ok := fields[key] + if !ok { + continue + } + if !first { + buf.WriteByte(',') + } + first = false + encoded, err := json.Marshal(key) + if err != nil { + return nil, err + } + buf.Write(encoded) + buf.WriteByte(':') + buf.Write(raw) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +func marshalTuple(first string, rest ...json.RawMessage) ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('[') + encoded, err := json.Marshal(first) + if err != nil { + return nil, err + } + buf.Write(encoded) + for _, raw := range rest { + buf.WriteByte(',') + buf.Write(raw) + } + buf.WriteByte(']') + return buf.Bytes(), nil +} + +// orderedKeys keeps the fixed Monarch fields first and the generated word lists +// after them, each group sorted, so the emitted file is stable. +func orderedKeys(fields map[string]json.RawMessage) []string { + fixed := []string{"defaultToken", "tokenPostfix", "start", "brackets"} + var rest []string + for key := range fields { + if !contains(fixed, key) && key != "tokenizer" { + rest = append(rest, key) + } + } + sort.Strings(rest) + out := make([]string, 0, len(fields)) + for _, key := range fixed { + if _, ok := fields[key]; ok { + out = append(out, key) + } + } + return append(append(out, rest...), "tokenizer") +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func dedupe(sorted []string) []string { + out := sorted[:0] + for i, s := range sorted { + if i == 0 || s != sorted[i-1] { + out = append(out, s) + } + } + return out +} + +// escapeRegexLiteral escapes a string for literal use inside a JS regex. Used +// for the configurable template delimiters, which are data, not patterns. +func escapeRegexLiteral(s string) string { + var b strings.Builder + for _, r := range s { + if strings.ContainsRune(`\.+*?()|[]{}^$/`, r) { + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} diff --git a/genmonarch/render.go b/genmonarch/render.go new file mode 100644 index 000000000..b69182e9c --- /dev/null +++ b/genmonarch/render.go @@ -0,0 +1,127 @@ +package genmonarch + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" +) + +// generatedHeader marks the emitted TypeScript so nobody edits it by hand. +const generatedHeader = `// Code generated by cmd/genmonarch. DO NOT EDIT. +// +// Regenerate with: make monarch +// The tokenizers come from cel-go's CEL.g4 and text/template's lexer; the +// function catalogue is read from a live cel.Env and gomplate's FuncMap. +` + +// Render turns a bundle into the files the npm package ships, keyed by +// filename. JSON is indented and newline-terminated so a regenerated tree +// produces a reviewable diff. +func Render(b *Bundle) (map[string][]byte, error) { + files := map[string][]byte{} + + for _, id := range b.Order { + lang := b.Languages[id] + encoded, err := marshalIndent(lang) + if err != nil { + return nil, fmt.Errorf("%s tokenizer: %w", id, err) + } + files[id+".monarch.json"] = encoded + + encoded, err = marshalIndent(b.Configurations[id]) + if err != nil { + return nil, fmt.Errorf("%s configuration: %w", id, err) + } + files[id+".config.json"] = encoded + } + + spec, err := marshalIndent(b.Spec) + if err != nil { + return nil, fmt.Errorf("spec: %w", err) + } + files["spec.json"] = spec + + corpus, err := marshalIndent(b.Conformance) + if err != nil { + return nil, fmt.Errorf("conformance corpus: %w", err) + } + files["conformance.json"] = corpus + + files["index.ts"] = []byte(renderIndex(b)) + return files, nil +} + +func marshalIndent(v any) ([]byte, error) { + // Marshal first so the custom MarshalJSON methods run, then re-indent: + // json.MarshalIndent does not indent the output of a custom marshaller. + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := json.Indent(&buf, raw, "", " "); err != nil { + return nil, err + } + buf.WriteByte('\n') + return buf.Bytes(), nil +} + +// renderIndex emits the typed entry point: every definition imported and keyed +// by language id, so the runtime package needs no per-language wiring. +func renderIndex(b *Bundle) string { + var out strings.Builder + out.WriteString(generatedHeader) + out.WriteString("\nimport type { ConformanceCase, LanguageDefinition, GomplateSpec } from \"../types\";\n\n") + + ids := append([]string(nil), b.Order...) + sort.Strings(ids) + + for _, id := range ids { + symbol := identifierFor(id) + fmt.Fprintf(&out, "import %sMonarch from \"./%s.monarch.json\";\n", symbol, id) + fmt.Fprintf(&out, "import %sConfig from \"./%s.config.json\";\n", symbol, id) + } + out.WriteString("import specJson from \"./spec.json\";\n") + out.WriteString("import conformanceJson from \"./conformance.json\";\n\n") + + out.WriteString("export const spec = specJson as GomplateSpec;\n\n") + out.WriteString("/** Token boundaries produced by the languages' real lexers. */\n") + out.WriteString("export const conformance = conformanceJson as ConformanceCase[];\n\n") + + out.WriteString("/** Every language id this package registers. */\n") + out.WriteString("export const LANGUAGE_IDS = [\n") + for _, id := range ids { + fmt.Fprintf(&out, " %q,\n", id) + } + out.WriteString("] as const;\n\n") + out.WriteString("export type LanguageId = (typeof LANGUAGE_IDS)[number];\n\n") + + // The casts go through `unknown` because TypeScript widens a JSON import's + // fixed-length arrays to `string[]`, while Monaco's types want tuples + // (`CharacterPair`, `blockComment`). The shapes are produced by this + // generator and covered by the tokenizer tests, so widening here loses + // nothing that is actually checked elsewhere. + out.WriteString("export const definitions: Record = {\n") + for _, id := range ids { + symbol := identifierFor(id) + fmt.Fprintf(&out, " %q: {\n id: %q,\n monarch: %sMonarch as unknown as LanguageDefinition[\"monarch\"],\n configuration: %sConfig as unknown as LanguageDefinition[\"configuration\"],\n },\n", + id, id, symbol, symbol) + } + out.WriteString("};\n") + return out.String() +} + +// identifierFor turns a language id into a JS identifier: `yaml-gomplate` -> +// `yamlGomplate`. +func identifierFor(id string) string { + parts := strings.Split(id, "-") + for i := 1; i < len(parts); i++ { + if parts[i] == "" { + continue + } + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + return strings.Join(parts, "") +} diff --git a/genmonarch/spec.go b/genmonarch/spec.go new file mode 100644 index 000000000..c7757b4dc --- /dev/null +++ b/genmonarch/spec.go @@ -0,0 +1,104 @@ +// Package genmonarch builds Monaco language definitions for the expression +// languages gomplate runs, from the grammars and registries gomplate itself +// uses -- so the editor cannot drift from the evaluator. +package genmonarch + +// Spec is the machine-readable catalogue of everything gomplate exposes to an +// author. It drives the generated tokenizers, the completion and hover +// providers, and the playground's function browser. +type Spec struct { + CEL CELSpec `json:"cel"` + GoTemplate GoTemplateSpec `json:"gotemplate"` +} + +// CELSpec is the CEL surface, read from a live cel.Env. +type CELSpec struct { + // Namespaces are the dotted prefixes in use: k8s, math, time, ... + Namespaces []string `json:"namespaces"` + // Keywords are CEL's reserved words. + Keywords []string `json:"keywords"` + // Types are the built-in type names usable as identifiers. + Types []string `json:"types"` + // Variables are the identifiers the base environment declares. + Variables []string `json:"variables,omitempty"` + Macros []Macro `json:"macros"` + Functions []Function `json:"functions"` +} + +// GoTemplateSpec is the Go text/template surface. +type GoTemplateSpec struct { + // Namespaces are the dotted prefixes: strings, coll, conv, ... + Namespaces []string `json:"namespaces"` + // Keywords are text/template's action keywords. + Keywords []string `json:"keywords"` + // Builtins are the functions text/template provides itself. + Builtins []string `json:"builtins"` + // Delimiters are text/template's defaults. + Delimiters Delimiters `json:"delimiters"` + Functions []Function `json:"functions"` +} + +// Delimiters is an action-delimiter pair. +type Delimiters struct { + Left string `json:"left"` + Right string `json:"right"` + // LeftComment and RightComment open and close a comment *inside* an action. + LeftComment string `json:"leftComment"` + RightComment string `json:"rightComment"` + // TrimMarker abuts a delimiter to trim surrounding whitespace. + TrimMarker string `json:"trimMarker"` +} + +// Function is one callable name, with every registered overload. +type Function struct { + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + // MemberOnly marks a function callable only as `x.f()`, never as `f(x)`. + // The tokenizer uses this to colour `x.sum()` without colouring a bare + // `sum`, which in CEL is just an identifier. + MemberOnly bool `json:"memberOnly,omitempty"` + Doc string `json:"doc,omitempty"` + // Signature is the Go signature, for go-template functions. + Signature string `json:"signature,omitempty"` + Overloads []Overload `json:"overloads,omitempty"` + Examples []string `json:"examples,omitempty"` +} + +// Overload is one typed signature of a CEL function. +type Overload struct { + ID string `json:"id"` + Args []string `json:"args"` + Result string `json:"result"` + Member bool `json:"member,omitempty"` +} + +// Macro is a CEL macro -- expanded at parse time, so it is never a Function. +type Macro struct { + Name string `json:"name"` + ArgCount int `json:"argCount"` + ReceiverStyle bool `json:"receiverStyle"` + Doc string `json:"doc,omitempty"` + Examples []string `json:"examples,omitempty"` +} + +// MemberNames returns the names callable only in member position. +func (s CELSpec) MemberNames() []string { + var out []string + for _, f := range s.Functions { + if f.MemberOnly { + out = append(out, f.Name) + } + } + return out +} + +// GlobalNames returns the names callable in global position, namespace included. +func (s CELSpec) GlobalNames() []string { + var out []string + for _, f := range s.Functions { + if !f.MemberOnly { + out = append(out, f.Name) + } + } + return out +} diff --git a/genmonarch/suite_test.go b/genmonarch/suite_test.go new file mode 100644 index 000000000..a07b30a89 --- /dev/null +++ b/genmonarch/suite_test.go @@ -0,0 +1,13 @@ +package genmonarch + +import ( + "testing" + + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGenmonarch(t *testing.T) { + RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "genmonarch") +} diff --git a/genmonarch/vocab_cel.go b/genmonarch/vocab_cel.go new file mode 100644 index 000000000..a84e9bca7 --- /dev/null +++ b/genmonarch/vocab_cel.go @@ -0,0 +1,158 @@ +package genmonarch + +import ( + "fmt" + "sort" + "strings" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common" + + gomplate "github.com/flanksource/gomplate/v3" +) + +// celBuiltinTypes are the type names CEL exposes as identifiers. They come from +// the checker's standard declarations rather than from a rule in CEL.g4, which +// only knows IDENTIFIER. +var celBuiltinTypes = []string{ + "bool", "bytes", "double", "dyn", "duration", "int", "list", "map", + "null_type", "string", "timestamp", "type", "uint", +} + +// ExtractCEL reads the CEL surface out of a live environment built from the +// same options RunExpression compiles against, so the catalogue is whatever +// gomplate actually registers -- not a list maintained alongside it. +// +// extra layers a caller's own options on top. Because this reads a live +// cel.Env rather than a maintained list, a host that registers +// `cel.Function("catalog.query", cel.Overload(...))` gets it back here with its +// typed overloads, and the editor can highlight and complete it without any +// change to the grammar. +func ExtractCEL(extra ...cel.EnvOption) (CELSpec, error) { + opts := gomplate.GetCelEnv(nil) + opts = append(opts, extra...) + env, err := cel.NewEnv(opts...) + if err != nil { + return CELSpec{}, fmt.Errorf("building the CEL environment: %w", err) + } + + spec := CELSpec{ + Keywords: gomplate.CELKeywords(), + Types: celBuiltinTypes, + } + + namespaces := map[string]bool{} + for name, decl := range env.Functions() { + if !isAuthorable(name) { + continue + } + fn := Function{Name: name, Doc: decl.Description()} + if ns, _, ok := splitNamespace(name); ok { + fn.Namespace = ns + namespaces[ns] = true + } + + memberOnly := true + for _, o := range decl.OverloadDecls() { + args := make([]string, 0, len(o.ArgTypes())) + for _, a := range o.ArgTypes() { + args = append(args, a.String()) + } + fn.Overloads = append(fn.Overloads, Overload{ + ID: o.ID(), + Args: args, + Result: o.ResultType().String(), + Member: o.IsMemberFunction(), + }) + fn.Examples = append(fn.Examples, o.Examples()...) + if !o.IsMemberFunction() { + memberOnly = false + } + } + if len(fn.Overloads) == 0 { + continue // a declaration with no overload is not callable + } + fn.MemberOnly = memberOnly + sort.Slice(fn.Overloads, func(i, j int) bool { return fn.Overloads[i].ID < fn.Overloads[j].ID }) + spec.Functions = append(spec.Functions, fn) + } + + for _, m := range env.Macros() { + macro := Macro{ + Name: m.Function(), + ArgCount: m.ArgCount(), + ReceiverStyle: m.IsReceiverStyle(), + } + // Docs are attached with cel.MacroDocs/MacroExamples -- gomplate's own + // `fold` macro sets both (cel_fold.go) -- but the Macro interface does + // not require them, so probe rather than assume. + if documented, ok := m.(interface{ Documentation() *common.Doc }); ok { + if doc := documented.Documentation(); doc != nil { + macro.Doc = doc.Description + for _, child := range doc.Children { + if child.Kind == common.DocExample { + macro.Examples = append(macro.Examples, child.Description) + } + } + } + } + spec.Macros = append(spec.Macros, macro) + } + + for _, v := range env.Variables() { + spec.Variables = append(spec.Variables, v.Name()) + } + + for ns := range namespaces { + spec.Namespaces = append(spec.Namespaces, ns) + } + + sortSpec(&spec) + return spec, nil +} + +// isAuthorable reports whether a declared name is one a person can actually +// type. The checker also declares operators (`_+_`, `_?._`, `_[?_]`), internal +// helpers (`__not_strictly_false__`) and macro-expansion targets (`cel.@fold*`) +// as functions; none of them belong in a tokenizer or a completion list. +func isAuthorable(name string) bool { + for _, segment := range strings.Split(name, ".") { + if segment == "" { + return false + } + for i, r := range segment { + isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') + isDigit := r >= '0' && r <= '9' + if i == 0 && !isLetter { + return false + } + if !isLetter && !isDigit && r != '_' { + return false + } + } + } + return name != "" +} + +// splitNamespace splits `k8s.isHealthy` into its namespace and leaf. Reports +// false for un-namespaced names such as `toJSON`. +func splitNamespace(name string) (ns, leaf string, ok bool) { + i := strings.Index(name, ".") + if i <= 0 || i == len(name)-1 { + return "", name, false + } + return name[:i], name[i+1:], true +} + +func sortSpec(spec *CELSpec) { + sort.Strings(spec.Namespaces) + sort.Strings(spec.Keywords) + sort.Strings(spec.Variables) + sort.Slice(spec.Functions, func(i, j int) bool { return spec.Functions[i].Name < spec.Functions[j].Name }) + sort.Slice(spec.Macros, func(i, j int) bool { + if spec.Macros[i].Name != spec.Macros[j].Name { + return spec.Macros[i].Name < spec.Macros[j].Name + } + return spec.Macros[i].ArgCount < spec.Macros[j].ArgCount + }) +} diff --git a/genmonarch/vocab_cel_test.go b/genmonarch/vocab_cel_test.go new file mode 100644 index 000000000..f61bf6bed --- /dev/null +++ b/genmonarch/vocab_cel_test.go @@ -0,0 +1,99 @@ +package genmonarch + +import ( + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("the CEL vocabulary read from a live environment", func() { + var spec CELSpec + var byName map[string]Function + + ginkgo.BeforeEach(func() { + var err error + spec, err = ExtractCEL() + Expect(err).ToNot(HaveOccurred()) + byName = map[string]Function{} + for _, f := range spec.Functions { + byName[f.Name] = f + } + }) + + ginkgo.It("finds the namespaces gomplate registers, not the ones CEL.md claims", func() { + // conv.* and path.* are commented out of funcs/cel_exports.go, so they + // must not appear however much the prose suggests otherwise. + Expect(spec.Namespaces).To(ContainElements("k8s", "aws", "math", "time", "filepath", "crypto", "random", "regexp", "base64", "uuid", "test", "net")) + Expect(spec.Namespaces).ToNot(ContainElement("conv")) + Expect(spec.Namespaces).ToNot(ContainElement("gcp")) + }) + + ginkgo.It("carries the reserved words from the evaluator's own table", func() { + Expect(spec.Keywords).To(ContainElements("true", "false", "null", "in", "as", "while")) + }) + + ginkgo.DescribeTable("registers the functions this fork adds", + func(name, namespace string) { + fn, found := byName[name] + Expect(found).To(BeTrue(), "%s is not registered", name) + Expect(fn.Namespace).To(Equal(namespace)) + Expect(fn.Overloads).ToNot(BeEmpty()) + }, + ginkgo.Entry("k8s health", "k8s.isHealthy", "k8s"), + ginkgo.Entry("k8s quantity", "k8s.cpuAsMillicores", "k8s"), + ginkgo.Entry("aws", "aws.arnToMap", "aws"), + ginkgo.Entry("un-namespaced coll", "matchLabel", ""), + ginkgo.Entry("go-template bridge", "f", ""), + ginkgo.Entry("business hours", "in_business_hours", ""), + ) + + ginkgo.It("separates member-only functions from globally callable ones", func() { + // `x.sum()` is valid, a bare `sum(x)` is not -- the tokenizer must only + // colour the former, so this classification has to be right. + Expect(byName["sum"].MemberOnly).To(BeTrue()) + Expect(byName["getHost"].MemberOnly).To(BeTrue()) + Expect(byName["k8s.isHealthy"].MemberOnly).To(BeFalse()) + Expect(byName["matchLabel"].MemberOnly).To(BeFalse()) + }) + + ginkgo.It("keeps aliases, because both spellings are real", func() { + for _, alias := range []string{"k8s.isHealthy", "k8s.is_healthy", "IsHealthy"} { + Expect(byName).To(HaveKey(alias)) + } + }) + + ginkgo.It("excludes operator and internal declarations", func() { + // The checker declares `_+_`, `_?._` and `__not_strictly_false__` as + // functions; none of them can be typed by an author. + for name := range byName { + Expect(isAuthorable(name)).To(BeTrue(), "%q is not an authorable identifier", name) + } + Expect(byName).ToNot(HaveKey("_+_")) + Expect(byName).ToNot(HaveKey("__not_strictly_false__")) + }) + + ginkgo.It("captures macros with their arities, including gomplate's own fold", func() { + type key struct { + name string + arity int + } + got := map[key]Macro{} + for _, m := range spec.Macros { + got[key{m.Name, m.ArgCount}] = m + } + Expect(got).To(HaveKey(key{"has", 1})) + Expect(got).To(HaveKey(key{"all", 2})) + Expect(got).To(HaveKey(key{"exists_one", 2})) + Expect(got).To(HaveKey(key{"fold", 3})) + Expect(got).To(HaveKey(key{"fold", 4})) + + // cel_fold.go attaches MacroDocs/MacroExamples; they should survive. + Expect(got[key{"fold", 3}].Doc).To(ContainSubstring("accumulator")) + Expect(got[key{"fold", 3}].Examples).ToNot(BeEmpty()) + }) + + ginkgo.It("is deterministic, so a regenerated spec produces no diff", func() { + again, err := ExtractCEL() + Expect(err).ToNot(HaveOccurred()) + Expect(again).To(Equal(spec)) + }) +}) diff --git a/genmonarch/vocab_gotemplate.go b/genmonarch/vocab_gotemplate.go new file mode 100644 index 000000000..29a93bad0 --- /dev/null +++ b/genmonarch/vocab_gotemplate.go @@ -0,0 +1,390 @@ +package genmonarch + +import ( + "context" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "runtime" + "sort" + "strings" + "sync" + + gomplate "github.com/flanksource/gomplate/v3" + "github.com/flanksource/gomplate/v3/genmonarch/grammar" +) + +// ExtractGoTemplate reads the go-template surface from the FuncMap gomplate +// actually installs, plus the vocabulary of text/template itself. +// +// Namespaces are registered as a zero-argument function returning a shared +// `*XFuncs` value (`f["strings"] = func() any { return ns }`), so the namespaced +// names are the exported methods of whatever that call returns. +func ExtractGoTemplate() (GoTemplateSpec, error) { + g, err := grammar.ParseGoTemplate() + if err != nil { + return GoTemplateSpec{}, err + } + + spec := GoTemplateSpec{ + Keywords: g.Keywords, + Builtins: g.Builtins, + Delimiters: Delimiters{ + Left: g.LeftDelim, + Right: g.RightDelim, + LeftComment: g.LeftComment, + RightComment: g.RightComment, + TrimMarker: g.TrimMarker, + }, + } + + namespaces := map[string]bool{} + ordinary := map[string]any{} + for name, entry := range gomplate.CreateFuncs(context.Background()) { + ns, isNamespace := namespaceValue(entry) + if !isNamespace { + ordinary[name] = entry + continue + } + namespaces[name] = true + methods, err := methodsOf(ns, name) + if err != nil { + return GoTemplateSpec{}, err + } + spec.Functions = append(spec.Functions, methods...) + } + for name, entry := range ordinary { + sig, err := functionSignature(entry, 0) + if err != nil { + return GoTemplateSpec{}, fmt.Errorf("reading signature for %s: %w", name, err) + } + spec.Functions = append(spec.Functions, Function{ + Name: name, + Signature: sig, + }) + } + + for ns := range namespaces { + spec.Namespaces = append(spec.Namespaces, ns) + } + sort.Strings(spec.Namespaces) + sort.Slice(spec.Functions, func(i, j int) bool { return spec.Functions[i].Name < spec.Functions[j].Name }) + + if len(spec.Functions) == 0 { + return GoTemplateSpec{}, fmt.Errorf("gomplate.CreateFuncs returned no functions") + } + return spec, nil +} + +// namespaceValue calls a `func() any` namespace accessor and returns the value +// it yields. Reports false for anything that is an ordinary template function. +func namespaceValue(entry any) (reflect.Value, bool) { + v := reflect.ValueOf(entry) + t := v.Type() + if t.Kind() != reflect.Func || t.NumIn() != 0 || t.NumOut() != 1 || t.IsVariadic() { + return reflect.Value{}, false + } + out := v.Call(nil)[0] + // The accessor is declared as `func() any`, so unwrap the interface to + // reach the concrete *XFuncs before looking for methods. + for out.Kind() == reflect.Interface { + out = out.Elem() + } + if !out.IsValid() || out.NumMethod() == 0 { + return reflect.Value{}, false + } + return out, true +} + +// methodsOf lists the exported methods of a namespace value as `ns.Method`. +func methodsOf(ns reflect.Value, namespace string) ([]Function, error) { + t := ns.Type() + out := make([]Function, 0, t.NumMethod()) + for i := 0; i < t.NumMethod(); i++ { + m := t.Method(i) + if !m.IsExported() { + continue + } + sig, err := methodSignature(t, m) + if err != nil { + return nil, fmt.Errorf("reading signature for %s.%s: %w", namespace, m.Name, err) + } + out = append(out, Function{ + Name: namespace + "." + m.Name, + Namespace: namespace, + // Method values carry the receiver in Type.In(0); the signature an + // author writes does not. + Signature: sig, + }) + } + return out, nil +} + +// functionSignature renders a Go function the way a template author sees it, +// retaining source parameter names that reflection itself discards. +func functionSignature(fn any, skip int) (string, error) { + t := reflect.TypeOf(fn) + if t == nil || t.Kind() != reflect.Func { + return "", fmt.Errorf("expected a function, got %T", fn) + } + names, err := sourceParameterNames(fn, t.NumIn()-skip) + if err != nil { + return "", err + } + return renderSignature(t, names, skip), nil +} + +func methodSignature(receiver reflect.Type, method reflect.Method) (string, error) { + names, err := methodParameterNames(receiver, method.Name, method.Type.NumIn()-1) + if err != nil { + return "", err + } + base := receiver + for base.Kind() == reflect.Pointer { + base = base.Elem() + } + sourceParameterCache.Store(base.PkgPath()+"."+base.Name()+"."+method.Name, names) + sourceParameterCache.Store(base.PkgPath()+".(*"+base.Name()+")."+method.Name, names) + if runtimeFn := runtime.FuncForPC(method.Func.Pointer()); runtimeFn != nil { + sourceParameterCache.Store(strings.TrimSuffix(runtimeFn.Name(), "-fm"), names) + } + return renderSignature(method.Type, names, 1), nil +} + +func renderSignature(t reflect.Type, names []string, skip int) string { + var args []string + for i := skip; i < t.NumIn(); i++ { + arg := t.In(i).String() + if t.IsVariadic() && i == t.NumIn()-1 { + arg = "..." + strings.TrimPrefix(arg, "[]") + } + args = append(args, names[i-skip]+" "+arg) + } + + var results []string + for i := 0; i < t.NumOut(); i++ { + results = append(results, t.Out(i).String()) + } + + sig := "(" + strings.Join(args, ", ") + ")" + switch len(results) { + case 0: + case 1: + sig += " " + results[0] + default: + sig += " (" + strings.Join(results, ", ") + ")" + } + return sig +} + +var ( + parsedSourceFiles sync.Map + sourceParameterCache sync.Map +) + +type parsedSourceFile struct { + file *ast.File + fset *token.FileSet +} + +func sourceParameterNames(fn any, count int) ([]string, error) { + v := reflect.ValueOf(fn) + pc := v.Pointer() + runtimeFn := runtime.FuncForPC(pc) + if runtimeFn == nil { + return nil, fmt.Errorf("runtime has no function metadata") + } + cacheKey := strings.TrimSuffix(runtimeFn.Name(), "-fm") + if cached, ok := sourceParameterCache.Load(cacheKey); ok { + names := cached.([]string) + if len(names) != count { + return nil, fmt.Errorf("source metadata for %s has %d parameters, expected %d", cacheKey, len(names), count) + } + return append([]string(nil), names...), nil + } + filename, line := runtimeFn.FileLine(pc) + if filename == "" { + packagePath, receiver, method, ok := runtimeMethodIdentity(cacheKey) + if !ok { + return nil, fmt.Errorf("source metadata for %s was not indexed", cacheKey) + } + names, err := namedMethodParameterNames(packagePath, receiver, method, count) + if err != nil { + return nil, err + } + sourceParameterCache.Store(cacheKey, names) + return append([]string(nil), names...), nil + } + source, err := parseSourceFile(filename) + if err != nil { + return nil, err + } + + name := sourceFunctionName(runtimeFn.Name()) + var matchingName, containingLine *ast.FuncDecl + for _, decl := range source.file.Decls { + function, ok := decl.(*ast.FuncDecl) + if !ok { + continue + } + if function.Name.Name == name && fieldCount(function.Type.Params) == count { + matchingName = function + } + if source.fset.Position(function.Pos()).Line <= line && line <= source.fset.Position(function.End()).Line && fieldCount(function.Type.Params) == count { + containingLine = function + } + } + selected := containingLine + if selected == nil { + selected = matchingName + } + if selected == nil { + return nil, fmt.Errorf("could not locate %s with %d parameters in %s:%d", name, count, filename, line) + } + names := fieldNames(selected.Type.Params) + sourceParameterCache.Store(cacheKey, names) + return append([]string(nil), names...), nil +} + +func methodParameterNames(receiver reflect.Type, name string, count int) ([]string, error) { + for receiver.Kind() == reflect.Pointer { + receiver = receiver.Elem() + } + return namedMethodParameterNames(receiver.PkgPath(), receiver.Name(), name, count) +} + +func namedMethodParameterNames(packagePath, receiver, name string, count int) ([]string, error) { + dir, err := packageSourceDir(packagePath) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", dir, err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + source, err := parseSourceFile(filepath.Join(dir, entry.Name())) + if err != nil { + return nil, err + } + for _, decl := range source.file.Decls { + function, ok := decl.(*ast.FuncDecl) + if !ok || function.Recv == nil || function.Name.Name != name { + continue + } + if receiverName(function.Recv.List[0].Type) != receiver || fieldCount(function.Type.Params) != count { + continue + } + return fieldNames(function.Type.Params), nil + } + } + return nil, fmt.Errorf("could not locate %s.%s with %d parameters", receiver, name, count) +} + +func runtimeMethodIdentity(runtimeName string) (packagePath, receiver, method string, ok bool) { + methodSeparator := strings.LastIndex(runtimeName, ".") + if methodSeparator < 0 { + return "", "", "", false + } + method = runtimeName[methodSeparator+1:] + prefix := runtimeName[:methodSeparator] + receiverSeparator := strings.LastIndex(prefix, ".") + lastSlash := strings.LastIndex(prefix, "/") + if receiverSeparator <= lastSlash { + return "", "", "", false + } + packagePath = prefix[:receiverSeparator] + receiver = strings.Trim(prefix[receiverSeparator+1:], "()") + receiver = strings.TrimPrefix(receiver, "*") + return packagePath, receiver, method, packagePath != "" && receiver != "" && method != "" +} + +func packageSourceDir(packagePath string) (string, error) { + const modulePath = "github.com/flanksource/gomplate/v3" + if packagePath != modulePath && !strings.HasPrefix(packagePath, modulePath+"/") { + return "", fmt.Errorf("package %s is outside %s", packagePath, modulePath) + } + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("runtime has no source location for genmonarch") + } + root := filepath.Dir(filepath.Dir(sourceFile)) + relative := strings.TrimPrefix(packagePath, modulePath) + return filepath.Join(root, strings.TrimPrefix(relative, "/")), nil +} + +func receiverName(expr ast.Expr) string { + switch typed := expr.(type) { + case *ast.Ident: + return typed.Name + case *ast.StarExpr: + return receiverName(typed.X) + case *ast.IndexExpr: + return receiverName(typed.X) + case *ast.IndexListExpr: + return receiverName(typed.X) + default: + return "" + } +} + +func parseSourceFile(filename string) (*parsedSourceFile, error) { + if cached, ok := parsedSourceFiles.Load(filename); ok { + return cached.(*parsedSourceFile), nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, filename, nil, parser.SkipObjectResolution) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filename, err) + } + source := &parsedSourceFile{file: file, fset: fset} + parsedSourceFiles.Store(filename, source) + return source, nil +} + +func sourceFunctionName(runtimeName string) string { + runtimeName = strings.TrimSuffix(runtimeName, "-fm") + name := runtimeName[strings.LastIndex(runtimeName, ".")+1:] + if generic := strings.IndexByte(name, '['); generic >= 0 { + name = name[:generic] + } + return name +} + +func fieldCount(fields *ast.FieldList) int { + if fields == nil { + return 0 + } + count := 0 + for _, field := range fields.List { + count += len(field.Names) + if len(field.Names) == 0 { + count++ + } + } + return count +} + +func fieldNames(fields *ast.FieldList) []string { + if fields == nil { + return nil + } + var names []string + for _, field := range fields.List { + if len(field.Names) == 0 { + names = append(names, fmt.Sprintf("arg%d", len(names)+1)) + continue + } + for _, name := range field.Names { + names = append(names, name.Name) + } + } + return names +} diff --git a/genmonarch/vocab_gotemplate_test.go b/genmonarch/vocab_gotemplate_test.go new file mode 100644 index 000000000..bad45a7d5 --- /dev/null +++ b/genmonarch/vocab_gotemplate_test.go @@ -0,0 +1,73 @@ +package genmonarch + +import ( + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("the go-template vocabulary read from the live FuncMap", func() { + var spec GoTemplateSpec + var byName map[string]Function + + ginkgo.BeforeEach(func() { + var err error + spec, err = ExtractGoTemplate() + Expect(err).ToNot(HaveOccurred()) + byName = map[string]Function{} + for _, f := range spec.Functions { + byName[f.Name] = f + } + }) + + ginkgo.It("finds every namespace CreateFuncs installs", func() { + Expect(spec.Namespaces).To(ConsistOf( + "base64", "coll", "conv", "crypto", "data", "filepath", "k8s", + "math", "net", "path", "random", "regexp", "strings", "test", + "time", "uuid", + )) + }) + + ginkgo.It("keeps namespaces go-template has but CEL does not", func() { + // conv.* and path.* are registered for go templates while being + // commented out of funcs/cel_exports.go. The two catalogues are + // genuinely different and the specs must say so. + celSpec, err := ExtractCEL() + Expect(err).ToNot(HaveOccurred()) + Expect(spec.Namespaces).To(ContainElements("conv", "path")) + Expect(celSpec.Namespaces).ToNot(ContainElement("conv")) + Expect(celSpec.Namespaces).ToNot(ContainElement("path")) + }) + + ginkgo.DescribeTable("expands namespace accessors into their methods, with signatures", + func(name, wantSignature string) { + fn, found := byName[name] + Expect(found).To(BeTrue(), "%s is not registered", name) + Expect(fn.Signature).To(Equal(wantSignature)) + }, + // The receiver is dropped: an author writes `strings.ToUpper "x"`. + ginkgo.Entry("string method", "strings.ToUpper", "(s interface {}) string"), + ginkgo.Entry("variadic", "coll.Dict", "(in ...interface {}) (map[string]interface {}, error)"), + ginkgo.Entry("typed return", "time.ParseDuration", "(n interface {}) (time.Duration, error)"), + ginkgo.Entry("two args", "jq", "(jqExpr string, in interface {}) (interface {}, error)"), + ginkgo.Entry("grouped parameters", "contains", "(s string, substr string) bool"), + ) + + ginkgo.It("uses the exported method names, which differ in case from CEL's", func() { + // The same helper is `k8s.IsHealthy` in a go template and + // `k8s.isHealthy` in CEL. Sharing one list across both would be wrong. + Expect(byName).To(HaveKey("k8s.IsHealthy")) + Expect(byName).ToNot(HaveKey("k8s.isHealthy")) + }) + + ginkgo.It("keeps the top-level aliases alongside the namespaced names", func() { + Expect(byName).To(HaveKey("toJSON")) + Expect(byName).To(HaveKey("isHealthy")) + Expect(byName).To(HaveKey("strings.ToUpper")) + }) + + ginkgo.It("is deterministic, so a regenerated spec produces no diff", func() { + again, err := ExtractGoTemplate() + Expect(err).ToNot(HaveOccurred()) + Expect(again).To(Equal(spec)) + }) +}) From 6f4b6daaa6bcf51c92ba2002c14b56292dc074bb Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 14 Aug 2026 14:10:17 +0300 Subject: [PATCH 2/7] feat(playground): add embeddable expression playground API Expose reusable evaluation and metadata endpoints for hosts embedding gomplate's language playground. Support CEL, Go templates, JSONPath, and JavaScript with source-positioned errors, typed results, host-provided functions, examples, and bounded responses. --- playground/README.md | 93 +++++++++ playground/eval.go | 415 +++++++++++++++++++++++++++++++++++++ playground/eval_test.go | 449 ++++++++++++++++++++++++++++++++++++++++ playground/server.go | 98 +++++++++ 4 files changed, 1055 insertions(+) create mode 100644 playground/README.md create mode 100644 playground/eval.go create mode 100644 playground/eval_test.go create mode 100644 playground/server.go diff --git a/playground/README.md b/playground/README.md new file mode 100644 index 000000000..abc4fab97 --- /dev/null +++ b/playground/README.md @@ -0,0 +1,93 @@ +# Embedding the expression playground + +`playground` serves the API behind the language playground: evaluate an expression, fetch the function catalogue, fetch the sample documents. It runs everything through the same entry points a gomplate caller uses, so what an author sees in the editor is what production does. + +A host embeds it to give its own authors a playground over its **own** language. Everything a host registers on top of gomplate — mission-control's `catalog.query`, `gitops.source` — flows through `Options`, so the catalogue, the highlighting and the evaluator all agree with what that binary can actually run. + +## Mounting + +```go +handler, err := playground.NewHandler(playground.Options{ + Timeout: 5 * time.Second, +}) +if err != nil { + return err +} +mux.Handle("/playground/", http.StripPrefix("/playground", handler.Mux())) +``` + +`Mux()` is an `http.Handler`, so an echo host wraps it: + +```go +group.Any("/playground/*", echo.WrapHandler( + http.StripPrefix("/playground", handler.Mux()), +)) +``` + +Routes: `POST /api/eval`, `GET /api/spec`, `GET /api/examples`, `GET /api/health`. + +## ⚠️ It carries no authorization + +`/api/eval` runs arbitrary expressions with whatever `Options` grants them. In a host whose functions reach a database or a repository, **that is arbitrary execution against real data** — a `catalog.query` an author can write is a `catalog.query` anyone reaching the endpoint can write. + +Mount it inside an already-authenticated route group, under the same authorization you would put any other query endpoint behind. The package deliberately does not offer a half-measure of its own. + +## Supplying your own functions + +Both fields are factories rather than plain slices, matching how hosts already register — duty keeps `map[string]func(Context) cel.EnvOption` because a function like `catalog.query` closes over the database handle it queries through. + +```go +playground.Options{ + CelEnvs: func(ctx context.Context) []cel.EnvOption { + opts := make([]cel.EnvOption, 0, len(duty.CelEnvFuncs)) + for _, f := range duty.CelEnvFuncs { + opts = append(opts, f(dutyContext(ctx))) + } + return opts + }, + Functions: func(ctx context.Context) map[string]any { + out := map[string]any{} + for name, f := range duty.TemplateFuncs { + out[name] = f(dutyContext(ctx)) + } + return out + }, +} +``` + +`CelEnvs` reaches three places at once, which is the point: + +- the **evaluator**, through `gomplate.Template.CelEnvs`; +- the **compile check** that gives errors a source position, through `gomplate.CompileEnvOptions` — miss it there and a host's own function reports "undeclared reference" before the evaluator ever sees it; +- the **catalogue** at `GET /api/spec`, through `genmonarch.ExtractCEL`, which reads a live `cel.Env` rather than a maintained list. A `cel.Function("catalog.query", cel.Overload(...))` shows up there with its typed overloads, and the editor highlights and completes it without any change to the grammar. + +`Functions` is exposed to both CEL and go templates, subject to gomplate's existing constraint: a CEL-visible entry must be a `func() any`. Anything with real arguments belongs in `CelEnvs`. + +The spec is extracted once, at `NewHandler`, against `context.Background()`. The factories are per-request because a function's *binding* closes over a request; the *declarations* it registers — names, overloads, types — are the same every time. + +## Sample data + +```go +playground.Options{ + Examples: []playground.Example{{ + Name: "Unhealthy config items", + Language: playground.LanguageCEL, + Source: `catalog.query("health=unhealthy").size() > 0`, + Input: "…", + }}, +} +``` + +Served from `GET /api/examples`, always as an array. + +## Bounding an evaluation + +`Options.Timeout` bounds the **response**, not the work. gomplate honours no context deadline while evaluating — there is no `cel.ContextEval` and no deadline check in `RunTemplateContext` — so a runaway expression keeps its goroutine after the caller has been answered. + +That is still the right trade for a shared endpoint, where a hung request is the worse failure. But it is not cancellation, and a host that expects to see CPU released on timeout will be disappointed. + +## Known limits + +- **Your functions will have thin documentation.** The catalogue reads `decl.Description()`; declare `cel.FunctionDocs` and overload examples to get prose and examples in hovers. Worth knowing: gomplate's own `gencel`-generated functions do not set them either, so this is a shared gap rather than a tax on hosts. +- **Go-template functions extract less well than CEL ones.** gomplate's own readable signatures come from parsing its source with `go/packages`; a host's `map[string]any` closure yields reflection types only, with no parameter names. +- **The conformance corpus does not cover host vocabulary.** It round-trips snippets from gomplate's docs through the real lexers. Hosts inherit the grammar guarantees, but need their own snippets for the same guard on their own functions. diff --git a/playground/eval.go b/playground/eval.go new file mode 100644 index 000000000..789874604 --- /dev/null +++ b/playground/eval.go @@ -0,0 +1,415 @@ +// Package playground evaluates expressions for the language playground, using +// the same entry points a gomplate caller uses so what the playground shows is +// what production does. +// +// A host embeds this to give its own authors a playground over its own +// language: Options carries the CEL options and template functions the host +// registers, so the catalogue, the highlighting and the evaluator all agree +// with what that binary can actually run. +package playground + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/google/cel-go/cel" + "github.com/robertkrimen/otto" + ottoparser "github.com/robertkrimen/otto/parser" + "gopkg.in/yaml.v3" + + gomplate "github.com/flanksource/gomplate/v3" + "github.com/flanksource/gomplate/v3/coll" +) + +// Options configure a playground for one host. +// +// The two function fields are shaped as factories rather than plain slices to +// match how hosts already register: duty keeps +// `map[string]func(Context) cel.EnvOption`, because a function like +// `catalog.query` closes over the database handle it queries through. +type Options struct { + // CelEnvs are layered onto gomplate's own CEL options, per evaluation. + CelEnvs func(context.Context) []cel.EnvOption + // Functions are exposed to both CEL and go templates. Note gomplate's + // constraint: a CEL-visible entry must be a `func() any`; anything else + // belongs in CelEnvs. + Functions func(context.Context) map[string]any + // Examples are the samples the playground offers to load. + Examples []Example + // Timeout bounds one evaluation. Zero means no bound. + // + // It bounds the *response*, not the work: gomplate honours no context + // deadline while evaluating -- there is no cel.ContextEval and no deadline + // check in RunTemplateContext -- so a runaway expression keeps its + // goroutine after the caller has been answered. That is still the right + // trade for a shared endpoint, where a hung request is the worse failure, + // but it is not cancellation and should not be mistaken for it. + Timeout time.Duration +} + +// Example is one sample an author can load into the playground. +type Example struct { + Name string `json:"name"` + Language Language `json:"language"` + Source string `json:"source"` + Input string `json:"input"` +} + +func (o Options) celEnvs(ctx context.Context) []cel.EnvOption { + if o.CelEnvs == nil { + return nil + } + return o.CelEnvs(ctx) +} + +func (o Options) functions(ctx context.Context) map[string]any { + if o.Functions == nil { + return nil + } + return o.Functions(ctx) +} + +// template returns the prototype every evaluation starts from: the host's +// extensions, with the language-specific source left to the caller. +func (o Options) template(ctx context.Context) gomplate.Template { + return gomplate.Template{ + CelEnvs: o.celEnvs(ctx), + Functions: o.functions(ctx), + } +} + +// Language selects which evaluator to run. +type Language string + +const ( + LanguageCEL Language = "cel" + LanguageGoTemplate Language = "gotemplate" + LanguageJSONPath Language = "jsonpath" + LanguageJavaScript Language = "javascript" +) + +// Request is one evaluation. +type Request struct { + Language Language `json:"language"` + Source string `json:"source"` + // Input is the evaluation environment, as YAML or JSON. JSON is valid YAML, + // so one parser covers both. + Input string `json:"input,omitempty"` + // LeftDelim and RightDelim override the go-template delimiters. Both must + // be set together. + LeftDelim string `json:"leftDelim,omitempty"` + RightDelim string `json:"rightDelim,omitempty"` +} + +// Response is the result of an evaluation. +type Response struct { + // Result is the value rendered as a string, as a gomplate caller sees it. + Result string `json:"result"` + // Value is the native result, so the playground can show typed JSON rather + // than a stringified value. + Value any `json:"value,omitempty"` + // Type names the Go type of Value, which is what makes CEL's int/uint/ + // double distinction visible. + Type string `json:"type,omitempty"` + // Error is set when evaluation failed. Result is empty in that case. + Error *EvalError `json:"error,omitempty"` + // DurationMs is wall-clock evaluation time. + DurationMs float64 `json:"durationMs"` +} + +// EvalError carries a message and, where the compiler reports one, a source +// position so the editor can place a marker on the offending token. +type EvalError struct { + Message string `json:"message"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` +} + +// Evaluate runs one request. A failed evaluation is a populated Error in the +// response, not a Go error: the playground always has something to render. A +// Go error means the request itself was malformed. +func (h *Handler) Evaluate(ctx context.Context, req Request) (*Response, error) { + environment, err := parseInput(req.Input) + if err != nil { + return &Response{Error: &EvalError{Message: fmt.Sprintf("input: %s", err)}}, nil + } + + if strings.TrimSpace(req.Source) == "" { + return &Response{}, nil + } + + base := h.options.template(ctx) + + started := time.Now() + value, evalErr := h.runBounded(req, environment, base) + elapsed := float64(time.Since(started).Microseconds()) / 1000 + + resp := &Response{DurationMs: elapsed} + if evalErr != nil { + resp.Error = evalErr + return resp, nil + } + resp.Value = value + resp.Type = fmt.Sprintf("%T", value) + resp.Result = renderResult(value) + return resp, nil +} + +type evalOutcome struct { + value any + err *EvalError +} + +// runBounded answers within Options.Timeout. See the field's documentation for +// why the abandoned evaluation keeps running: gomplate has no cancellation to +// call, so the choice is between answering late and answering at all. +func (h *Handler) runBounded( + req Request, + environment map[string]any, + base gomplate.Template, +) (any, *EvalError) { + if h.options.Timeout <= 0 { + return evaluate(req, environment, base) + } + + done := make(chan evalOutcome, 1) + go func() { + value, err := evaluate(req, environment, base) + done <- evalOutcome{value: value, err: err} + }() + + timer := time.NewTimer(h.options.Timeout) + defer timer.Stop() + select { + case outcome := <-done: + return outcome.value, outcome.err + case <-timer.C: + return nil, &EvalError{ + Message: fmt.Sprintf("evaluation exceeded %s", h.options.Timeout), + } + } +} + +func evaluate(req Request, environment map[string]any, base gomplate.Template) (any, *EvalError) { + switch req.Language { + case LanguageCEL: + return evaluateCEL(req.Source, environment, base) + + case LanguageJSONPath: + // RunTemplateContext declares a JSONPath field but never evaluates it, + // so route to the implementation the `jsonpath` function uses rather + // than silently returning nothing. + out, err := coll.JSONPath(req.Source, environment) + if err != nil { + return nil, jsonPathParseError(req.Source, err) + } + return out, nil + + case LanguageGoTemplate: + tpl := base + tpl.Template = req.Source + tpl.LeftDelim = req.LeftDelim + tpl.RightDelim = req.RightDelim + out, err := gomplate.RunTemplate(environment, tpl) + if err != nil { + return nil, goTemplateError(err) + } + return out, nil + + case LanguageJavaScript: + if validationErr := validateJavaScript(req.Source); validationErr != nil { + return nil, validationErr + } + tpl := base + tpl.Javascript = req.Source + out, err := gomplate.RunTemplate(environment, tpl) + if err != nil { + return nil, javaScriptRuntimeError(err) + } + return out, nil + + default: + return nil, &EvalError{Message: fmt.Sprintf("unknown language %q", req.Language)} + } +} + +// text/template reports where it failed only inside the message text: +// `template: :: ` while parsing, and +// `template: ::: executing "" at : ` +// while executing. The template is unnamed here, which is why the name group +// is allowed to be empty. +var goTemplatePosition = regexp.MustCompile(`^template: .*?:(\d+)(?::(\d+))?: `) + +// The template name repeated inside an execution failure is empty here, so it +// reads as `executing "" at <.a.b>` -- noise in front of the part that says +// which action failed. +var goTemplateExecuting = regexp.MustCompile(`^executing "[^"]*" at `) + +// otto keeps the position in the stack trace rather than the message, one +// `at [name (]::[)]` frame per line, innermost first. +var javaScriptFrame = regexp.MustCompile(`\n\s*at (?:[^\s(]+ \()?:(\d+):(\d+)\)?`) + +// goTemplateError lifts the position text/template buried in its message into +// a field the editor can place a marker from. An error raised before parsing -- +// gomplate's own, or a function's -- carries no position and keeps its message +// whole. +func goTemplateError(err error) *EvalError { + message := err.Error() + match := goTemplatePosition.FindStringSubmatch(message) + if match == nil { + return &EvalError{Message: message} + } + line, convErr := strconv.Atoi(match[1]) + if convErr != nil { + return &EvalError{Message: message} + } + // Parse failures report a line but no column; the start of the line is as + // close as the marker can honestly get. + column := 1 + if match[2] != "" { + if column, convErr = strconv.Atoi(match[2]); convErr != nil { + return &EvalError{Message: message} + } + } + rest := goTemplateExecuting.ReplaceAllString(message[len(match[0]):], "at ") + return &EvalError{Message: rest, Line: line, Column: column} +} + +// javaScriptRuntimeError reads the innermost stack frame, which is where the +// throw happened rather than where the call chain started. +func javaScriptRuntimeError(err error) *EvalError { + var runtimeErr *otto.Error + if !errors.As(err, &runtimeErr) { + return &EvalError{Message: err.Error()} + } + out := &EvalError{Message: runtimeErr.Error()} + if frame := javaScriptFrame.FindStringSubmatch(runtimeErr.String()); frame != nil { + line, lineErr := strconv.Atoi(frame[1]) + column, columnErr := strconv.Atoi(frame[2]) + if lineErr == nil && columnErr == nil { + out.Line, out.Column = line, column + } + } + return out +} + +func jsonPathParseError(source string, parseErr error) *EvalError { + message := parseErr.Error() + withoutSource := strings.TrimSuffix(message, " in "+source) + separator := strings.LastIndex(withoutSource, " at ") + if separator < 0 { + return &EvalError{Message: "jsonpath parser returned an unpositioned error: " + message} + } + offset, err := strconv.Atoi(withoutSource[separator+4:]) + if err != nil || offset < 1 { + return &EvalError{Message: "jsonpath parser returned an invalid error position: " + message} + } + line, column := positionAtByteOffset(source, offset) + return &EvalError{Message: message, Line: line, Column: column} +} + +func positionAtByteOffset(source string, offset int) (int, int) { + before := source + if offset-1 < len(source) { + before = source[:offset-1] + } + line := strings.Count(before, "\n") + 1 + if newline := strings.LastIndexByte(before, '\n'); newline >= 0 { + before = before[newline+1:] + } + return line, utf8.RuneCountInString(before) + 1 +} + +func validateJavaScript(source string) *EvalError { + if _, err := ottoparser.ParseFile(nil, "", source, 0); err != nil { + var parseErrors *ottoparser.ErrorList + if errors.As(err, &parseErrors) && len(*parseErrors) > 0 { + first := (*parseErrors)[0] + return &EvalError{ + Message: first.Message, + Line: first.Position.Line, + Column: first.Position.Column, + } + } + return &EvalError{Message: "javascript parser returned an unpositioned error: " + err.Error()} + } + return nil +} + +// evaluateCEL compiles before evaluating so compile errors carry a source +// position. RunExpression alone reports the message without one, and a marker +// without a position lands on line 1. +// +// The compile-check environment comes from CompileEnvOptions rather than +// GetCelEnv so it matches what the evaluator will build. Without the host's +// CelEnvs and Functions, its own functions report "undeclared reference" here +// and never reach the evaluator that would have run them perfectly well. +func evaluateCEL(source string, environment map[string]any, base gomplate.Template) (any, *EvalError) { + tpl := base + tpl.Expression = source + + env, err := cel.NewEnv(gomplate.CompileEnvOptions(environment, tpl)...) + if err != nil { + return nil, &EvalError{Message: err.Error()} + } + if _, issues := env.Compile(source); issues != nil && issues.Err() != nil { + return nil, celIssueError(issues) + } + + out, err := gomplate.RunExpression(environment, tpl) + if err != nil { + return nil, &EvalError{Message: err.Error()} + } + return out, nil +} + +// celIssueError takes the first issue's position, which is where the editor +// should point. +func celIssueError(issues *cel.Issues) *EvalError { + out := &EvalError{Message: issues.Err().Error()} + if errs := issues.Errors(); len(errs) > 0 { + out.Message = errs[0].Message + out.Line = errs[0].Location.Line() + // CEL columns are 0-based; Monaco's are 1-based. + out.Column = errs[0].Location.Column() + 1 + } + return out +} + +// parseInput reads the environment. JSON is valid YAML, so one parser serves +// both, and an empty input is an empty environment rather than an error. +func parseInput(input string) (map[string]any, error) { + if strings.TrimSpace(input) == "" { + return map[string]any{}, nil + } + var environment map[string]any + if err := yaml.Unmarshal([]byte(input), &environment); err != nil { + return nil, err + } + if environment == nil { + return map[string]any{}, nil + } + return environment, nil +} + +// renderResult stringifies the way a gomplate caller sees a result: strings +// verbatim, structures as JSON. +func renderResult(value any) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return typed + } + if encoded, err := json.Marshal(value); err == nil { + return string(encoded) + } + return fmt.Sprintf("%v", value) +} diff --git a/playground/eval_test.go b/playground/eval_test.go new file mode 100644 index 000000000..e33e0f9ad --- /dev/null +++ b/playground/eval_test.go @@ -0,0 +1,449 @@ +package playground + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPlayground(t *testing.T) { + RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "playground") +} + +// run evaluates against a playground with no host extensions, which is what +// most specs are about. +func run(req Request) (*Response, error) { + return runWith(Options{}, req) +} + +func runWith(options Options, req Request) (*Response, error) { + handler, err := NewHandler(options) + if err != nil { + return nil, err + } + return handler.Evaluate(context.Background(), req) +} + +var _ = ginkgo.Describe("evaluating a playground request", func() { + const podInput = ` +pod: + metadata: + name: web + status: + phase: Running +count: 3 +` + + ginkgo.DescribeTable("returns the same result the language itself would", + func(req Request, want string) { + resp, err := run(req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(BeNil(), "unexpected error: %+v", resp.Error) + Expect(resp.Result).To(Equal(want)) + }, + ginkgo.Entry("cel field access", + Request{Language: LanguageCEL, Source: `pod.metadata.name`, Input: podInput}, "web"), + ginkgo.Entry("cel arithmetic", + Request{Language: LanguageCEL, Source: `count * 2`, Input: podInput}, "6"), + ginkgo.Entry("cel member function", + Request{Language: LanguageCEL, Source: `pod.status.phase.lowerAscii()`, Input: podInput}, "running"), + ginkgo.Entry("cel k8s helper", + Request{Language: LanguageCEL, Source: `k8s.cpuAsMillicores("500m")`}, "500"), + ginkgo.Entry("go template", + Request{Language: LanguageGoTemplate, Source: `{{ .pod.metadata.name }}`, Input: podInput}, "web"), + ginkgo.Entry("go template pipeline", + Request{Language: LanguageGoTemplate, Source: `{{ .pod.metadata.name | strings.ToUpper }}`, Input: podInput}, "WEB"), + ginkgo.Entry("jsonpath", + Request{Language: LanguageJSONPath, Source: `$.pod.metadata.name`, Input: podInput}, "web"), + ginkgo.Entry("jsonpath built-in function", + Request{ + Language: LanguageJSONPath, + Source: `$.items[?(length(@.name) == 3)].name`, + Input: "items:\n - name: web\n - name: worker\n", + }, "web"), + ginkgo.Entry("javascript", + Request{Language: LanguageJavaScript, Source: `count + 1`, Input: podInput}, "4"), + ginkgo.Entry("javascript registered function", + Request{Language: LanguageJavaScript, Source: `startsWith(pod.metadata.name, "we")`, Input: podInput}, "true"), + ) + + ginkgo.It("honours custom go-template delimiters", func() { + resp, err := run(Request{ + Language: LanguageGoTemplate, + Source: `$[[ .count ]]`, + Input: podInput, + LeftDelim: "$[[", + RightDelim: "]]", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(BeNil()) + Expect(resp.Result).To(Equal("3")) + }) + + ginkgo.It("evaluates jsonpath, which RunTemplateContext itself ignores", func() { + // Template.JSONPath is declared but never dispatched on, so routing had + // to be explicit. Guard against it silently returning nothing again. + resp, err := run(Request{Language: LanguageJSONPath, Source: `$.count`, Input: podInput}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(BeNil()) + Expect(resp.Result).ToNot(BeEmpty()) + }) + + ginkgo.It("reports a CEL compile error at the position of the offending token", func() { + resp, err := run(Request{Language: LanguageCEL, Source: "1 +"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Message).ToNot(BeEmpty()) + Expect(resp.Error.Line).To(Equal(1)) + // A marker needs a 1-based column; 0 would silently land at the start. + Expect(resp.Error.Column).To(BeNumerically(">", 0)) + }) + + ginkgo.It("places the marker on the right line of a multi-line expression", func() { + resp, err := run(Request{Language: LanguageCEL, Source: "1 +\n2 +\n%%%"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Line).To(Equal(3)) + }) + + ginkgo.DescribeTable("reports parser validation at the offending source position", + func(language Language, source string, line, column int) { + resp, err := run(Request{Language: language, Source: source}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Message).ToNot(BeEmpty()) + Expect(resp.Error.Line).To(Equal(line)) + Expect(resp.Error.Column).To(Equal(column)) + }, + ginkgo.Entry("jsonpath", LanguageJSONPath, `$.items[?(`, 1, 11), + ginkgo.Entry("javascript", LanguageJavaScript, "var value = 1;\nvalue + ;", 2, 9), + ginkgo.Entry("go template parse", LanguageGoTemplate, "hello\n{{ nope .a }}\n", 2, 1), + ginkgo.Entry("go template unclosed action", LanguageGoTemplate, "one\ntwo\n{{ if true }}", 3, 1), + ) + + ginkgo.It("reports a go-template execution failure at the offending action", func() { + // The failure surfaces while executing, not while parsing, and text/template + // reports the position only inside the message text. + resp, err := run(Request{ + Language: LanguageGoTemplate, + Source: "x\n{{ .pod.metadata.name.nope }}\n", + Input: podInput, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Line).To(Equal(2)) + // text/template picks the column inside the failing action itself; the + // contract here is that it lands on that line, 1-based, not at 0. + Expect(resp.Error.Column).To(BeNumerically(">", 0)) + Expect(resp.Error.Column).To(BeNumerically("<=", len("{{ .pod.metadata.name.nope }}"))) + // The position belongs in the marker, not repeated in its message, and + // the empty template name reads as noise. + Expect(resp.Error.Message).ToNot(ContainSubstring("template: ")) + Expect(resp.Error.Message).ToNot(ContainSubstring(`executing ""`)) + Expect(resp.Error.Message).To(ContainSubstring("can't evaluate field")) + }) + + ginkgo.It("reports a javascript runtime failure at the frame that raised it", func() { + // otto keeps the position in the stack trace rather than the message, and + // the innermost frame is where the editor should point. + resp, err := run(Request{ + Language: LanguageJavaScript, + Source: "function fail() {\n throw new Error('boom')\n}\nfail()", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Message).To(ContainSubstring("boom")) + Expect(resp.Error.Line).To(Equal(2)) + Expect(resp.Error.Column).To(BeNumerically(">", 0)) + }) + + ginkgo.It("reports an unknown identifier rather than evaluating to empty", func() { + resp, err := run(Request{Language: LanguageCEL, Source: `nope.field`}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).ToNot(BeNil()) + }) + + ginkgo.It("surfaces malformed input as an error instead of an empty environment", func() { + resp, err := run(Request{Language: LanguageCEL, Source: "a", Input: "{not: [valid"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Message).To(ContainSubstring("input:")) + }) + + ginkgo.It("rejects an unknown language", func() { + resp, err := run(Request{Language: "klingon", Source: "x"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error.Message).To(ContainSubstring("unknown language")) + }) + + ginkgo.It("keeps the native value so the playground can show its type", func() { + resp, err := run(Request{Language: LanguageCEL, Source: `[1, 2, 3]`}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(BeNil()) + Expect(resp.Result).To(Equal("[1,2,3]")) + Expect(resp.Type).ToNot(BeEmpty()) + }) +}) + +var _ = ginkgo.Describe("the playground API", func() { + var mux *http.ServeMux + + ginkgo.BeforeEach(func() { + handler, err := NewHandler(Options{}) + Expect(err).ToNot(HaveOccurred()) + mux = handler.Mux() + }) + + post := func(path string, body any) *httptest.ResponseRecorder { + encoded, err := json.Marshal(body) + Expect(err).ToNot(HaveOccurred()) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, bytes.NewReader(encoded))) + return rec + } + + ginkgo.It("evaluates over HTTP", func() { + rec := post("/api/eval", Request{Language: LanguageCEL, Source: `"a" + "b"`}) + Expect(rec.Code).To(Equal(http.StatusOK)) + + var resp Response + Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed()) + Expect(resp.Result).To(Equal("ab")) + }) + + ginkgo.It("returns an evaluation failure as a 200 with an error body", func() { + // The request was well formed; the expression was not. The playground + // needs the message and position, not a transport-level failure. + rec := post("/api/eval", Request{Language: LanguageCEL, Source: "1 +"}) + Expect(rec.Code).To(Equal(http.StatusOK)) + + var resp Response + Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed()) + Expect(resp.Error).ToNot(BeNil()) + }) + + ginkgo.It("rejects a malformed request body", func() { + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/eval", bytes.NewReader([]byte("{")))) + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + }) + + ginkgo.It("serves the spec the editor completes from", func() { + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/spec", nil)) + Expect(rec.Code).To(Equal(http.StatusOK)) + + var body struct { + CEL struct { + Functions []struct { + Name string `json:"name"` + } `json:"functions"` + } `json:"cel"` + } + Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed()) + Expect(len(body.CEL.Functions)).To(BeNumerically(">", 100)) + }) +}) + +// hostCELFunction is a stand-in for the shape a host actually registers -- +// duty's `catalog.query` is a namespaced function taking a string -- without +// dragging a database into the spec. +func hostCELFunction() cel.EnvOption { + return cel.Function("catalog.query", + cel.Overload("catalog.query_string", + []*cel.Type{cel.StringType}, + cel.StringType, + cel.FunctionBinding(func(args ...ref.Val) ref.Val { + return types.String("queried:" + args[0].Value().(string)) + }), + ), + ) +} + +func hostOptions() Options { + return Options{ + CelEnvs: func(context.Context) []cel.EnvOption { + return []cel.EnvOption{hostCELFunction()} + }, + } +} + +var _ = ginkgo.Describe("a host's own language", func() { + const query = `catalog.query("name=web")` + + ginkgo.It("evaluates a function supplied through CelEnvs", func() { + resp, err := runWith(hostOptions(), Request{Language: LanguageCEL, Source: query}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(BeNil(), "unexpected error: %+v", resp.Error) + Expect(resp.Result).To(Equal("queried:name=web")) + }) + + ginkgo.It("rejects the same expression without the option", func() { + // The negative half matters: without it the spec above would still pass + // if the function were somehow reaching the evaluator by another route. + resp, err := run(Request{Language: LanguageCEL, Source: query}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).ToNot(BeNil()) + }) + + ginkgo.It("compiles against the host's options, so its own function is not undeclared", func() { + // The compile pass exists to give errors a source position, and it + // builds its own environment. Miss the host's options there and its + // functions fail this check before the evaluator ever sees them. + resp, err := runWith(hostOptions(), Request{ + Language: LanguageCEL, + Source: query + ` + "!"`, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(BeNil(), "unexpected error: %+v", resp.Error) + }) + + ginkgo.It("exposes a template function to go templates", func() { + resp, err := runWith(Options{ + Functions: func(context.Context) map[string]any { + return map[string]any{"hostName": func() any { return "mission-control" }} + }, + }, Request{Language: LanguageGoTemplate, Source: `{{ hostName }}`}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(BeNil(), "unexpected error: %+v", resp.Error) + Expect(resp.Result).To(Equal("mission-control")) + }) + + ginkgo.It("catalogues the host's function, with its overload, in the spec", func() { + handler, err := NewHandler(hostOptions()) + Expect(err).ToNot(HaveOccurred()) + + rec := httptest.NewRecorder() + handler.Mux().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/spec", nil)) + Expect(rec.Code).To(Equal(http.StatusOK)) + + var body struct { + CEL struct { + Namespaces []string `json:"namespaces"` + Functions []struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Overloads []struct { + Args []string `json:"args"` + Result string `json:"result"` + } `json:"overloads"` + } `json:"functions"` + } `json:"cel"` + } + Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed()) + + var found bool + for _, fn := range body.CEL.Functions { + if fn.Name != "catalog.query" { + continue + } + found = true + Expect(fn.Namespace).To(Equal("catalog")) + Expect(fn.Overloads).To(HaveLen(1)) + Expect(fn.Overloads[0].Args).To(Equal([]string{"string"})) + Expect(fn.Overloads[0].Result).To(Equal("string")) + } + Expect(found).To(BeTrue(), "catalog.query missing from the spec") + + // The namespace is what the tokenizer colours `catalog.` by. + Expect(body.CEL.Namespaces).To(ContainElement("catalog")) + }) +}) + +var _ = ginkgo.Describe("serving examples", func() { + serve := func(options Options) *httptest.ResponseRecorder { + handler, err := NewHandler(options) + Expect(err).ToNot(HaveOccurred()) + rec := httptest.NewRecorder() + handler.Mux().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/examples", nil)) + return rec + } + + ginkgo.It("serves what the host configured", func() { + rec := serve(Options{Examples: []Example{{ + Name: "Healthy pods", + Language: LanguageCEL, + Source: `pod.status.phase == "Running"`, + Input: "pod:\n status:\n phase: Running\n", + }}}) + Expect(rec.Code).To(Equal(http.StatusOK)) + + var examples []Example + Expect(json.Unmarshal(rec.Body.Bytes(), &examples)).To(Succeed()) + Expect(examples).To(HaveLen(1)) + Expect(examples[0].Name).To(Equal("Healthy pods")) + Expect(examples[0].Language).To(Equal(LanguageCEL)) + }) + + ginkgo.It("serves an empty array rather than null when none are configured", func() { + // The playground renders the response directly; a null would show an + // empty picker with nothing to explain it. + rec := serve(Options{}) + Expect(rec.Body.String()).To(ContainSubstring("[]")) + + var examples []Example + Expect(json.Unmarshal(rec.Body.Bytes(), &examples)).To(Succeed()) + Expect(examples).To(BeEmpty()) + }) +}) + +var _ = ginkgo.Describe("bounding an evaluation", func() { + // A slow function rather than a pathological expression: text/template + // refuses deep recursion on its own after ~0.1s, so a runaway template + // would exercise that limit instead of this one. + slowOptions := func(d time.Duration) Options { + return Options{ + Timeout: 100 * time.Millisecond, + Functions: func(context.Context) map[string]any { + return map[string]any{"slow": func() any { time.Sleep(d); return "done" }} + }, + } + } + + ginkgo.It("answers with an error instead of waiting for the evaluation", func() { + started := time.Now() + resp, err := runWith(slowOptions(10*time.Second), Request{ + Language: LanguageGoTemplate, + Source: `{{ slow }}`, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Message).To(ContainSubstring("evaluation exceeded 100ms")) + + // The point of the bound: the caller is answered long before the + // evaluation finishes. It is still running -- gomplate has no + // cancellation to call -- but nobody is waiting on it. + Expect(time.Since(started)).To(BeNumerically("<", 5*time.Second)) + }) + + ginkgo.It("returns the real result when the evaluation finishes in time", func() { + resp, err := runWith(slowOptions(time.Millisecond), Request{ + Language: LanguageGoTemplate, + Source: `{{ slow }}`, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(BeNil(), "unexpected error: %+v", resp.Error) + Expect(resp.Result).To(Equal("done")) + }) + + ginkgo.It("leaves a fast evaluation alone", func() { + resp, err := runWith(Options{Timeout: 30 * time.Second}, Request{ + Language: LanguageCEL, + Source: `1 + 1`, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(BeNil()) + Expect(resp.Result).To(Equal("2")) + }) +}) diff --git a/playground/server.go b/playground/server.go new file mode 100644 index 000000000..b9aeb51d0 --- /dev/null +++ b/playground/server.go @@ -0,0 +1,98 @@ +package playground + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/flanksource/gomplate/v3/genmonarch" +) + +// Handler serves the playground API. +// +// It carries no authentication of its own, deliberately. /api/eval runs +// arbitrary expressions with whatever Options grants them: in a host whose +// functions reach a database or a repository, that is arbitrary execution +// against real data. Mount it behind the same authorization as any other query +// endpoint. Handler.Mux satisfies http.Handler, so echo hosts wrap it with +// echo.WrapHandler inside an already-authenticated group. +type Handler struct { + spec genmonarch.Spec + options Options +} + +// NewHandler builds the API over a freshly extracted spec, so a running +// playground reflects the current binary rather than a stale generated file. +// +// The spec is extracted once, against context.Background(): Options.CelEnvs is +// a per-request factory because a function's *binding* closes over a request's +// context, but the declarations it registers -- the names, overloads and types +// the editor completes from -- are the same for every request. +func NewHandler(options Options) (*Handler, error) { + celSpec, err := genmonarch.ExtractCEL(options.celEnvs(context.Background())...) + if err != nil { + return nil, err + } + goSpec, err := genmonarch.ExtractGoTemplate() + if err != nil { + return nil, err + } + return &Handler{ + spec: genmonarch.Spec{CEL: celSpec, GoTemplate: goSpec}, + options: options, + }, nil +} + +// Mux returns the routes, ready to serve. +func (h *Handler) Mux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/eval", h.handleEval) + mux.HandleFunc("GET /api/spec", h.handleSpec) + mux.HandleFunc("GET /api/examples", h.handleExamples) + mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + return mux +} + +func (h *Handler) handleEval(w http.ResponseWriter, r *http.Request) { + var req Request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, Response{ + Error: &EvalError{Message: fmt.Sprintf("malformed request: %s", err)}, + }) + return + } + + resp, err := h.Evaluate(r.Context(), req) + if err != nil { + writeJSON(w, http.StatusBadRequest, Response{Error: &EvalError{Message: err.Error()}}) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) handleSpec(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, h.spec) +} + +// handleExamples always writes an array, never null: the playground renders the +// response directly and a null would be an empty picker with no explanation. +func (h *Handler) handleExamples(w http.ResponseWriter, _ *http.Request) { + examples := h.options.Examples + if examples == nil { + examples = []Example{} + } + writeJSON(w, http.StatusOK, examples) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + // The status line is already written, so the only useful signal left is + // the truncated body the client will fail to parse. + fmt.Fprintf(w, "\n{\"error\":{\"message\":%q}}\n", err.Error()) + } +} From 4e108c1fbba6c6b6ed60dc15147ccde2589aaf31 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 14 Aug 2026 14:14:47 +0300 Subject: [PATCH 3/7] feat(web): add Monaco language tooling and interactive playground Adds generated Monaco language support and an interactive playground for gomplate expressions and templates. Keeps editor catalogues aligned with evaluator registries while providing completion, hover, theming, object-graph exploration, and live evaluation. Includes generated specs, conformance coverage, package documentation, and playground tests. --- web/.gitignore | 4 + web/apps/playground/index.html | 31 + web/apps/playground/package.json | 32 + web/apps/playground/plugins/eval-server.ts | 74 + web/apps/playground/src/App.tsx | 450 ++ web/apps/playground/src/RunControls.tsx | 68 + web/apps/playground/src/VerticalSplit.tsx | 161 + web/apps/playground/src/api.ts | 89 + web/apps/playground/src/examples.ts | 148 + .../playground/src/functionCatalogue.test.ts | 34 + web/apps/playground/src/functionCatalogue.ts | 25 + web/apps/playground/src/hashRouter.ts | 46 + web/apps/playground/src/languages.ts | 113 + web/apps/playground/src/main.tsx | 14 + web/apps/playground/src/monaco-setup.ts | 18 + web/apps/playground/src/panels/GraphPanel.tsx | 95 + .../playground/src/panels/ResultPanel.tsx | 107 + web/apps/playground/src/panels/SpecPanel.tsx | 109 + .../playground/src/panels/TokensPanel.tsx | 82 + web/apps/playground/src/panels/graphNodes.ts | 51 + web/apps/playground/src/runAction.ts | 26 + web/apps/playground/src/styles.css | 30 + web/apps/playground/src/useEditorTheme.ts | 39 + web/apps/playground/src/useEvaluator.ts | 117 + web/apps/playground/src/useParsedInput.ts | 39 + web/apps/playground/src/useUrlState.ts | 70 + web/apps/playground/test/examples.test.ts | 22 + web/apps/playground/test/graphNodes.test.ts | 80 + web/apps/playground/tsconfig.json | 17 + web/apps/playground/vite.config.ts | 71 + web/package.json | 12 + web/packages/lang/README.md | 133 + web/packages/lang/package.json | 47 + web/packages/lang/src/attributes.ts | 81 + web/packages/lang/src/completion.ts | 250 + web/packages/lang/src/environment.ts | 167 + .../lang/src/generated/cel.config.json | 68 + .../lang/src/generated/cel.monarch.json | 566 ++ .../lang/src/generated/conformance.json | 4959 +++++++++++++ .../lang/src/generated/gomplate.config.json | 55 + .../lang/src/generated/gomplate.monarch.json | 282 + web/packages/lang/src/generated/index.ts | 72 + .../src/generated/json-gomplate.config.json | 31 + .../src/generated/json-gomplate.monarch.json | 353 + .../lang/src/generated/jsonpath.config.json | 31 + .../lang/src/generated/jsonpath.monarch.json | 103 + web/packages/lang/src/generated/spec.json | 6189 +++++++++++++++++ .../src/generated/text-gomplate.config.json | 31 + .../src/generated/text-gomplate.monarch.json | 287 + .../src/generated/yaml-gomplate.config.json | 38 + .../src/generated/yaml-gomplate.monarch.json | 434 ++ web/packages/lang/src/hover.ts | 140 + web/packages/lang/src/index.ts | 170 + web/packages/lang/src/merge.ts | 69 + web/packages/lang/src/prefix.ts | 173 + web/packages/lang/src/spec.ts | 25 + web/packages/lang/src/theme.ts | 109 + web/packages/lang/src/types.ts | 90 + web/packages/lang/test/completion.test.ts | 223 + web/packages/lang/test/conformance.test.ts | 91 + web/packages/lang/test/hostSpec.test.ts | 187 + web/packages/lang/test/registration.test.ts | 96 + web/packages/lang/test/setup.ts | 15 + web/packages/lang/test/tokenize.test.ts | 274 + web/packages/lang/tsconfig.build.json | 13 + web/packages/lang/tsconfig.json | 23 + web/packages/lang/vite.config.ts | 26 + web/pnpm-workspace.yaml | 27 + 68 files changed, 18202 insertions(+) create mode 100644 web/.gitignore create mode 100644 web/apps/playground/index.html create mode 100644 web/apps/playground/package.json create mode 100644 web/apps/playground/plugins/eval-server.ts create mode 100644 web/apps/playground/src/App.tsx create mode 100644 web/apps/playground/src/RunControls.tsx create mode 100644 web/apps/playground/src/VerticalSplit.tsx create mode 100644 web/apps/playground/src/api.ts create mode 100644 web/apps/playground/src/examples.ts create mode 100644 web/apps/playground/src/functionCatalogue.test.ts create mode 100644 web/apps/playground/src/functionCatalogue.ts create mode 100644 web/apps/playground/src/hashRouter.ts create mode 100644 web/apps/playground/src/languages.ts create mode 100644 web/apps/playground/src/main.tsx create mode 100644 web/apps/playground/src/monaco-setup.ts create mode 100644 web/apps/playground/src/panels/GraphPanel.tsx create mode 100644 web/apps/playground/src/panels/ResultPanel.tsx create mode 100644 web/apps/playground/src/panels/SpecPanel.tsx create mode 100644 web/apps/playground/src/panels/TokensPanel.tsx create mode 100644 web/apps/playground/src/panels/graphNodes.ts create mode 100644 web/apps/playground/src/runAction.ts create mode 100644 web/apps/playground/src/styles.css create mode 100644 web/apps/playground/src/useEditorTheme.ts create mode 100644 web/apps/playground/src/useEvaluator.ts create mode 100644 web/apps/playground/src/useParsedInput.ts create mode 100644 web/apps/playground/src/useUrlState.ts create mode 100644 web/apps/playground/test/examples.test.ts create mode 100644 web/apps/playground/test/graphNodes.test.ts create mode 100644 web/apps/playground/tsconfig.json create mode 100644 web/apps/playground/vite.config.ts create mode 100644 web/package.json create mode 100644 web/packages/lang/README.md create mode 100644 web/packages/lang/package.json create mode 100644 web/packages/lang/src/attributes.ts create mode 100644 web/packages/lang/src/completion.ts create mode 100644 web/packages/lang/src/environment.ts create mode 100644 web/packages/lang/src/generated/cel.config.json create mode 100644 web/packages/lang/src/generated/cel.monarch.json create mode 100644 web/packages/lang/src/generated/conformance.json create mode 100644 web/packages/lang/src/generated/gomplate.config.json create mode 100644 web/packages/lang/src/generated/gomplate.monarch.json create mode 100644 web/packages/lang/src/generated/index.ts create mode 100644 web/packages/lang/src/generated/json-gomplate.config.json create mode 100644 web/packages/lang/src/generated/json-gomplate.monarch.json create mode 100644 web/packages/lang/src/generated/jsonpath.config.json create mode 100644 web/packages/lang/src/generated/jsonpath.monarch.json create mode 100644 web/packages/lang/src/generated/spec.json create mode 100644 web/packages/lang/src/generated/text-gomplate.config.json create mode 100644 web/packages/lang/src/generated/text-gomplate.monarch.json create mode 100644 web/packages/lang/src/generated/yaml-gomplate.config.json create mode 100644 web/packages/lang/src/generated/yaml-gomplate.monarch.json create mode 100644 web/packages/lang/src/hover.ts create mode 100644 web/packages/lang/src/index.ts create mode 100644 web/packages/lang/src/merge.ts create mode 100644 web/packages/lang/src/prefix.ts create mode 100644 web/packages/lang/src/spec.ts create mode 100644 web/packages/lang/src/theme.ts create mode 100644 web/packages/lang/src/types.ts create mode 100644 web/packages/lang/test/completion.test.ts create mode 100644 web/packages/lang/test/conformance.test.ts create mode 100644 web/packages/lang/test/hostSpec.test.ts create mode 100644 web/packages/lang/test/registration.test.ts create mode 100644 web/packages/lang/test/setup.ts create mode 100644 web/packages/lang/test/tokenize.test.ts create mode 100644 web/packages/lang/tsconfig.build.json create mode 100644 web/packages/lang/tsconfig.json create mode 100644 web/packages/lang/vite.config.ts create mode 100644 web/pnpm-workspace.yaml diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 000000000..4c8daff29 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +*.tsbuildinfo diff --git a/web/apps/playground/index.html b/web/apps/playground/index.html new file mode 100644 index 000000000..1af53e884 --- /dev/null +++ b/web/apps/playground/index.html @@ -0,0 +1,31 @@ + + + + + + gomplate — Language Playground + + + + +
+ + + diff --git a/web/apps/playground/package.json b/web/apps/playground/package.json new file mode 100644 index 000000000..73b76940d --- /dev/null +++ b/web/apps/playground/package.json @@ -0,0 +1,32 @@ +{ + "name": "gomplate-playground", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@flanksource/clicky-ui": "^0.3.19", + "@flanksource/gomplate-lang": "workspace:*", + "monaco-editor": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "yaml": "catalog:" + }, + "devDependencies": { + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/web/apps/playground/plugins/eval-server.ts b/web/apps/playground/plugins/eval-server.ts new file mode 100644 index 000000000..cce616911 --- /dev/null +++ b/web/apps/playground/plugins/eval-server.ts @@ -0,0 +1,74 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import type { Plugin } from "vite"; + +export interface EvalServerOptions { + /** Repository root, where `go run` is invoked. */ + repoRoot: string; + /** Port the Go server listens on. */ + port: number; +} + +/** + * Runs `go run ./cmd/playground` alongside the dev server, so evaluation goes + * through the real gomplate engine rather than a reimplementation in the + * browser. + * + * Set `GOMPLATE_PLAYGROUND_SERVER=0` to manage the process yourself -- useful + * when attaching a debugger, or when iterating on Go code that would otherwise + * be recompiled on every Vite restart. + */ +export function evalServer({ repoRoot, port }: EvalServerOptions): Plugin { + let child: ChildProcess | undefined; + + const stop = () => { + if (!child || child.killed) return; + child.kill("SIGTERM"); + child = undefined; + }; + + return { + name: "gomplate-eval-server", + apply: "serve", + + configureServer(server) { + // Vitest stands up a Vite server of its own; compiling and running the Go + // binary for a unit test would be minutes of nothing useful. + if (process.env.VITEST) return; + if (process.env.GOMPLATE_PLAYGROUND_SERVER === "0") { + server.config.logger.info( + `[gomplate] eval server not started; expecting one on :${port}`, + ); + return; + } + + child = spawn("go", ["run", "./cmd/playground", "-addr", `:${port}`], { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout?.on("data", (chunk: Buffer) => { + server.config.logger.info(`[gomplate] ${chunk.toString().trimEnd()}`); + }); + child.stderr?.on("data", (chunk: Buffer) => { + // `go run` reports compile errors here; they are the single most useful + // thing to surface, so do not swallow them. + server.config.logger.error(`[gomplate] ${chunk.toString().trimEnd()}`); + }); + child.on("exit", (code) => { + if (code !== 0 && code !== null) { + server.config.logger.error(`[gomplate] eval server exited with code ${code}`); + } + child = undefined; + }); + + // `go run` leaves the compiled binary as a grandchild, so kill on every + // way the dev server can end rather than relying on process-group death. + for (const signal of ["SIGINT", "SIGTERM", "exit"] as const) { + process.once(signal, stop); + } + server.httpServer?.once("close", stop); + }, + + closeBundle: stop, + }; +} diff --git a/web/apps/playground/src/App.tsx b/web/apps/playground/src/App.tsx new file mode 100644 index 000000000..dd428a53a --- /dev/null +++ b/web/apps/playground/src/App.tsx @@ -0,0 +1,450 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + AppShell, + Combobox, + DensityProvider, + Tabs, + ThemeProvider, +} from "@flanksource/clicky-ui"; +import type { AppShellNavSection } from "@flanksource/clicky-ui"; +import { RouterProvider } from "@flanksource/clicky-ui/rpc"; +import { MonacoEditor, MonacoProvider } from "@flanksource/clicky-ui/monaco"; +import type { Monaco } from "@flanksource/clicky-ui/monaco"; +import { mergeSpec, registerGomplateLanguages, spec } from "@flanksource/gomplate-lang"; +import type { GomplateSpec, RegisteredLanguages } from "@flanksource/gomplate-lang"; +import * as monacoEditor from "monaco-editor"; + +import { fetchSpec } from "./api"; +import type { EvalResponse } from "./api"; +import { defaultExample, examplesFor } from "./examples"; +import { functionCatalogue, functionCatalogueFlavour } from "./functionCatalogue"; +import { LANGUAGES, SECTIONS, languageById } from "./languages"; +import { getMonacoWorker } from "./monaco-setup"; +import { GraphPanel } from "./panels/GraphPanel"; +import { ResultPanel } from "./panels/ResultPanel"; +import { SpecPanel } from "./panels/SpecPanel"; +import { TokensPanel } from "./panels/TokensPanel"; +import { RunControls } from "./RunControls"; +import { registerRunAction } from "./runAction"; +import { useEvaluator } from "./useEvaluator"; +import { useParsedInput } from "./useParsedInput"; +import { VerticalSplit, rowsToPaneHeight } from "./VerticalSplit"; +import { useHashRouter } from "./hashRouter"; +import { useEditorTheme } from "./useEditorTheme"; +import { stateHref, useUrlState } from "./useUrlState"; + +const SOURCE_MODEL_PATH = "inmemory://playground/source"; +const INPUT_MODEL_PATH = "inmemory://playground/input.yaml"; +/** Owner of the markers this app sets, so clearing them leaves other providers' alone. */ +const MARKER_OWNER = "gomplate"; + +export function App() { + const router = useHashRouter(); + + return ( + + + + + + + + + + ); +} + +function Playground() { + const initial = defaultExample("cel"); + const [state, setState] = useUrlState({ + language: "cel", + source: initial.source, + input: initial.input, + }); + + const language = useMemo(() => languageById(state.language), [state.language]); + const [outputTab, setOutputTab] = useState("result"); + + const evaluator = useEvaluator({ + language: language.evalLanguage, + source: state.source, + input: state.input, + }); + + // Completion reads the document through a ref: languages are registered once, + // before the first editor mounts, while the input keeps being edited after. + const parsedInput = useParsedInput(state.input); + const environmentRef = useRef(undefined); + environmentRef.current = parsedInput.value; + + // The catalogue the server can actually evaluate, which for a host binary is + // wider than the one this package ships. Everything that reads a catalogue + // reads this one, so the Functions tab counts what completion offers. + const [served, setServed] = useState(); + const servedRef = useRef(undefined); + servedRef.current = served; + const activeSpec = useMemo(() => mergeSpec(spec, served), [served]); + + const catalogue = functionCatalogue(language.evalLanguage, activeSpec); + const catalogueFlavour = functionCatalogueFlavour(language.evalLanguage); + + // Registration must happen before the first model is created, or Monaco + // resolves the language id to plaintext and never revisits it. Which of the + // two lands first is a race -- Monaco loads slowly, the fetch returns fast -- + // so both paths apply the catalogue: `beforeMount` reads whatever has already + // arrived, and the effect updates whatever is already registered. + const languages = useRef(null); + const registerLanguages = useCallback((monaco: Monaco) => { + languages.current = registerGomplateLanguages(monaco, { + environment: () => environmentRef.current, + spec: servedRef.current, + }); + }, []); + + useEffect(() => { + const controller = new AbortController(); + void fetchSpec(controller.signal).then((spec) => { + if (!controller.signal.aborted) setServed(spec); + }); + return () => controller.abort(); + }, []); + + useEffect(() => { + if (served) languages.current?.setSpec(served); + }, [served]); + + const [sourceModel, setSourceModel] = useState(null); + useMarkers(evaluator.response, sourceModel); + const applyTheme = useEditorTheme(); + + useEffect(() => { + if (outputTab === "spec" && !catalogueFlavour) setOutputTab("result"); + }, [catalogueFlavour, outputTab]); + + // The Monaco action is registered once per editor, so it has to reach the + // current `run` through a ref rather than capturing it. + const runRef = useRef(evaluator.run); + runRef.current = evaluator.run; + const sourceEditor = useRef[0] | null>(null); + const onEditorMount = useCallback( + (editor: Parameters[0], monaco: Monaco) => { + applyTheme(); + // Hovers are content widgets, and Monaco renders them inside its own DOM + // unless told otherwise. An error on line 2 has no room above it inside a + // short pane, so the hover -- the only place the marker's message is + // readable -- was drawn over the editor's top edge and clipped away by the + // container's `overflow-hidden`. Fixed positioning lets it escape to the + // viewport, which is what every editor embedded in a pane does. + editor.updateOptions({ + fixedOverflowWidgets: true, + // Monaco's defaults size the gutter for a source file: five digits of + // line number and a decorations strip nothing here draws in. A + // playground document is tens of lines, so three digits is generous and + // the strip is dead space. What is reclaimed pays for the glyph margin + // below several times over. + lineNumbersMinChars: 3, + // Enough to keep the number off the code; the default 10 exists to hold + // decorations, and folding would add 16 more for controls a document + // this short has no use for. + lineDecorationsWidth: 6, + folding: false, + }); + registerRunAction(editor, monaco, () => runRef.current()); + const model = editor.getModel(); + if (model?.uri.toString() === SOURCE_MODEL_PATH) { + sourceEditor.current = editor; + setSourceModel(model); + // The glyph margin is where the error icon goes, and it is also the only + // gutter column Monaco will show a hover over. Reserved up front rather + // than toggled with the error, so the text does not jump sideways + // mid-keystroke as evaluation succeeds and fails. + editor.updateOptions({ glyphMargin: true }); + } + }, + [applyTheme], + ); + + /** Writes a path from the object graph where the reader last left the caret. */ + const insertExpression = useCallback((expression: string) => { + const editor = sourceEditor.current; + const selection = editor?.getSelection(); + if (!editor || !selection) return; + editor.executeEdits("object-graph", [ + { range: selection, text: expression, forceMoveMarkers: true }, + ]); + editor.focus(); + }, []); + + const examples = examplesFor(state.language); + + return ( + } + navSections={navSections(state.language)} + collapsedStorageKey="gomplate-playground:rail" + sidebarFooter={} + bodyHeader={ +
+

{language.label}

+

{language.description}

+
+ } + bodyActions={ +
+ {examples.length > 0 ? ( + ({ + value: example.name, + label: example.name, + }))} + value="" + allowCustomValue={false} + placeholder="Load an example…" + ariaLabel="Load an example" + className="w-64" + onChange={(name) => { + const example = examples.find((candidate) => candidate.name === name); + if (example) setState({ source: example.source, input: example.input }); + }} + /> + ) : null} + +
+ } + bodySidebar={ + + setState({ source })} + language={language.editorLanguage} + path={SOURCE_MODEL_PATH} + height="100%" + beforeMount={registerLanguages} + onMount={onEditorMount} + /> + + } + bottom={ + + setState({ input })} + language="yaml" + path={INPUT_MODEL_PATH} + height="100%" + onMount={onEditorMount} + /> + + } + /> + } + bodySplit={52} + contentClassName="p-0" + > +
+
+ +
+ +
+ {outputTab === "result" ? ( + + ) : null} + {outputTab === "graph" ? ( + + ) : null} + {outputTab === "tokens" ? ( + + ) : null} + {outputTab === "spec" && catalogueFlavour ? ( + + ) : null} +
+
+
+ ); +} + +function Brand() { + return ( +
+ gomplate + playground +
+ ); +} + +/** + * The catalogue sizes double as a sanity check: an empty namespace list means + * the generated spec did not load. + */ +function CatalogueSummary() { + return ( +
+
{spec.cel.functions.length} CEL functions
+
{spec.gotemplate.functions.length} template functions
+
+ ); +} + +/** + * Builds the rail. Each language is an anchor to the hash that selects it, so + * the browser treats a language switch as navigation; `useUrlState` picks the + * change up from `hashchange`. + */ +function navSections(activeId: string): AppShellNavSection[] { + return SECTIONS.map((section) => ({ + label: section, + items: LANGUAGES.filter((language) => language.section === section).map((language) => { + const example = defaultExample(language.id); + return { + key: language.id, + label: language.label, + icon: language.icon, + active: language.id === activeId, + to: stateHref({ + language: language.id, + source: example.source, + input: example.input, + }), + }; + }), + })); +} + +function EditorPane({ + label, + hint, + hintTone = "muted", + children, +}: { + label: string; + hint?: string; + /** A parse failure belongs next to the text that caused it, not in a panel. */ + hintTone?: "muted" | "error"; + children: React.ReactNode; +}) { + return ( +
+
+

+ {label} +

+ {hint ? ( + + {hint} + + ) : null} +
+
+ {children} +
+
+ ); +} + +/** + * Mirrors an evaluation error onto the editor as a marker, so the position the + * Go side reported is underlined where the mistake is -- with Monaco's own + * squiggle, hover and F8 navigation -- rather than only named in the result + * panel. + * + * The squiggle alone underlines one token in a pane the reader may not be + * looking at, so the same line is also called out in the gutter -- an icon and a + * red line number -- and the icon carries the message too, so hovering either + * the gutter or the token explains the failure. + * + * `model` is passed rather than looked up because Monaco loads asynchronously: + * the first evaluation regularly finishes before the editor exists, and a + * lookup that missed would leave that error unmarked until the next run. + */ +function useMarkers(response: EvalResponse | null, model: monacoEditor.editor.ITextModel | null) { + const decorations = useRef([]); + + useEffect(() => { + if (!model || model.isDisposed()) return; + + const error = response?.error; + if (!error?.line) { + monacoEditor.editor.setModelMarkers(model, MARKER_OWNER, []); + decorations.current = model.deltaDecorations(decorations.current, []); + return; + } + + const line = Math.min(Math.max(error.line, 1), model.getLineCount()); + const column = Math.max(error.column ?? 1, 1); + monacoEditor.editor.setModelMarkers(model, MARKER_OWNER, [ + { + severity: monacoEditor.MarkerSeverity.Error, + message: error.message, + startLineNumber: line, + startColumn: column, + endLineNumber: line, + endColumn: markerEndColumn(model, line, column), + }, + ]); + decorations.current = model.deltaDecorations(decorations.current, [ + { + range: new monacoEditor.Range(line, 1, line, 1), + options: { + glyphMarginClassName: "playground-error-glyph", + // A fenced block, not bare text: the message is full of `<...>` and + // `{}`, which the hover's markdown renderer would otherwise eat. + glyphMarginHoverMessage: { value: "```\n" + error.message + "\n```" }, + lineNumberClassName: "playground-error-line-number", + // Survives edits to the line rather than growing to swallow what the + // reader types next to it. + stickiness: monacoEditor.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + }, + }, + ]); + }, [response, model]); +} + +/** + * Underlines the whole token at the reported position. A compiler points at one + * character; a one-character squiggle is easy to miss and unpleasant to hover. + * Monaco clamps a range that runs past the end of the line, so a position at + * end-of-input still underlines something. + */ +function markerEndColumn( + model: monacoEditor.editor.ITextModel, + line: number, + column: number, +): number { + const word = model.getWordAtPosition({ lineNumber: line, column }); + return Math.max(word?.endColumn ?? 0, column + 1); +} diff --git a/web/apps/playground/src/RunControls.tsx b/web/apps/playground/src/RunControls.tsx new file mode 100644 index 000000000..3dae7a6c7 --- /dev/null +++ b/web/apps/playground/src/RunControls.tsx @@ -0,0 +1,68 @@ +import { useEffect } from "react"; +import { Button, Switch } from "@flanksource/clicky-ui"; +import { UiPlay } from "@flanksource/clicky-ui/icons"; +import type { Evaluator } from "./useEvaluator"; + +/** The accelerator, spelled the way the platform spells it. */ +export const RUN_SHORTCUT_LABEL = isApple() ? "⌘⏎" : "Ctrl+↵"; + +interface RunControlsProps { + evaluator: Evaluator; +} + +export function RunControls({ evaluator }: RunControlsProps) { + useGlobalRunShortcut(evaluator.run); + + return ( +
+ Auto-run} + /> + +
+ ); +} + +/** + * Cmd/Ctrl+Enter outside the editors. + * + * Monaco swallows keystrokes while it has focus, so the editors register the + * same accelerator as a Monaco action of their own (see `runAction`). This + * covers the rest of the page. + */ +function useGlobalRunShortcut(run: () => void) { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter") return; + if (!(event.metaKey || event.ctrlKey)) return; + event.preventDefault(); + run(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [run]); +} + +function isApple(): boolean { + if (typeof navigator === "undefined") return false; + return /mac|iphone|ipad/i.test(navigator.userAgent); +} diff --git a/web/apps/playground/src/VerticalSplit.tsx b/web/apps/playground/src/VerticalSplit.tsx new file mode 100644 index 000000000..3f0dc23de --- /dev/null +++ b/web/apps/playground/src/VerticalSplit.tsx @@ -0,0 +1,161 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; + +/** Monaco's line height, as clicky-ui's MonacoEditor configures it. */ +export const EDITOR_LINE_HEIGHT = 20; + +/** Height of an EditorPane header row (text-xs on py-2). */ +const PANE_HEADER_HEIGHT = 33; + +/** Slack for Monaco's own chrome: the horizontal scrollbar and top padding. */ +const EDITOR_CHROME = 12; + +/** + * Pane height that shows `rows` lines of code without scrolling. + * + * Expressed in rows because that is how the size is actually reasoned about -- + * a CEL expression is one or two lines, a templated manifest is a screenful -- + * and it keeps the default honest if the editor's line height ever changes. + */ +export function rowsToPaneHeight(rows: number): number { + return PANE_HEADER_HEIGHT + rows * EDITOR_LINE_HEIGHT + EDITOR_CHROME; +} + +export interface VerticalSplitProps { + top: ReactNode; + bottom: ReactNode; + /** Top-pane height in pixels, used until the reader drags the divider. */ + defaultTopHeight: number; + /** Smallest the top pane may be dragged. Defaults to two rows. */ + minTop?: number; + /** Smallest the bottom pane may be dragged. */ + minBottom?: number; + /** localStorage key persisting the dragged height. */ + storageKey?: string; +} + +/** + * A vertically stacked, resizable pair of panes. + * + * clicky-ui's SplitPane only splits horizontally, and the two editors stack, so + * the divider here is its own component. Sizing is in pixels rather than a + * percentage because the useful size of the expression editor is a number of + * rows, which a percentage of a varying viewport does not express. + */ +export function VerticalSplit({ + top, + bottom, + defaultTopHeight, + minTop = rowsToPaneHeight(2), + minBottom = 96, + storageKey, +}: VerticalSplitProps) { + const container = useRef(null); + const [topHeight, setTopHeight] = useState(() => readStored(storageKey) ?? defaultTopHeight); + const [dragging, setDragging] = useState(false); + + // Only follow the language's default while the reader has not chosen a size: + // a dragged divider is a decision, and switching language should not undo it. + const hasStoredSize = useRef(readStored(storageKey) !== null); + useEffect(() => { + if (!hasStoredSize.current) setTopHeight(defaultTopHeight); + }, [defaultTopHeight]); + + const clamp = useCallback( + (height: number) => { + const available = container.current?.getBoundingClientRect().height ?? 0; + const upperBound = Math.max(minTop, available - minBottom); + return Math.round(Math.max(minTop, Math.min(upperBound, height))); + }, + [minTop, minBottom], + ); + + const commit = useCallback( + (height: number) => { + const next = clamp(height); + setTopHeight(next); + hasStoredSize.current = true; + if (storageKey) window.localStorage.setItem(storageKey, String(next)); + }, + [clamp, storageKey], + ); + + const onPointerDown = useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + const rect = container.current?.getBoundingClientRect(); + if (!rect) return; + + setDragging(true); + const onMove = (moveEvent: PointerEvent) => commit(moveEvent.clientY - rect.top); + const onUp = () => { + setDragging(false); + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + }; + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); + }, + [commit], + ); + + // A separator that can only be dragged is unusable without a mouse, and this + // one gates access to the input editor. + const onKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + const step = event.shiftKey ? EDITOR_LINE_HEIGHT * 5 : EDITOR_LINE_HEIGHT; + if (event.key === "ArrowUp") { + event.preventDefault(); + commit(topHeight - step); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + commit(topHeight + step); + } else if (event.key === "Home") { + event.preventDefault(); + commit(minTop); + } + }, + [commit, topHeight, minTop], + ); + + return ( +
+
+ {top} +
+ +
+ {/* A 6px strip is a small target; widen the grab area without moving + anything by overflowing an invisible band above and below it. */} + +
+ +
{bottom}
+
+ ); +} + +function readStored(key: string | undefined): number | null { + if (!key || typeof window === "undefined") return null; + const raw = window.localStorage.getItem(key); + if (raw === null) return null; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/web/apps/playground/src/api.ts b/web/apps/playground/src/api.ts new file mode 100644 index 000000000..16484e9a4 --- /dev/null +++ b/web/apps/playground/src/api.ts @@ -0,0 +1,89 @@ +import type { GomplateSpec } from "@flanksource/gomplate-lang"; + +import type { EvalLanguage } from "./languages"; + +export interface EvalRequest { + language: EvalLanguage; + source: string; + input?: string; + leftDelim?: string; + rightDelim?: string; +} + +export interface EvalError { + message: string; + line?: number; + column?: number; +} + +export interface EvalResponse { + result: string; + value?: unknown; + type?: string; + error?: EvalError; + durationMs: number; +} + +/** + * Evaluates against the Go server the dev server proxies to. + * + * A transport failure is surfaced as an error result rather than thrown: the + * usual cause is the eval server not running yet, and the playground should say + * so rather than blank out. + */ +export async function evaluate( + request: EvalRequest, + signal?: AbortSignal, +): Promise { + let response: Response; + try { + response = await fetch("/api/eval", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + signal, + }); + } catch (cause) { + if (signal?.aborted) throw cause; + return { + result: "", + durationMs: 0, + error: { + message: + `could not reach the eval server: ${String(cause)}\n\n` + + "Start it with `make playground-server`, or let the dev server manage it " + + "by unsetting GOMPLATE_PLAYGROUND_SERVER.", + }, + }; + } + + if (!response.ok && response.status !== 400) { + return { + result: "", + durationMs: 0, + error: { message: `eval server returned ${response.status} ${response.statusText}` }, + }; + } + return (await response.json()) as EvalResponse; +} + +/** + * Fetches the catalogue the running server can actually evaluate. + * + * The package ships gomplate's own catalogue baked in, which is right for this + * app but not for a host that registers its own functions. Reading it from the + * server instead is the path a host takes, so the playground takes it too -- + * otherwise the interesting case only ever runs somewhere else. + * + * Returns undefined on any failure: the baked catalogue stays in place, which + * is exactly right when the server is simply not up yet. + */ +export async function fetchSpec(signal?: AbortSignal): Promise { + try { + const response = await fetch("/api/spec", { signal }); + if (!response.ok) return undefined; + return (await response.json()) as GomplateSpec; + } catch { + return undefined; + } +} diff --git a/web/apps/playground/src/examples.ts b/web/apps/playground/src/examples.ts new file mode 100644 index 000000000..1203c461f --- /dev/null +++ b/web/apps/playground/src/examples.ts @@ -0,0 +1,148 @@ +export interface Example { + name: string; + source: string; + input: string; +} + +const POD_INPUT = `pod: + metadata: + name: web-7d4f + labels: + app: web + tier: frontend + spec: + containers: + - name: web + resources: + limits: + cpu: 500m + memory: 1Gi + status: + phase: Running +replicas: 3 +env: production +`; + +/** + * Starting points per language. Each one is chosen to exercise something the + * highlighter has to get right as well as something the evaluator does. + */ +export const EXAMPLES: Record = { + cel: [ + { + name: "Field access and optionals", + source: `pod.metadata.labels.app + "/" + pod.status.?phase.orValue("unknown")`, + input: POD_INPUT, + }, + { + name: "Kubernetes helpers", + source: `{ + "cpu": k8s.cpuAsMillicores(pod.spec.containers[0].resources.limits.cpu), + "memory": k8s.memoryAsBytes(pod.spec.containers[0].resources.limits.memory), +}`, + input: POD_INPUT, + }, + { + name: "Macros and collections", + source: `[1, 2, 3, 4].filter(e, e % 2 == 0).map(e, e * 10)`, + input: "", + }, + { + name: "fold", + source: `["a", "b", "c"].fold(e, acc, acc + e)`, + input: "", + }, + { + name: "String literal forms", + source: `[ + "plain", + """triple "quoted" """, + r"raw\\dstring", + "unicode \\U0001F600", +]`, + input: "", + }, + { + name: "Strings and time", + source: `"hello world".upperAscii() + " @ " + string(time.Now().getFullYear())`, + input: "", + }, + ], + // Keyed by the playground language id, not the evaluator name: the go + // template language is `gomplate` here and `gotemplate` on the wire. + gomplate: [ + { + name: "Pipelines", + source: `{{ .pod.metadata.name | strings.ToUpper }}`, + input: POD_INPUT, + }, + { + name: "Control flow and variables", + source: `{{- $name := .pod.metadata.name -}} +{{ if eq .env "production" }}PROD: {{ $name }}{{ else }}dev: {{ $name }}{{ end }}`, + input: POD_INPUT, + }, + { + name: "Collections", + source: `{{ coll.Dict "app" .pod.metadata.labels.app "replicas" .replicas | toJSON }}`, + input: POD_INPUT, + }, + { + name: "Comments and trim markers", + source: `{{/* not rendered */}} +{{- range $i, $v := coll.Slice "a" "b" "c" }} +{{ $i }}={{ $v }} +{{- end }}`, + input: "", + }, + ], + "yaml-gomplate": [ + { + name: "Templated manifest", + source: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .pod.metadata.labels.app }}-deployment" + labels: + app: {{ .pod.metadata.labels.app }} +spec: + replicas: {{ .replicas }} + template: + spec: + containers: + - name: {{ .pod.metadata.labels.app }} + image: "registry.example.com/{{ .pod.metadata.labels.app }}:latest" +`, + input: POD_INPUT, + }, + ], + "json-gomplate": [ + { + name: "Templated JSON", + source: `{ + "app": "{{ .pod.metadata.labels.app }}", + "replicas": {{ .replicas }}, + "env": "{{ .env }}" +}`, + input: POD_INPUT, + }, + ], + jsonpath: [ + { name: "Field path", source: `$.pod.metadata.name`, input: POD_INPUT }, + { name: "Recursive descent", source: `$..name`, input: POD_INPUT }, + { name: "Filter", source: `$.pod.spec.containers[?(@.name == "web")]`, input: POD_INPUT }, + ], + javascript: [ + { name: "Arithmetic", source: `replicas * 2`, input: POD_INPUT }, + { name: "Object access", source: `pod.metadata.labels.app`, input: POD_INPUT }, + ], +}; + +export function examplesFor(languageId: string): Example[] { + return EXAMPLES[languageId] ?? []; +} + +export function defaultExample(languageId: string): Example { + const [first] = examplesFor(languageId); + return first ?? { name: "Empty", source: "", input: "" }; +} diff --git a/web/apps/playground/src/functionCatalogue.test.ts b/web/apps/playground/src/functionCatalogue.test.ts new file mode 100644 index 000000000..484ac8393 --- /dev/null +++ b/web/apps/playground/src/functionCatalogue.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { mergeSpec, spec } from "@flanksource/gomplate-lang"; + +import { functionCatalogue, functionCatalogueFlavour } from "./functionCatalogue"; + +describe("playground function catalogues", () => { + it.each(["jsonpath", "javascript"] as const)( + "does not claim Go-template functions are available in %s", + (language) => { + expect(functionCatalogueFlavour(language)).toBeNull(); + expect(functionCatalogue(language, spec)).toEqual([]); + }, + ); + + it.each(["cel", "gotemplate"] as const)("uses the generated %s catalogue", (language) => { + expect(functionCatalogueFlavour(language)).toBe(language); + expect(functionCatalogue(language, spec).length).toBeGreaterThan(100); + }); + + it("browses a host's own functions, not only the ones baked in", () => { + // The tab count is the quickest signal that the server's spec arrived, so + // it has to read the merged catalogue rather than the packaged one. + const served = mergeSpec(spec, { + ...spec, + cel: { + ...spec.cel, + functions: [...spec.cel.functions, { name: "catalog.query", namespace: "catalog" }], + }, + }); + const names = functionCatalogue("cel", served).map((fn) => fn.name); + expect(names).toContain("catalog.query"); + expect(names.length).toBe(functionCatalogue("cel", spec).length + 1); + }); +}); diff --git a/web/apps/playground/src/functionCatalogue.ts b/web/apps/playground/src/functionCatalogue.ts new file mode 100644 index 000000000..9b39940c0 --- /dev/null +++ b/web/apps/playground/src/functionCatalogue.ts @@ -0,0 +1,25 @@ +import type { GomplateSpec, SpecFunction } from "@flanksource/gomplate-lang"; + +import type { EvalLanguage } from "./languages"; + +export type FunctionCatalogueFlavour = "cel" | "gotemplate"; + +export function functionCatalogueFlavour( + language: EvalLanguage, +): FunctionCatalogueFlavour | null { + if (language === "cel" || language === "gotemplate") return language; + return null; +} + +/** + * The functions to browse for a language. + * + * Takes the catalogue rather than reading the baked one, so a host binary's own + * functions show up in the browser as well as in completion — the tab count is + * the quickest way to see whether the server's spec actually arrived. + */ +export function functionCatalogue(language: EvalLanguage, spec: GomplateSpec): SpecFunction[] { + const flavour = functionCatalogueFlavour(language); + if (!flavour) return []; + return spec[flavour].functions; +} diff --git a/web/apps/playground/src/hashRouter.ts b/web/apps/playground/src/hashRouter.ts new file mode 100644 index 000000000..193da8000 --- /dev/null +++ b/web/apps/playground/src/hashRouter.ts @@ -0,0 +1,46 @@ +import { createElement, useCallback, useSyncExternalStore } from "react"; +import type { RouterAdapter, RenderLinkArgs } from "@flanksource/clicky-ui"; + +/** + * A RouterAdapter whose location is the URL hash. + * + * clicky-ui's default adapter intercepts a plain left-click on a nav link and + * navigates via `history.pushState`. That is right for a path-routed app, but + * `pushState` does not fire `hashchange`, so a hash-routed playground would see + * the URL change and never hear about it -- the rail would highlight while the + * page kept showing the previous language. + * + * Letting the browser handle the anchor natively fixes that and costs nothing: + * the whole state already lives in the hash, so a link is genuinely a link -- + * middle-clickable, copyable and reachable by Back. + */ +export function useHashRouter(): RouterAdapter { + const pathname = useSyncExternalStore( + subscribeToHash, + () => window.location.hash, + () => "", + ); + + const navigate = useCallback((to: string, opts?: { replace?: boolean }) => { + const hash = to.startsWith("#") ? to : `#${to}`; + if (opts?.replace) { + window.history.replaceState(null, "", hash); + // replaceState is silent, so tell the subscribers ourselves. + window.dispatchEvent(new HashChangeEvent("hashchange")); + return; + } + window.location.hash = hash; + }, []); + + return { pathname, navigate, renderLink: anchorLink }; +} + +function subscribeToHash(onChange: () => void): () => void { + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); +} + +/** A plain anchor: no interception, so the browser fires `hashchange`. */ +function anchorLink({ to, className, children, title }: RenderLinkArgs) { + return createElement("a", { href: to, className, title }, children); +} diff --git a/web/apps/playground/src/languages.ts b/web/apps/playground/src/languages.ts new file mode 100644 index 000000000..a3c37ea10 --- /dev/null +++ b/web/apps/playground/src/languages.ts @@ -0,0 +1,113 @@ +import type { LanguageId } from "@flanksource/gomplate-lang"; +import { + UiBraces, + UiCode2, + UiFileCode, + UiFileJson, + UiFileText, + UiFunction, +} from "@flanksource/clicky-ui/icons"; +import type { StaticIconComponent } from "@flanksource/clicky-ui"; + +/** An evaluator on the Go side. */ +export type EvalLanguage = "cel" | "gotemplate" | "jsonpath" | "javascript"; + +/** One entry in the playground's language rail. */ +export interface PlaygroundLanguage { + id: string; + label: string; + icon: StaticIconComponent; + /** Rail section this language belongs to. */ + section: "Expressions" | "Templates"; + /** Monaco language for the source editor. */ + editorLanguage: LanguageId | "javascript"; + /** Which evaluator the Go server should run. */ + evalLanguage: EvalLanguage; + description: string; + /** + * Rows the expression editor opens at, before the reader drags the divider. + * + * An expression is a line or two, so five rows leaves the input document the + * rest of the pane. A template is a whole document, and starting it at five + * rows would hide most of what is being written. + */ + editorRows: number; +} + +/** Default rows for a language whose source is an expression. */ +const EXPRESSION_ROWS = 5; + +/** Default rows for a language whose source is a document. */ +const TEMPLATE_ROWS = 14; + +export const LANGUAGES: PlaygroundLanguage[] = [ + { + id: "cel", + label: "CEL", + icon: UiFunction, + section: "Expressions", + editorLanguage: "cel", + evalLanguage: "cel", + description: "Common Expression Language, with gomplate's k8s, aws and math helpers", + editorRows: EXPRESSION_ROWS, + }, + { + id: "jsonpath", + label: "JSONPath", + icon: UiBraces, + section: "Expressions", + editorLanguage: "jsonpath", + evalLanguage: "jsonpath", + description: "JSONPath, as evaluated by ojg", + editorRows: EXPRESSION_ROWS, + }, + { + id: "javascript", + label: "JavaScript", + icon: UiCode2, + section: "Expressions", + editorLanguage: "javascript", + evalLanguage: "javascript", + description: "JavaScript, as evaluated by otto", + editorRows: EXPRESSION_ROWS, + }, + { + id: "gomplate", + label: "Go template", + icon: UiFileCode, + section: "Templates", + editorLanguage: "gomplate", + evalLanguage: "gotemplate", + description: "Go text/template with gomplate's function library", + editorRows: TEMPLATE_ROWS, + }, + { + id: "yaml-gomplate", + label: "YAML + template", + icon: UiFileText, + section: "Templates", + editorLanguage: "yaml-gomplate", + evalLanguage: "gotemplate", + description: "YAML with templates embedded in it, as real configuration is written", + editorRows: TEMPLATE_ROWS, + }, + { + id: "json-gomplate", + label: "JSON + template", + icon: UiFileJson, + section: "Templates", + editorLanguage: "json-gomplate", + evalLanguage: "gotemplate", + description: "JSON with templates embedded in it", + editorRows: TEMPLATE_ROWS, + }, +]; + +/** Rail section order. */ +export const SECTIONS = ["Expressions", "Templates"] as const; + +export function languageById(id: string): PlaygroundLanguage { + const found = LANGUAGES.find((language) => language.id === id); + if (!found) throw new Error(`unknown playground language "${id}"`); + return found; +} diff --git a/web/apps/playground/src/main.tsx b/web/apps/playground/src/main.tsx new file mode 100644 index 000000000..91c2f85bc --- /dev/null +++ b/web/apps/playground/src/main.tsx @@ -0,0 +1,14 @@ +import { createRoot } from "react-dom/client"; +import { setFallbackIconProvider } from "@flanksource/clicky-ui"; +import { clickyIconProvider } from "@flanksource/clicky-ui/icons"; +import { App } from "./App"; +import "@flanksource/clicky-ui/styles.css"; +import "./styles.css"; + +// Icons referenced by name in schema-driven surfaces are runtime strings, which +// no import can resolve; registering the provider turns them into glyphs. +setFallbackIconProvider(clickyIconProvider()); + +const root = document.getElementById("app"); +if (!root) throw new Error("#app root not found"); +createRoot(root).render(); diff --git a/web/apps/playground/src/monaco-setup.ts b/web/apps/playground/src/monaco-setup.ts new file mode 100644 index 000000000..f7af4a9f6 --- /dev/null +++ b/web/apps/playground/src/monaco-setup.ts @@ -0,0 +1,18 @@ +import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; +import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; +import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; + +/** + * Worker factory for clicky-ui's `MonacoProvider`; Vite bundles each `?worker` + * import. + * + * gomplate's own languages need no worker -- Monarch tokenizes on the main + * thread and completion is served from the generated spec. The JSON worker + * backs the input editor, and the TypeScript worker backs the JavaScript + * language, which is Monaco's own rather than one this package generates. + */ +export function getMonacoWorker(label: string): Worker { + if (label === "typescript" || label === "javascript") return new TsWorker(); + if (label === "json") return new JsonWorker(); + return new EditorWorker(); +} diff --git a/web/apps/playground/src/panels/GraphPanel.tsx b/web/apps/playground/src/panels/GraphPanel.tsx new file mode 100644 index 000000000..272a2868b --- /dev/null +++ b/web/apps/playground/src/panels/GraphPanel.tsx @@ -0,0 +1,95 @@ +import { useMemo, useState } from "react"; +import { ObjectGraph } from "@flanksource/clicky-ui/data"; +import { createLazyJSONPathTree, literalSegments } from "@flanksource/clicky-ui/components"; +import type { JSONPathNode, LazyJSONPathTree } from "@flanksource/clicky-ui/components"; +import { pathExpression } from "@flanksource/gomplate-lang"; +import { toGraphNode } from "./graphNodes"; +import type { GraphNode } from "./graphNodes"; + +interface GraphPanelProps { + /** The parsed input document, or undefined when there is nothing to show. */ + document: unknown; + /** Monaco language id, which decides the syntax a click inserts. */ + languageId: string; + /** Writes an expression at the source editor's cursor. */ + onInsert: (expression: string) => void; +} + +/** + * The shape of the document being evaluated against. + * + * The input pane shows the document as text; this shows it as paths. Clicking a + * row writes that path into the expression, in the syntax of the language being + * written, which is the step that otherwise means reading YAML and retyping it + * by hand. + */ +export function GraphPanel({ document, languageId, onInsert }: GraphPanelProps) { + const [selectedId, setSelectedId] = useState(); + const [note, setNote] = useState(); + + const tree = useMemo( + () => + document === undefined || document === null + ? null + : createLazyJSONPathTree(document, { keyPrefix: "input" }), + [document], + ); + + const roots = useMemo(() => (tree ? tree.roots.map(toGraphNode) : []), [tree]); + + if (!tree) { + return ( +
+ Nothing to show yet — write a YAML or JSON document in the input pane and its shape + appears here. +
+ ); + } + + const select = (node: GraphNode) => { + setSelectedId(node.id); + const segments = node.path ? literalSegments(node.path) : undefined; + if (!segments) { + setNote("That row has no addressable path."); + return; + } + const expression = pathExpression(languageId, segments); + if (expression === null) { + setNote( + "A go template reaches a list element or a non-identifier key through `index`, " + + "which is a call rather than a path — so there is nothing to insert.", + ); + return; + } + if (expression === "") { + setNote("The whole document has no name in this language — pick a key under it."); + return; + } + setNote(undefined); + onInsert(expression); + }; + + return ( +
+
+ { + const source = node.metadata?.node as JSONPathNode | undefined; + if (!source) return []; + return (await tree.loadChildren(source)).map(toGraphNode); + }} + empty="The document is empty." + /> +
+
+ {note ?? "Click a key to insert its path at the cursor."} +
+
+ ); +} + diff --git a/web/apps/playground/src/panels/ResultPanel.tsx b/web/apps/playground/src/panels/ResultPanel.tsx new file mode 100644 index 000000000..de44e1e08 --- /dev/null +++ b/web/apps/playground/src/panels/ResultPanel.tsx @@ -0,0 +1,107 @@ +import { Badge, Button, CodeBlock, JsonView } from "@flanksource/clicky-ui"; +import type { EvalResponse } from "../api"; +import { RUN_SHORTCUT_LABEL } from "../RunControls"; + +interface ResultPanelProps { + response: EvalResponse | null; + pending: boolean; + /** The shown result predates the current source or input. */ + stale: boolean; + onRun: () => void; +} + +export function ResultPanel({ response, pending, stale, onRun }: ResultPanelProps) { + if (!response) { + return ( +
+

+ {pending ? "Evaluating…" : "Type an expression, then run it."} +

+ {stale && !pending ? ( + + ) : null} +
+ ); + } + + if (response.error) { + return ( +
+
+

+ {response.error.line + ? `Error at line ${response.error.line}, column ${response.error.column}` + : "Error"} +

+ +
+
+ ); + } + + return ( +
+
+ {response.durationMs.toFixed(2)} ms + {response.type ? {response.type} : null} + {/* Without this, a result that no longer matches what is on screen is + indistinguishable from one that does. */} + {stale ? ( + + ) : null} +
+ +
+ {/* The rendered string is what a gomplate caller receives. A structured + result is more readable as a tree, so hand those to JsonView and + keep the raw rendering below it. */} + {isStructured(response.value) ? ( + <> + +
+ + Rendered string + +
+ +
+
+ + ) : ( + + )} +
+
+ ); +} + +function isStructured(value: unknown): boolean { + return typeof value === "object" && value !== null; +} + +/** + * Templates render whole documents, so highlight the output when it is + * recognisably YAML or JSON rather than showing a wall of grey. + */ +function languageOf(result: string): string | undefined { + const trimmed = result.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json"; + if (/^[A-Za-z_][\w.-]*:\s/m.test(trimmed)) return "yaml"; + return undefined; +} diff --git a/web/apps/playground/src/panels/SpecPanel.tsx b/web/apps/playground/src/panels/SpecPanel.tsx new file mode 100644 index 000000000..87906937b --- /dev/null +++ b/web/apps/playground/src/panels/SpecPanel.tsx @@ -0,0 +1,109 @@ +import { useMemo } from "react"; +import { Badge, DataTable } from "@flanksource/clicky-ui"; +import type { DataTableColumn } from "@flanksource/clicky-ui"; +import type { GomplateSpec, SpecFunction } from "@flanksource/gomplate-lang"; + +interface SpecPanelProps { + /** Which catalogue to browse. */ + flavour: "cel" | "gotemplate"; + /** The catalogue itself — the server's when it has one, gomplate's otherwise. */ + spec: GomplateSpec; +} + +interface FunctionRow extends Record { + name: string; + namespace: string; + signature: string; + kind: string; + doc: string; +} + +/** + * Browses the generated function catalogue. + * + * This is the first accurate reference that exists for this fork: CEL.md + * documents functions that live in `duty` and omits whole namespaces, while + * docs-src carries the upstream list. What is shown here is read out of the + * live registries, so it is what the evaluator will actually accept. + */ +export function SpecPanel({ flavour, spec }: SpecPanelProps) { + const rows = useMemo(() => { + const functions = flavour === "cel" ? spec.cel.functions : spec.gotemplate.functions; + return functions.map(toRow); + }, [flavour, spec]); + + const columns: DataTableColumn[] = [ + { + key: "name", + label: "Name", + sortable: true, + grow: true, + cellClassName: "font-mono", + }, + { + key: "namespace", + label: "Namespace", + sortable: true, + filterable: true, + shrink: true, + render: (value) => + value ? String(value) : , + }, + { + key: "kind", + label: "Call", + filterable: true, + shrink: true, + // `x.sum()` is legal where a bare `sum(x)` is not, and nothing else in + // the catalogue records that -- so it is worth a column of its own. + render: (value) => + value === "member" ? ( + member only + ) : ( + global + ), + }, + { + key: "signature", + label: "Signature", + grow: true, + cellClassName: "font-mono text-muted-foreground", + }, + { key: "doc", label: "Description", grow: true }, + ]; + + return ( + + ); +} + +function toRow(fn: SpecFunction): FunctionRow { + return { + name: fn.name, + namespace: fn.namespace ?? "", + signature: signatureOf(fn), + kind: fn.memberOnly ? "member" : "global", + doc: fn.doc ?? "", + }; +} + +function signatureOf(fn: SpecFunction): string { + if (fn.signature) return fn.signature; + + const [overload] = fn.overloads ?? []; + if (!overload) return ""; + + // A member overload carries its receiver as the first argument; an author + // writes it before the dot, not inside the parentheses. + const args = overload.member ? overload.args.slice(1) : overload.args; + return `(${args.join(", ")}) -> ${overload.result}`; +} diff --git a/web/apps/playground/src/panels/TokensPanel.tsx b/web/apps/playground/src/panels/TokensPanel.tsx new file mode 100644 index 000000000..308a4ab85 --- /dev/null +++ b/web/apps/playground/src/panels/TokensPanel.tsx @@ -0,0 +1,82 @@ +import { useMemo } from "react"; +import { DataTable } from "@flanksource/clicky-ui"; +import type { DataTableColumn } from "@flanksource/clicky-ui"; +import * as monaco from "monaco-editor"; + +interface TokensPanelProps { + source: string; + languageId: string; +} + +interface TokenRow extends Record { + line: number; + text: string; + token: string; +} + +/** + * Shows the token stream Monarch produces. + * + * This is the panel that makes a highlighting bug legible: colours tell you + * something is off, the token stream tells you which rule matched. It is also + * the fastest way to check a newly registered function is classified as + * `function` rather than falling through to `identifier`. + */ +export function TokensPanel({ source, languageId }: TokensPanelProps) { + const rows = useMemo(() => tokenize(source, languageId), [source, languageId]); + + const columns: DataTableColumn[] = [ + { key: "line", label: "Line", align: "right", shrink: true, sortable: true }, + { + key: "text", + label: "Text", + grow: true, + cellClassName: "font-mono whitespace-pre", + }, + { + key: "token", + label: "Token", + sortable: true, + filterable: true, + cellClassName: "font-mono", + // An `identifier` is the fallback every unmatched word lands on, so it is + // the one class worth de-emphasising: what stands out is what matched. + render: (value) => ( + + {String(value)} + + ), + }, + ]; + + return ( + + ); +} + +function tokenize(source: string, languageId: string): TokenRow[] { + if (!source.trim()) return []; + + const lines = monaco.editor.tokenize(source, languageId); + const rows: TokenRow[] = []; + + source.split(/\r\n|\r|\n/).forEach((line, index) => { + const tokens = lines[index] ?? []; + tokens.forEach((token, i) => { + // Monaco reports a start offset only; each token runs to the next start. + const end = i + 1 < tokens.length ? tokens[i + 1]!.offset : line.length; + const text = line.slice(token.offset, end); + if (text.trim() === "") return; + rows.push({ line: index + 1, text, token: token.type }); + }); + }); + return rows; +} diff --git a/web/apps/playground/src/panels/graphNodes.ts b/web/apps/playground/src/panels/graphNodes.ts new file mode 100644 index 000000000..24ffcc9de --- /dev/null +++ b/web/apps/playground/src/panels/graphNodes.ts @@ -0,0 +1,51 @@ +import type { ObjectGraphNode } from "@flanksource/clicky-ui/data"; +import { literalSegments } from "@flanksource/clicky-ui/components"; +import type { JSONPathNode } from "@flanksource/clicky-ui/components"; +import { kindOf } from "@flanksource/gomplate-lang"; + +/** An `ObjectGraphNode` that keeps the tree node it came from, for lazy loading. */ +export interface GraphNode extends ObjectGraphNode { + metadata?: { node: JSONPathNode }; +} + +/** + * Maps one lazy-tree node to a graph row. + * + * Two of the tree's fields do not mean what their names suggest here. `key` is a + * namespaced identity, not a display name, so the label comes off the end of the + * path instead. And `kind` is structural — object, array, scalar — so a scalar's + * own type is read from the value: `string` against `number` is the distinction + * that decides whether an expression needs quotes. + */ +export function toGraphNode(node: JSONPathNode): GraphNode { + const container = node.kind === "object" || node.kind === "array"; + const scalar = node.kind === "scalar"; + return { + id: node.key, + label: labelOf(node), + path: node.path, + kind: node.kind, + type: scalar || container ? kindOf(node.value) : undefined, + value: scalar ? scalarValue(node.value) : undefined, + raw: scalar ? undefined : node.summary, + expandable: container && node.childCount > 0, + metadata: { node }, + }; +} + +/** The last segment of the node's path — the root has none, and is `$`. */ +function labelOf(node: JSONPathNode): string { + if (node.kind === "more") return "…"; + const segments = literalSegments(node.path); + const last = segments?.[segments.length - 1]; + return last === undefined ? node.path : String(last); +} + +function scalarValue(value: unknown): string | number | boolean | null { + if (value === null || value === undefined) return null; + const type = typeof value; + if (type === "string" || type === "number" || type === "boolean") { + return value as string | number | boolean; + } + return String(value); +} diff --git a/web/apps/playground/src/runAction.ts b/web/apps/playground/src/runAction.ts new file mode 100644 index 000000000..2efc92488 --- /dev/null +++ b/web/apps/playground/src/runAction.ts @@ -0,0 +1,26 @@ +import type * as monacoEditor from "monaco-editor"; +import type { Monaco } from "@flanksource/clicky-ui/monaco"; + +/** + * Registers Cmd/Ctrl+Enter inside a Monaco editor. + * + * A window-level listener is not enough: Monaco captures keystrokes while it + * has focus, which is exactly where the reader is when they want to run. The + * action also puts "Run expression" in the editor's command palette, so the + * accelerator is discoverable rather than folklore. + * + * `run` is read through a ref by the caller, because the action is registered + * once per editor and must not capture the first render's closure. + */ +export function registerRunAction( + editor: monacoEditor.editor.IStandaloneCodeEditor, + monaco: Monaco, + run: () => void, +): monacoEditor.IDisposable { + return editor.addAction({ + id: "gomplate.run", + label: "Run expression", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter], + run, + }); +} diff --git a/web/apps/playground/src/styles.css b/web/apps/playground/src/styles.css new file mode 100644 index 000000000..787f1ba21 --- /dev/null +++ b/web/apps/playground/src/styles.css @@ -0,0 +1,30 @@ +@import "tailwindcss"; + +@source "../src/**/*.{ts,tsx}"; +@source "../node_modules/@flanksource/clicky-ui/dist/**/*.js"; + +/* AppShell is full-height chrome: it sizes its rail, body split and scroll + regions from its container, so the container has to be the viewport. */ +html, +body, +#app { + height: 100%; +} + +/* The line an evaluation failed on, called out in the gutter as well as under + the offending token. The glyph margin is reserved for the whole session, so + nothing shifts when an error appears -- which matters in an editor that + re-evaluates on every keystroke. */ +.monaco-editor .playground-error-glyph { + background-color: var(--destructive); + /* A filled circle around an exclamation mark, as a mask so the token colours + it and it follows the theme. */ + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1a7 7 0 100 14A7 7 0 008 1zm0 3.4a.85.85 0 01.85.9l-.2 3.6a.65.65 0 01-1.3 0l-.2-3.6A.85.85 0 018 4.4zm0 6a.95.95 0 110 1.9.95.95 0 010-1.9z'/%3E%3C/svg%3E") + center / 13px 13px no-repeat; + cursor: pointer; +} + +.monaco-editor .playground-error-line-number { + color: var(--destructive); + font-weight: 600; +} diff --git a/web/apps/playground/src/useEditorTheme.ts b/web/apps/playground/src/useEditorTheme.ts new file mode 100644 index 000000000..9f49dbbbc --- /dev/null +++ b/web/apps/playground/src/useEditorTheme.ts @@ -0,0 +1,39 @@ +import { useCallback, useEffect } from "react"; +import * as monaco from "monaco-editor"; +import { GOMPLATE_DARK_THEME, GOMPLATE_LIGHT_THEME } from "@flanksource/gomplate-lang"; + +/** + * Applies the gomplate colour themes and keeps them in step with clicky-ui's + * theme switcher. + * + * `monaco.editor.setTheme` is global rather than per-editor, and + * `@monaco-editor/react` calls it whenever an editor mounts, using the `theme` + * prop clicky-ui hardcodes to `light`/`vs-dark`. So setting the theme once is + * not enough: every editor that mounts afterwards resets it. Re-asserting from + * each editor's `onMount` runs after that call and is deterministic, where a + * plain effect would race the editor's lazy mount. + * + * clicky-ui's `MonacoEditor` gaining a `theme` prop makes this unnecessary; + * until then this keeps the playground correct against the published package. + */ +export function useEditorTheme() { + const applyTheme = useCallback(() => { + const dark = document.documentElement.getAttribute("data-theme") === "dark"; + monaco.editor.setTheme(dark ? GOMPLATE_DARK_THEME : GOMPLATE_LIGHT_THEME); + }, []); + + useEffect(() => { + applyTheme(); + + // clicky-ui's ThemeProvider writes `data-theme` on and emits no + // event, so observe the attribute. + const observer = new MutationObserver(applyTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, [applyTheme]); + + return applyTheme; +} diff --git a/web/apps/playground/src/useEvaluator.ts b/web/apps/playground/src/useEvaluator.ts new file mode 100644 index 000000000..39cf1ee44 --- /dev/null +++ b/web/apps/playground/src/useEvaluator.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { evaluate, type EvalRequest, type EvalResponse } from "./api"; + +const AUTO_RUN_STORAGE_KEY = "gomplate-playground:auto-run"; + +/** How long typing settles before an automatic run fires. */ +const DEBOUNCE_MS = 250; + +export interface Evaluator { + response: EvalResponse | null; + /** A request is in flight or a debounce is pending. */ + pending: boolean; + /** The source or input has changed since the shown result was produced. */ + stale: boolean; + autoRun: boolean; + setAutoRun: (next: boolean) => void; + /** Evaluates now, skipping the debounce. */ + run: () => void; +} + +type Payload = Pick; + +/** + * Runs an expression against the Go evaluator. + * + * Automatic evaluation is convenient for a one-line expression and a nuisance + * for anything longer: a debounce fires mid-keystroke and reports errors for + * half-written input. So it is a toggle, and an explicit run is always + * available -- which is also the only way to re-run an expression whose value + * changes on its own (`time.Now()`, `uuid.V4()`, `random.*`). + */ +export function useEvaluator(payload: Payload): Evaluator { + const [response, setResponse] = useState(null); + const [pending, setPending] = useState(false); + const [autoRun, setAutoRunState] = useState(readAutoRun); + const [evaluated, setEvaluated] = useState(null); + + const abortRef = useRef(undefined); + // The current payload, so `run` stays referentially stable: it is wired into + // a Monaco action registered once at mount, which would otherwise capture the + // payload from the first render forever. + const payloadRef = useRef(payload); + payloadRef.current = payload; + + const evaluateNow = useCallback(() => { + const current = payloadRef.current; + if (!current.source.trim()) { + abortRef.current?.abort(); + setResponse(null); + setEvaluated(current); + setPending(false); + return; + } + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setPending(true); + evaluate(current, controller.signal) + .then((next) => { + setResponse(next); + setEvaluated(current); + setPending(false); + }) + .catch(() => { + // Superseded by a newer run; that one owns the state. + }); + }, []); + + useEffect(() => { + if (!autoRun) return; + const timer = setTimeout(evaluateNow, DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [payload.language, payload.source, payload.input, autoRun, evaluateNow]); + + // Switching language changes what the source even means, so show nothing + // rather than the previous language's result. + useEffect(() => { + setResponse(null); + setEvaluated(null); + }, [payload.language]); + + const setAutoRun = useCallback( + (next: boolean) => { + setAutoRunState(next); + window.localStorage.setItem(AUTO_RUN_STORAGE_KEY, String(next)); + if (next) evaluateNow(); + }, + [evaluateNow], + ); + + return { + response, + pending, + stale: isStale(evaluated, payload), + autoRun, + setAutoRun, + run: evaluateNow, + }; +} + +function isStale(evaluated: Payload | null, current: Payload): boolean { + if (!current.source.trim()) return false; + if (!evaluated) return true; + return ( + evaluated.source !== current.source || + evaluated.input !== current.input || + evaluated.language !== current.language + ); +} + +function readAutoRun(): boolean { + if (typeof window === "undefined") return true; + // Default on: the playground should evaluate as soon as it opens. + return window.localStorage.getItem(AUTO_RUN_STORAGE_KEY) !== "false"; +} diff --git a/web/apps/playground/src/useParsedInput.ts b/web/apps/playground/src/useParsedInput.ts new file mode 100644 index 000000000..53818787f --- /dev/null +++ b/web/apps/playground/src/useParsedInput.ts @@ -0,0 +1,39 @@ +import { useMemo, useRef } from "react"; +import { parse } from "yaml"; + +export interface ParsedInput { + /** The last document that parsed, so editing one does not blank the other. */ + value: unknown; + /** The parse failure for the text as it stands, if there is one. */ + error: string | null; +} + +/** + * Parses the input pane on the client. + * + * The eval response carries only the result — it never echoes the environment + * back — and completion has to work while the document is still being typed, + * before any round trip. JSON is valid YAML, so one parser covers both, the same + * way the Go side's `parseInput` does. + * + * A half-typed document is a parse error on most keystrokes. Holding the last + * good value through those keeps the completion list and the shape tree from + * flickering out from under the reader. + */ +export function useParsedInput(input: string): ParsedInput { + const lastGood = useRef(undefined); + + return useMemo(() => { + if (input.trim() === "") { + lastGood.current = undefined; + return { value: undefined, error: null }; + } + try { + const value: unknown = parse(input); + lastGood.current = value; + return { value, error: null }; + } catch (error) { + return { value: lastGood.current, error: (error as Error).message }; + } + }, [input]); +} diff --git a/web/apps/playground/src/useUrlState.ts b/web/apps/playground/src/useUrlState.ts new file mode 100644 index 000000000..f6ad5a08e --- /dev/null +++ b/web/apps/playground/src/useUrlState.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useState } from "react"; + +export interface PlaygroundState { + language: string; + source: string; + input: string; +} + +/** + * Keeps the whole playground state in the URL hash so a snippet is shareable by + * copying the address bar -- the thing anyone actually wants from a playground. + * + * Base64 rather than percent-encoding: expressions are full of `%`, `#` and + * `&`, and a hash full of escapes is unreadable and easy to truncate by hand. + */ +export function useUrlState(initial: PlaygroundState) { + const [state, setState] = useState(() => decode(window.location.hash) ?? initial); + + useEffect(() => { + const onHashChange = () => { + const decoded = decode(window.location.hash); + if (decoded) setState(decoded); + }; + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + + const update = useCallback((patch: Partial) => { + setState((previous) => { + const next = { ...previous, ...patch }; + // replaceState, not a hash assignment: editing must not push a history + // entry per keystroke. + window.history.replaceState(null, "", `#${encode(next)}`); + return next; + }); + }, []); + + return [state, update] as const; +} + +/** + * The hash a link should point at to open the playground in a given state. + * + * The language rail is built from these, so switching language is a real + * anchor -- middle-clickable, copyable, and reachable by Back -- rather than a + * button that mutates state behind the URL's back. + */ +export function stateHref(state: PlaygroundState): string { + return `#${encode(state)}`; +} + +function encode(state: PlaygroundState): string { + const json = JSON.stringify(state); + // btoa is latin1-only; percent-encode first so non-ASCII survives. + return btoa(unescape(encodeURIComponent(json))); +} + +function decode(hash: string): PlaygroundState | null { + const raw = hash.replace(/^#/, ""); + if (!raw) return null; + try { + const parsed = JSON.parse(decodeURIComponent(escape(atob(raw)))) as Partial; + if (typeof parsed.language !== "string" || typeof parsed.source !== "string") return null; + return { language: parsed.language, source: parsed.source, input: parsed.input ?? "" }; + } catch { + // A hand-edited or truncated link should drop back to the default state + // rather than break the page. + return null; + } +} diff --git a/web/apps/playground/test/examples.test.ts b/web/apps/playground/test/examples.test.ts new file mode 100644 index 000000000..92ed2ba86 --- /dev/null +++ b/web/apps/playground/test/examples.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { defaultExample, examplesFor } from "../src/examples"; +import { LANGUAGES } from "../src/languages"; + +describe("examples", () => { + // They are keyed by the playground language id, which is not always the + // evaluator name — the go template language is `gomplate` here and + // `gotemplate` on the wire. Getting that wrong silently opens the editor + // empty, because the lookup falls through to a blank fallback. + it.each(LANGUAGES.map((language) => [language.id]))("has an example for %s", (id) => { + expect(examplesFor(id).length).toBeGreaterThan(0); + expect(defaultExample(id).source).not.toBe(""); + }); + + it("gives every example an input to evaluate against", () => { + for (const language of LANGUAGES) { + for (const example of examplesFor(language.id)) { + expect(example.name, `${language.id}: ${example.name}`).not.toBe(""); + } + } + }); +}); diff --git a/web/apps/playground/test/graphNodes.test.ts b/web/apps/playground/test/graphNodes.test.ts new file mode 100644 index 000000000..5f028d7ea --- /dev/null +++ b/web/apps/playground/test/graphNodes.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { createLazyJSONPathTree } from "@flanksource/clicky-ui/components"; +import { toGraphNode } from "../src/panels/graphNodes"; + +const DOCUMENT = { + pod: { metadata: { name: "web-7d4f" } }, + replicas: 3, + ready: true, + note: null, + tags: ["a", "b"], +}; + +const tree = createLazyJSONPathTree(DOCUMENT, { keyPrefix: "test" }); +const root = tree.roots[0]!; + +async function childrenOf(node: { metadata?: { node: Parameters[0] } }) { + return (await tree.loadChildren(node.metadata!.node)).map(toGraphNode); +} + +describe("mapping a document node onto a graph row", () => { + it("labels the root with the path root rather than its internal key", () => { + // `key` namespaces the node for caching (`test$`); it is not a display name. + expect(toGraphNode(root).label).toBe("$"); + expect(toGraphNode(root).id).toBe("test$"); + }); + + it("labels a child with the last segment of its path", async () => { + const children = await childrenOf(toGraphNode(root)); + expect(children.map((child) => child.label)).toEqual([ + "pod", + "replicas", + "ready", + "note", + "tags", + ]); + expect(children.map((child) => child.path)).toEqual([ + "$.pod", + "$.replicas", + "$.ready", + "$.note", + "$.tags", + ]); + }); + + it("labels a list element with its index", async () => { + const children = await childrenOf(toGraphNode(root)); + const tags = children.find((child) => child.label === "tags")!; + expect((await childrenOf(tags)).map((child) => child.path)).toEqual(["$.tags[0]", "$.tags[1]"]); + }); + + it("reports the scalar's own type, not the tree's structural kind", async () => { + const byLabel = new Map((await childrenOf(toGraphNode(root))).map((c) => [c.label, c])); + expect(byLabel.get("replicas")!.type).toBe("number"); + expect(byLabel.get("ready")!.type).toBe("boolean"); + expect(byLabel.get("note")!.type).toBe("null"); + expect(byLabel.get("pod")!.type).toBe("object"); + expect(byLabel.get("tags")!.type).toBe("array"); + }); + + it("shows a scalar's value and a container's summary, never both", async () => { + const byLabel = new Map((await childrenOf(toGraphNode(root))).map((c) => [c.label, c])); + expect(byLabel.get("replicas")!.value).toBe(3); + expect(byLabel.get("replicas")!.raw).toBeUndefined(); + expect(byLabel.get("pod")!.value).toBeUndefined(); + expect(byLabel.get("pod")!.raw).toBeTruthy(); + }); + + it("marks only containers with children as expandable", async () => { + const byLabel = new Map((await childrenOf(toGraphNode(root))).map((c) => [c.label, c])); + expect(byLabel.get("pod")!.expandable).toBe(true); + expect(byLabel.get("tags")!.expandable).toBe(true); + expect(byLabel.get("replicas")!.expandable).toBe(false); + }); + + it("gives every row a distinct id, which is what selection is keyed by", async () => { + const children = await childrenOf(toGraphNode(root)); + const ids = children.map((child) => child.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/web/apps/playground/tsconfig.json b/web/apps/playground/tsconfig.json new file mode 100644 index 000000000..e01d1279c --- /dev/null +++ b/web/apps/playground/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "resolveJsonModule": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vite/client", "node"] + }, + "include": ["src", "plugins", "test", "vite.config.ts"] +} diff --git a/web/apps/playground/vite.config.ts b/web/apps/playground/vite.config.ts new file mode 100644 index 000000000..ae9212238 --- /dev/null +++ b/web/apps/playground/vite.config.ts @@ -0,0 +1,71 @@ +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; + +import { evalServer } from "./plugins/eval-server"; + +const root = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(root, "../../.."); + +const EVAL_PORT = 8321; +const DEV_PORT = 5280; + +// A sibling clicky-ui checkout is aliased to its sources during `vite dev`, so +// UI changes show up without a publish round-trip. Absent (CI, a clean clone), +// the published package is used instead. +// Tests are excluded on purpose: they assert against the surface the package +// publishes, not against whatever a sibling checkout happens to have mid-edit. +const clickySrc = resolve(repoRoot, "../clicky-ui/packages/ui/src"); +const clickySourceAvailable = existsSync(clickySrc) && process.env.GOMPLATE_CLICKY_SOURCE !== "0"; + +// Every subpath the app imports has to be aliased, not just the ones that are +// convenient: mixing aliased sources with the published bundle loads clicky-ui +// twice, and React context does not cross module instances -- a RouterProvider +// from one copy is invisible to an AppShell from the other. +const clickyAliases = [ + { find: /^@flanksource\/clicky-ui\/styles\.css$/, replacement: resolve(clickySrc, "styles/full.css") }, + { find: /^@flanksource\/clicky-ui\/tailwind-preset$/, replacement: resolve(clickySrc, "tailwind-preset.ts") }, + { find: /^@flanksource\/clicky-ui\/monaco$/, replacement: resolve(clickySrc, "monaco.ts") }, + { find: /^@flanksource\/clicky-ui\/icons$/, replacement: resolve(clickySrc, "icons.ts") }, + { find: /^@flanksource\/clicky-ui\/data$/, replacement: resolve(clickySrc, "data.ts") }, + { find: /^@flanksource\/clicky-ui\/components$/, replacement: resolve(clickySrc, "components.ts") }, + { find: /^@flanksource\/clicky-ui\/hooks$/, replacement: resolve(clickySrc, "hooks.ts") }, + { find: /^@flanksource\/clicky-ui\/rpc$/, replacement: resolve(clickySrc, "rpc.ts") }, + { find: /^@flanksource\/clicky-ui$/, replacement: resolve(clickySrc, "index.ts") }, +]; + +export default defineConfig(({ command, mode }) => { + // Vitest also runs in `serve`, and it is the one mode that must not alias: + // tests assert against the surface the package publishes, not against + // whatever a sibling checkout happens to have mid-edit. + const useClickySource = clickySourceAvailable && mode !== "test"; + + return { + plugins: [react(), tailwindcss(), evalServer({ repoRoot, port: EVAL_PORT })], + resolve: { + dedupe: ["react", "react-dom"], + alias: command === "serve" && useClickySource ? clickyAliases : [], + }, + server: { + port: DEV_PORT, + strictPort: true, + proxy: { + "/api": { + target: `http://127.0.0.1:${EVAL_PORT}`, + changeOrigin: false, + }, + }, + // Vite refuses to serve files outside the project root unless told to. + fs: { allow: [root, resolve(root, "../.."), ...(useClickySource ? [clickySrc] : [])] }, + }, + optimizeDeps: { + exclude: [ + "@flanksource/gomplate-lang", + ...(useClickySource ? ["@flanksource/clicky-ui"] : []), + ], + }, + }; +}); diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000..8192aa778 --- /dev/null +++ b/web/package.json @@ -0,0 +1,12 @@ +{ + "name": "@flanksource/gomplate-web", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm -r build", + "test": "pnpm -r test", + "typecheck": "pnpm -r typecheck", + "dev:playground": "pnpm --filter gomplate-playground dev" + }, + "packageManager": "pnpm@10.34.5" +} diff --git a/web/packages/lang/README.md b/web/packages/lang/README.md new file mode 100644 index 000000000..fc06b2aa0 --- /dev/null +++ b/web/packages/lang/README.md @@ -0,0 +1,133 @@ +# @flanksource/gomplate-lang + +Monaco language support for the expression languages [gomplate](https://github.com/flanksource/gomplate) evaluates: CEL, Go templates, templated YAML/JSON, and JSONPath. + +Nothing in this package is written by hand. The tokenizers are generated from the grammars gomplate's own parsers use, and the function catalogue is read out of a live `cel.Env` and the `text/template` FuncMap gomplate installs — so the editor cannot disagree with the evaluator. + +| Layer | Source | +|---|---| +| CEL lexical rules | `CEL.g4`, cel-go's ANTLR grammar | +| CEL functions, macros, types | `Env.Functions()`, `Env.Macros()` on a live environment | +| Template keywords, builtins, delimiters | the `text/template` lexer, read from its AST | +| gomplate functions | reflection over `gomplate.CreateFuncs` | + +## Install + +```bash +pnpm add @flanksource/gomplate-lang +``` + +`monaco-editor` is a peer dependency (`>=0.48 <1`). This package never imports it: you pass your Monaco instance in, so the host app keeps ownership of its version and bundling. + +## Usage + +```ts +import * as monaco from "monaco-editor"; +import { registerGomplateLanguages } from "@flanksource/gomplate-lang"; + +registerGomplateLanguages(monaco); +``` + +With `@monaco-editor/react`, register in `beforeMount` — a model created before registration resolves to plaintext and is never revisited: + +```tsx + registerGomplateLanguages(monaco)} /> +``` + +The call is idempotent, so several editors can register independently without coordinating. It returns a disposable that removes the completion and hover providers it added. + +### Options + +```ts +registerGomplateLanguages(monaco, { + languages: ["cel", "yaml-gomplate"], // default: all + completions: true, + hovers: true, + themes: true, + environment: () => currentDocument, // default: none +}); +``` + +### Completing the document + +`environment` is what turns the function catalogue into an editor that knows the payload being evaluated against: typing `pod.` then offers the keys that document actually has, ahead of the 267 CEL functions. + +It is a **getter**, not a value. Registration happens once, before the first editor mounts, while the document keeps being edited afterwards — a value would freeze at first mount. It is called on every completion request, so keep it cheap (read a ref; do not re-parse). + +Each suggestion replaces the whole path typed so far rather than appending to it, so what lands in the editor is valid in that language: `pod["app.kubernetes.io/name"]` for an awkward key, `pod.items[0]` where a list needs an index, and nothing at all where a go template would need `index` instead of a path. + +Paths render per language — `pod.metadata.name` in CEL, `.pod.metadata.name` inside a `{{ }}` action, `$.pod.metadata.name` in JSONPath. `pathExpression(languageId, segments)` is exported so a host UI can insert the same syntax the editor completes. + +### Completing a host's own functions + +The catalogue baked into this package is gomplate's. A host binary — mission-control, commons-db — registers more on top and serves the result from `GET /api/spec`. Fold it in with `setSpec`: + +```ts +const languages = registerGomplateLanguages(monaco, { environment }); + +fetch("/api/spec") + .then((r) => r.json()) + .then((served) => languages.setSpec(served)); +``` + +Registration has to happen in `beforeMount`, before the first model exists, while the catalogue arrives over the network afterwards — so gating registration on the fetch would stall the editor. Register with the baked catalogue and update it when the response lands. (Which of the two lands first is a race, so a host that already has the spec can pass it as `spec` at registration; both paths are safe.) + +There is no new grammar involved. The generated tokenizers match any dotted call and dispatch on word lists — `namespaces`, `globalFunctions`, `memberFunctions`, `macros` — so `catalog.query(…)` highlights the moment `catalog` joins `namespaces`. `setSpec` re-applies those lists and re-registers completion and hover against the merged functions. + +`mergeSpec(base, incoming)` is exported for anyone who needs the merged catalogue themselves — a function browser, say. Incoming wins on a name collision, since its binary is what evaluates. + +### Languages + +| Id | For | +|---|---| +| `cel` | CEL expressions (`Template.Expression`) | +| `gomplate` | a bare Go template (`Template.Template`) | +| `yaml-gomplate` | YAML with `{{ }}` in it — how most configuration is written | +| `json-gomplate` | JSON with `{{ }}` in it | +| `text-gomplate` | plain text with `{{ }}` in it | +| `jsonpath` | JSONPath, the dialect `ojg` evaluates | + +JavaScript (`Template.Javascript`) uses Monaco's own `javascript` language and is not generated here. + +### Themes + +`gomplate-light` and `gomplate-dark` colour the tokens the generated tokenizers emit — namespaces, member functions, macros, template delimiters, optional access. They inherit from `vs` and `vs-dark`, so anything they do not name keeps its usual colour. + +```ts +import { GOMPLATE_DARK_THEME, GOMPLATE_LIGHT_THEME } from "@flanksource/gomplate-lang"; + +monaco.editor.setTheme(isDark ? GOMPLATE_DARK_THEME : GOMPLATE_LIGHT_THEME); +``` + +`setTheme` is global to a Monaco instance, and `@monaco-editor/react` re-applies its `theme` prop whenever an editor mounts. If a wrapper hardcodes that prop, re-assert the theme from `onMount`. + +### The catalogue + +The generated spec is the accurate function reference for this fork — more so than the checked-in Markdown, which documents functions that live in other repositories and omits whole namespaces. + +```ts +import { spec, celFunction, celNamespace } from "@flanksource/gomplate-lang/spec"; + +spec.cel.namespaces; // k8s, aws, math, time, filepath, ... +celFunction("k8s.isHealthy"); // overloads, argument and result types +celNamespace("k8s"); // everything under k8s.* +``` + +Note that the CEL and go-template catalogues genuinely differ: `conv.*` and `path.*` are registered for templates but not for CEL, and the same helper is `k8s.IsHealthy` in a template and `k8s.isHealthy` in CEL. + +## Regenerating + +From the gomplate repository root, after changing any registered function: + +```bash +make monarch # regenerate +make monarch-check # fail if the checked-in files are stale (CI runs this) +``` + +## Testing + +```bash +pnpm test +``` + +Two suites run against real Monaco. `tokenize.test.ts` asserts exact token streams for each language. `conformance.test.ts` replays a corpus generated by running cel-go's own ANTLR lexer over snippets from the reference docs, and asserts the tokenizer never ends a token part-way through a real one — the failure mode behind a triple-quoted string cut short, `0x1f` truncated to `0`, or `123u` split in two. diff --git a/web/packages/lang/package.json b/web/packages/lang/package.json new file mode 100644 index 000000000..c197357af --- /dev/null +++ b/web/packages/lang/package.json @@ -0,0 +1,47 @@ +{ + "name": "@flanksource/gomplate-lang", + "version": "0.1.0", + "description": "Monaco language support for the expression languages gomplate evaluates: CEL, Go templates, JSONPath", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/flanksource/gomplate.git", + "directory": "web/packages/lang" + }, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./spec": { + "types": "./dist/spec.d.ts", + "import": "./dist/spec.js", + "require": "./dist/spec.cjs" + } + }, + "files": [ + "dist" + ], + "sideEffects": false, + "scripts": { + "build": "vite build && tsc --project tsconfig.build.json", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "monaco-editor": ">=0.48 <1" + }, + "devDependencies": { + "jsdom": "catalog:", + "monaco-editor": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/web/packages/lang/src/attributes.ts b/web/packages/lang/src/attributes.ts new file mode 100644 index 000000000..e496b5fe1 --- /dev/null +++ b/web/packages/lang/src/attributes.ts @@ -0,0 +1,81 @@ +import type { CelSpec, GoTemplateSpec, GomplateSpec } from "./types"; +import { pathFlavour } from "./environment"; + +/** + * The Monarch word lists a tokenizer rule refers to as `@name`. + * + * The tokenizers themselves are fixed: their rules match any dotted call and + * then dispatch on these lists (`$1@namespaces`, `$1@globalFunctions`, …). So a + * host's own `catalog.query` needs no new grammar — only `catalog` in + * `namespaces` and `query` in `globalFunctions`. + * + * This mirrors the derivation in `genmonarch/lang_cel.go` and + * `lang_gotemplate.go`. `attributes.test.ts` recomputes the generated bundle + * from the generated spec and asserts the two agree, so the mirror cannot drift + * silently. + */ +export type Attributes = Record; + +/** CEL's word lists, minus `operators`, which comes from the grammar. */ +export function celAttributes(spec: CelSpec): Attributes { + const global: string[] = []; + const member: string[] = []; + for (const fn of spec.functions) { + if (fn.memberOnly) member.push(fn.name); + else global.push(leafOf(fn.name)); + } + + return { + // `true`/`false`/`null` are constants, and colouring them as keywords too + // would let whichever rule ran first decide. + keywords: spec.keywords.filter((word) => !CONSTANTS.includes(word)), + constants: [...CONSTANTS], + typeKeywords: spec.types, + macros: spec.macros.map((macro) => macro.name), + namespaces: spec.namespaces, + globalFunctions: global, + memberFunctions: member, + }; +} + +/** The go-template word lists, shared by the bare and embedded languages. */ +export function goTemplateAttributes(spec: GoTemplateSpec): Attributes { + return { + keywords: spec.keywords, + builtins: spec.builtins, + namespaces: spec.namespaces, + functions: spec.functions.filter((fn) => !fn.namespace).map((fn) => fn.name), + }; +} + +/** The word lists for one language id, or null where a spec does not drive it. */ +export function attributesFor(languageId: string, spec: GomplateSpec): Attributes | null { + switch (pathFlavour(languageId)) { + case "cel": + return normalize(celAttributes(spec.cel)); + case "gotemplate": + return normalize(goTemplateAttributes(spec.gotemplate)); + default: + // JSONPath's vocabulary is the grammar's own, with nothing to extend. + return null; + } +} + +const CONSTANTS = ["true", "false", "null"]; + +/** + * Sorted and deduplicated, matching `Language.MarshalJSON` on the Go side, so a + * recomputed list compares equal to a generated one. + */ +function normalize(attributes: Attributes): Attributes { + const out: Attributes = {}; + for (const [name, words] of Object.entries(attributes)) { + out[name] = [...new Set(words)].sort(); + } + return out; +} + +function leafOf(name: string) { + const dot = name.lastIndexOf("."); + return dot < 0 ? name : name.slice(dot + 1); +} diff --git a/web/packages/lang/src/completion.ts b/web/packages/lang/src/completion.ts new file mode 100644 index 000000000..fc9962966 --- /dev/null +++ b/web/packages/lang/src/completion.ts @@ -0,0 +1,250 @@ +import type * as monaco from "monaco-editor"; +import type { GomplateSpec, Monaco, SpecFunction, SpecMacro } from "./types"; +import { functionDocumentation, macroDocumentation } from "./hover"; +import { childEntries, pathExpression, pathFlavour, resolvePath } from "./environment"; +import { environmentPrefixAt } from "./prefix"; + +/** Supplies the document expressions are evaluated against, on every request. */ +export type EnvironmentSource = () => unknown; + +export interface CompletionOptions { + /** The catalogue to complete from — gomplate's, or a host's merged over it. */ + spec: GomplateSpec; + environment?: EnvironmentSource | undefined; +} + +/** + * Registers completion for one language. + * + * The catalogue half is built once per registration, so a host that supplies + * its own spec re-registers rather than mutating in place. The document half is + * rebuilt per request, because the document is being edited alongside the + * expression. + */ +export function registerCompletion( + monaco: Monaco, + languageId: string, + options: CompletionOptions, +) { + return monaco.languages.registerCompletionItemProvider( + languageId, + completionProvider(monaco, languageId, options), + ); +} + +/** + * The provider `registerCompletion` installs, exposed so it can be driven + * directly by a test over a real model rather than through Monaco's registry. + */ +export function completionProvider( + monaco: Monaco, + languageId: string, + { spec, environment }: CompletionOptions, +) { + const items = catalogueItems(monaco, languageId, spec); + return { + // A dot must retrigger, or `k8s.` offers nothing until another key is hit. + triggerCharacters: ["."], + provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) { + const suggestions: monaco.languages.CompletionItem[] = environmentItems( + monaco, + model, + position, + languageId, + environment, + ); + const range = wordRange(model, position); + for (const item of items) suggestions.push({ ...item, range }); + return { suggestions }; + }, + }; +} + +/** + * A completion item without its range. The range depends on the cursor, so it + * is attached per request while the rest of the item is built once. + */ +type Item = Omit; + +function catalogueItems(monaco: Monaco, languageId: string, spec: GomplateSpec): Item[] { + switch (pathFlavour(languageId)) { + case "cel": + return celCompletionItems(monaco, spec); + case "gotemplate": + return goTemplateCompletionItems(monaco, spec); + default: + // JSONPath's dialect has no catalogue to generate from; its completions + // come entirely from the document. + return []; + } +} + +/** + * The keys reachable from the cursor's position in the document. + * + * Each item replaces the whole path typed so far with a freshly rendered one, + * rather than appending to it, so what lands in the editor is always valid in + * that language — quoting an awkward key, or dropping the suggestion entirely + * where the language cannot express the path. + */ +function environmentItems( + monaco: Monaco, + model: monaco.editor.ITextModel, + position: monaco.Position, + languageId: string, + environment: EnvironmentSource | undefined, +): monaco.languages.CompletionItem[] { + if (!environment) return []; + const document = environment(); + if (document === undefined || document === null) return []; + + const prefix = environmentPrefixAt(model, position, languageId); + if (!prefix) return []; + + const parent = resolvePath(document, prefix.segments); + if (parent === undefined) return []; + + const kinds = monaco.languages.CompletionItemKind; + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: prefix.startColumn, + endColumn: prefix.endColumn, + }; + + // Monaco filters a candidate against the model text from the range start to + // the cursor, so `filterText` has to continue what was typed rather than + // repeat the rendered path: `pod.items[0]` does not match `pod.items.`, and + // the subscripted keys would be filtered straight back out. + const typedHead = prefix.typed.slice(0, prefix.typed.length - prefix.leaf.length); + + const items: monaco.languages.CompletionItem[] = []; + for (const entry of childEntries(parent)) { + const insertText = pathExpression(languageId, [...prefix.segments, entry.segment]); + if (insertText === null) continue; + items.push({ + label: entry.key, + kind: entry.container ? kinds.Folder : kinds.Field, + insertText, + filterText: `${typedHead}${entry.key}`, + detail: `${entry.kind} · ${entry.summary}`, + // A key of the document being evaluated beats any catalogue entry: it is + // what the author came to the editor to write. + sortText: `0${entry.key}`, + range, + }); + } + return items; +} + +function celCompletionItems(monaco: Monaco, spec: GomplateSpec) { + const kinds = monaco.languages.CompletionItemKind; + const items: Item[] = []; + + for (const fn of spec.cel.functions) { + items.push(buildFunctionItem(monaco, fn, celInsertText(fn), kinds.Function)); + } + for (const macro of spec.cel.macros) { + items.push(buildMacroItem(monaco, macro)); + } + for (const keyword of spec.cel.keywords) { + items.push({ + label: keyword, + kind: kinds.Keyword, + insertText: keyword, + detail: "CEL keyword", + }); + } + for (const type of spec.cel.types) { + items.push({ label: type, kind: kinds.TypeParameter, insertText: type, detail: "CEL type" }); + } + return items; +} + +function goTemplateCompletionItems(monaco: Monaco, spec: GomplateSpec) { + const kinds = monaco.languages.CompletionItemKind; + const items: Item[] = []; + + for (const fn of spec.gotemplate.functions) { + items.push(buildFunctionItem(monaco, fn, fn.name, kinds.Function)); + } + for (const builtin of spec.gotemplate.builtins) { + items.push({ + label: builtin, + kind: kinds.Function, + insertText: builtin, + detail: "text/template builtin", + }); + } + for (const keyword of spec.gotemplate.keywords) { + items.push({ + label: keyword, + kind: kinds.Keyword, + insertText: keyword, + detail: "template keyword", + }); + } + return items; +} + +function buildFunctionItem( + monaco: Monaco, + fn: SpecFunction, + insertText: string, + kind: monaco.languages.CompletionItemKind, +): Item { + // A function with neither a Go signature nor an overload has no detail to + // show; the key has to be absent rather than explicitly undefined. + const detail = fn.signature ?? fn.overloads?.[0]?.result; + return { + label: fn.name, + kind: fn.memberOnly ? monaco.languages.CompletionItemKind.Method : kind, + insertText, + ...(detail === undefined ? {} : { detail }), + documentation: { value: functionDocumentation(fn) }, + filterText: fn.name, + // Un-namespaced names first: they are the short, common ones, and a + // namespace is easy to reach by typing its prefix. + sortText: fn.namespace ? `2${fn.name}` : `1${fn.name}`, + }; +} + +function buildMacroItem(monaco: Monaco, macro: SpecMacro): Item { + return { + label: macro.name, + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: macro.name, + detail: `macro (${macro.argCount === 0 ? "variadic" : `${macro.argCount} args`})`, + documentation: { value: macroDocumentation(macro) }, + filterText: macro.name, + sortText: `1${macro.name}`, + }; +} + +/** + * CEL functions take parentheses; a member-only function is offered without a + * leading dot because the dot is already typed when completion fires. + */ +function celInsertText(fn: SpecFunction) { + const arity = fn.overloads?.[0]?.args.length ?? 0; + const receiver = fn.overloads?.[0]?.member ? 1 : 0; + return arity - receiver === 0 ? `${fn.name}()` : `${fn.name}(`; +} + +function wordRange( + model: { + getWordUntilPosition(p: { lineNumber: number; column: number }): { + startColumn: number; + endColumn: number; + }; + }, + position: { lineNumber: number; column: number }, +) { + const word = model.getWordUntilPosition(position); + return { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; +} diff --git a/web/packages/lang/src/environment.ts b/web/packages/lang/src/environment.ts new file mode 100644 index 000000000..2f480d53d --- /dev/null +++ b/web/packages/lang/src/environment.ts @@ -0,0 +1,167 @@ +/** + * Introspection of the document an expression is evaluated against. + * + * Deliberately free of Monaco and of any UI dependency: the same functions back + * editor completion and any caller that needs to render a document's shape. + */ + +/** One step of a path. A number indexes a list, a string keys a map. */ +export type PathSegment = string | number; + +/** The JSON shape of a value, as a name a reader recognises. */ +export type ValueKind = "string" | "number" | "boolean" | "null" | "object" | "array"; + +/** One child of a container, as completion and shape views need it. */ +export interface EnvironmentEntry { + /** The key or index, as typed. */ + key: string; + segment: PathSegment; + kind: ValueKind; + /** A short rendering of the value, for a detail column. */ + summary: string; + /** Whether the value has children of its own. */ + container: boolean; +} + +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** Whether `key` can be written as a bare `.key` rather than a subscript. */ +export function isIdentifier(key: string): boolean { + return IDENTIFIER.test(key); +} + +export function kindOf(value: unknown): ValueKind { + if (value === null || value === undefined) return "null"; + if (Array.isArray(value)) return "array"; + switch (typeof value) { + case "string": + return "string"; + case "number": + return "number"; + case "boolean": + return "boolean"; + default: + return "object"; + } +} + +const SUMMARY_LIMIT = 32; + +/** A one-line rendering of a value: the sample a completion detail shows. */ +export function summarize(value: unknown): string { + switch (kindOf(value)) { + case "null": + return "null"; + case "array": + return plural((value as unknown[]).length, "item"); + case "object": + return plural(Object.keys(value as Record).length, "key"); + case "string": { + const text = value as string; + return text.length > SUMMARY_LIMIT ? `${JSON.stringify(text.slice(0, SUMMARY_LIMIT))}…` : JSON.stringify(text); + } + default: + return String(value); + } +} + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +/** + * Walks `segments` from the root. + * + * A numeric segment indexes a list and nothing else; a string segment keys a + * map. Returns undefined as soon as a step does not apply, so a half-typed path + * simply yields no completions rather than throwing. + */ +export function resolvePath(environment: unknown, segments: readonly PathSegment[]): unknown { + let current = environment; + for (const segment of segments) { + if (typeof segment === "number") { + if (!Array.isArray(current)) return undefined; + current = current[segment]; + continue; + } + if (!isRecord(current)) return undefined; + current = current[segment]; + } + return current; +} + +/** + * The children of a container, in document order. + * + * A list yields its indices rather than the keys of its first element: no + * language here lets a field follow a list without an index, so offering + * `containers.name` would complete an expression that cannot evaluate. + */ +export function childEntries(value: unknown): EnvironmentEntry[] { + if (Array.isArray(value)) { + return value.map((element, index) => ({ + key: String(index), + segment: index, + kind: kindOf(element), + summary: summarize(element), + container: isContainer(element), + })); + } + if (!isRecord(value)) return []; + return Object.entries(value).map(([key, child]) => ({ + key, + segment: key, + kind: kindOf(child), + summary: summarize(child), + container: isContainer(child), + })); +} + +function isContainer(value: unknown): boolean { + const kind = kindOf(value); + return kind === "object" || kind === "array"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Renders a path in one language's syntax. + * + * The single place path syntax is spelled out, so completion and click-to-insert + * cannot drift apart. Returns null when the path has no expression in that + * language — a go-template reaches a list element or an awkward key through + * `index`, which is a call rather than a path. + */ +export function pathExpression(languageId: string, segments: readonly PathSegment[]): string | null { + const flavour = pathFlavour(languageId); + if (!flavour) return null; + if (flavour === "gotemplate") { + if (segments.some((segment) => typeof segment === "number" || !isIdentifier(segment))) { + return null; + } + return segments.length === 0 ? "." : segments.map((segment) => `.${String(segment)}`).join(""); + } + + const root = flavour === "jsonpath" ? "$" : ""; + let out = root; + for (const segment of segments) { + if (typeof segment === "number") { + out += `[${segment}]`; + } else if (isIdentifier(segment)) { + out += out === "" ? segment : `.${segment}`; + } else { + out += `[${JSON.stringify(segment)}]`; + } + } + return out; +} + +/** Which path syntax a language id uses. */ +export function pathFlavour(languageId: string): "cel" | "gotemplate" | "jsonpath" | null { + if (languageId === "cel") return "cel"; + if (languageId === "jsonpath") return "jsonpath"; + if (languageId === "gomplate" || languageId.endsWith("-gomplate")) return "gotemplate"; + return null; +} diff --git a/web/packages/lang/src/generated/cel.config.json b/web/packages/lang/src/generated/cel.config.json new file mode 100644 index 000000000..a7a104b89 --- /dev/null +++ b/web/packages/lang/src/generated/cel.config.json @@ -0,0 +1,68 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": [ + "", + "" + ] + }, + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ], + [ + "(", + ")" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "surroundingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" +} diff --git a/web/packages/lang/src/generated/cel.monarch.json b/web/packages/lang/src/generated/cel.monarch.json new file mode 100644 index 000000000..b3d9986fb --- /dev/null +++ b/web/packages/lang/src/generated/cel.monarch.json @@ -0,0 +1,566 @@ +{ + "defaultToken": "", + "tokenPostfix": ".cel", + "brackets": [ + { + "open": "{", + "close": "}", + "token": "delimiter.curly" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "constants": [ + "false", + "null", + "true" + ], + "globalFunctions": [ + "ASCII", + "Abs", + "Add", + "Age", + "Alpha", + "AlphaNum", + "Append", + "Assert", + "Base", + "CSV", + "CSVByColumn", + "CSVByRow", + "Ceil", + "Clean", + "Contains", + "ContainsCIDR", + "Dict", + "Dir", + "Div", + "Duration", + "Ext", + "Fail", + "Find", + "FindAll", + "Float", + "Floor", + "FromSlash", + "GetHealth", + "GetStatus", + "Has", + "HashUUID", + "Hour", + "HumanDuration", + "HumanSize", + "InTimeRange", + "IsAbs", + "IsFloat", + "IsHealthy", + "IsInt", + "IsKind", + "IsNum", + "IsReady", + "IsValid", + "IsValidIP", + "Item", + "Join", + "Kind", + "Match", + "Microsecond", + "Millisecond", + "Minute", + "Mul", + "Nanosecond", + "Nil", + "Now", + "Number", + "Parse", + "ParseDateTime", + "ParseDuration", + "ParseInLocation", + "ParseLocal", + "Pow", + "Prepend", + "QuoteMeta", + "Rel", + "Rem", + "Replace", + "ReplaceLiteral", + "Required", + "Round", + "SHA1", + "SHA1Bytes", + "SHA224", + "SHA224Bytes", + "SHA256", + "SHA256Bytes", + "SHA384", + "SHA384Bytes", + "SHA512", + "SHA512Bytes", + "SHA512_224", + "SHA512_224Bytes", + "SHA512_256", + "SHA512_256Bytes", + "Second", + "Semver", + "SemverCompare", + "Seq", + "Since", + "Sort", + "Split", + "SplitN", + "String", + "Sub", + "TOML", + "Ternary", + "ToSlash", + "Unix", + "Until", + "V1", + "V4", + "VolumeName", + "YAML", + "YAMLArray", + "ZoneName", + "ZoneOffset", + "abs", + "age", + "arnToMap", + "bitAnd", + "bitNot", + "bitOr", + "bitShiftLeft", + "bitShiftRight", + "bitXor", + "bool", + "bytes", + "ceil", + "coalesce", + "contains", + "containsFloat", + "cpuAsMillicores", + "debug", + "decode", + "double", + "duration", + "dyn", + "encode", + "equivalent", + "f", + "first", + "floor", + "fromAWSMap", + "getHealth", + "getResourcesLimit", + "getResourcesRequests", + "getStatus", + "in", + "in_business_hours", + "int", + "intersects", + "isFinite", + "isHealthy", + "isInf", + "isNaN", + "isReady", + "isURL", + "is_healthy", + "jmespath", + "jq", + "jsonpath", + "keyValToMap", + "labels", + "last", + "mapToKeyVal", + "matchLabel", + "matches", + "memoryAsBytes", + "merge", + "neat", + "nodeProperties", + "none", + "of", + "ofNonZeroValue", + "podProperties", + "quote", + "range", + "round", + "sign", + "size", + "sqrt", + "string", + "timestamp", + "toCSV", + "toTOML", + "toYAML", + "trunc", + "type", + "uint", + "url", + "urldecode", + "urlencode", + "xpath" + ], + "keywords": [ + "as", + "break", + "const", + "continue", + "else", + "for", + "function", + "if", + "import", + "in", + "let", + "loop", + "namespace", + "package", + "return", + "type", + "var", + "void", + "while" + ], + "macros": [ + "all", + "exists", + "exists_one", + "filter", + "fold", + "greatest", + "has", + "least", + "map", + "optFlatMap", + "optMap", + "sortBy" + ], + "memberFunctions": [ + "JSON", + "JSONArray", + "abbrev", + "camelCase", + "charAt", + "contains", + "distinct", + "endsWith", + "find", + "findAll", + "flatten", + "format", + "getDate", + "getDayOfMonth", + "getDayOfWeek", + "getDayOfYear", + "getEscapedPath", + "getFullYear", + "getHost", + "getHostname", + "getHours", + "getMilliseconds", + "getMinutes", + "getMonth", + "getPort", + "getQuery", + "getScheme", + "getSeconds", + "hasValue", + "indent", + "indexOf", + "isSorted", + "join", + "kebabCase", + "keys", + "lastIndexOf", + "lowerAscii", + "match", + "max", + "min", + "omit", + "or", + "orValue", + "pick", + "quote", + "repeat", + "replace", + "replaceAll", + "replaceAllRegex", + "reverse", + "runeCount", + "shellQuote", + "slice", + "slug", + "snakeCase", + "sort", + "sortBy", + "split", + "splitRegex", + "squote", + "startsWith", + "substring", + "sum", + "title", + "toJSON", + "toJSONPretty", + "toLower", + "toUpper", + "trim", + "trimPrefix", + "trimSpace", + "trimSuffix", + "trunc", + "uniq", + "upperAscii", + "value", + "values", + "wordWrap" + ], + "namespaces": [ + "aws", + "base64", + "crypto", + "data", + "filepath", + "json", + "k8s", + "lists", + "math", + "net", + "optional", + "random", + "regexp", + "sets", + "strings", + "test", + "time", + "uuid" + ], + "operators": [ + "!", + "!=", + "%", + "\u0026\u0026", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + ":", + "\u003c", + "\u003c=", + "==", + "\u003e", + "\u003e=", + "?", + "[", + "]", + "{", + "||", + "}" + ], + "typeKeywords": [ + "bool", + "bytes", + "double", + "duration", + "dyn", + "int", + "list", + "map", + "null_type", + "string", + "timestamp", + "type", + "uint" + ], + "tokenizer": { + "root": [ + { + "include": "@whitespace" + }, + [ + "[bB](?:[rR]\"\"\"[\\s\\S]*?\"\"\"|[rR]'''[\\s\\S]*?'''|\"\"\"(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?\"\"\"|'''(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?'''|[rR]\"[^\"\\n\\r]*\"|[rR]'[^'\\n\\r]*'|\"(?:[^\\\\\"\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*\"|'(?:[^\\\\'\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*')", + "string.bytes" + ], + [ + "(?:[rR]\"\"\"[\\s\\S]*?\"\"\"|[rR]'''[\\s\\S]*?'''|\"\"\"(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?\"\"\"|'''(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?'''|[rR]\"[^\"\\n\\r]*\"|[rR]'[^'\\n\\r]*'|\"(?:[^\\\\\"\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*\"|'(?:[^\\\\'\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*')", + "string" + ], + [ + "`[A-Za-z0-9_.\\-/ ]+`", + "identifier.escaped" + ], + [ + "(?:\\.[0-9]+(?:[eE][+\\-]?[0-9]+)?|[0-9]+\\.[0-9]+(?:[eE][+\\-]?[0-9]+)?|[0-9]+[eE][+\\-]?[0-9]+)", + "number.float" + ], + [ + "(?:0x[0-9a-fA-F]+[uU]|[0-9]+[uU])", + "number.uint" + ], + [ + "(?:0x[0-9a-fA-F]+|[0-9]+)", + "number" + ], + [ + "(\\.\\?)([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", + { + "cases": { + "$2@macros": [ + "operator.optional", + "keyword.macro" + ], + "$2@memberFunctions": [ + "operator.optional", + "function.member" + ], + "$2@globalFunctions": [ + "operator.optional", + "function" + ], + "@default": [ + "operator.optional", + "variable.field" + ] + } + } + ], + [ + "(\\.\\?)([A-Za-z_][A-Za-z0-9_]*)", + [ + "operator.optional", + "variable.field" + ] + ], + [ + "\\.\\?", + "operator.optional" + ], + [ + "\\[\\?", + "operator.optional" + ], + [ + "\\?\\.", + "operator.optional" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "$3@macros": [ + "identifier", + "delimiter", + "keyword.macro" + ], + "$3@memberFunctions": [ + "identifier", + "delimiter", + "function.member" + ], + "$3@globalFunctions": [ + "identifier", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", + { + "cases": { + "$2@macros": [ + "delimiter", + "keyword.macro" + ], + "$2@memberFunctions": [ + "delimiter", + "function.member" + ], + "$2@globalFunctions": [ + "delimiter", + "function" + ], + "@default": [ + "delimiter", + "variable.field" + ] + } + } + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", + { + "cases": { + "$1@macros": "keyword.macro", + "$1@globalFunctions": "function", + "$1@keywords": "keyword", + "@default": "identifier" + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@constants": "keyword.constant", + "@keywords": "keyword", + "@typeKeywords": "type", + "@default": "identifier" + } + } + ], + [ + "[{}()\\[\\]]", + "@brackets" + ], + [ + "(?:!=|\u0026\u0026|\u003c=|==|\u003e=|\\|\\||!|%|\\*|\\+|,|-|\\.|\\/|:|\u003c|\u003e|\\?)", + "operator" + ], + [ + "[,;]", + "delimiter" + ] + ], + "whitespace": [ + [ + "[\\t \\r\\n\\f]+", + "white" + ], + [ + "//[^\\n]*", + "comment" + ] + ] + } +} diff --git a/web/packages/lang/src/generated/conformance.json b/web/packages/lang/src/generated/conformance.json new file mode 100644 index 000000000..87a0d238c --- /dev/null +++ b/web/packages/lang/src/generated/conformance.json @@ -0,0 +1,4959 @@ +[ + { + "language": "cel", + "source": "\" \\ttrim\\n \".trim()", + "boundaries": [ + 0, + 16, + 17, + 21, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"\"\"triple \"quoted\" string\"\"\"", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "\"12345\".matches(\"^\\\\d+$\")", + "boundaries": [ + 0, + 7, + 8, + 15, + 16, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"2023-01-01T12:34:56Z\".getDate()", + "boundaries": [ + 0, + 22, + 23, + 30, + 31 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello Beautiful World!\".kebabCase()", + "boundaries": [ + 0, + 24, + 25, + 34, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello Beautiful World!\".snakeCase()", + "boundaries": [ + 0, + 24, + 25, + 34, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello Beautiful World\".slug()", + "boundaries": [ + 0, + 23, + 24, + 28, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello World!\".slug()", + "boundaries": [ + 0, + 14, + 15, + 19, + 20 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello World\".kebabCase()", + "boundaries": [ + 0, + 13, + 14, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello World\".runeCount()", + "boundaries": [ + 0, + 13, + 14, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello World\".shellQuote()", + "boundaries": [ + 0, + 13, + 14, + 24, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello World\".snakeCase()", + "boundaries": [ + 0, + 13, + 14, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello World\".squote()", + "boundaries": [ + 0, + 13, + 14, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello$World\".shellQuote()", + "boundaries": [ + 0, + 13, + 14, + 24, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello, World!\".slug()", + "boundaries": [ + 0, + 15, + 16, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"HelloWorld\".kebabCase()", + "boundaries": [ + 0, + 12, + 13, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"HelloWorld\".snakeCase()", + "boundaries": [ + 0, + 12, + 13, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"I have an apple\".replaceAll(\"apple\", \"orange\")", + "boundaries": [ + 0, + 17, + 18, + 28, + 29, + 36, + 38, + 46 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"John Smith\".reverse()", + "boundaries": [ + 0, + 12, + 13, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"KubernetesPod\".abbrev(1, 5)", + "boundaries": [ + 0, + 15, + 16, + 22, + 23, + 24, + 26, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"KubernetesPod\".abbrev(6)", + "boundaries": [ + 0, + 15, + 16, + 22, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Now is the time for all good men\".abbrev(5, 20)", + "boundaries": [ + 0, + 34, + 35, + 41, + 42, + 43, + 45, + 47 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"TacoCat\".lowerAscii()", + "boundaries": [ + 0, + 9, + 10, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"TacoCat\".upperAscii()", + "boundaries": [ + 0, + 9, + 10, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"TacoCÆt Xii\".lowerAscii()", + "boundaries": [ + 0, + 13, + 14, + 24, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"\\x41A\\U0001F600\\101\"", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "\"a\" + // trailing comment", + "boundaries": [ + 0, + 4, + 6 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "\"apple\" in [\"apple\", \"banana\"]", + "boundaries": [ + 0, + 8, + 11, + 12, + 19, + 21, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"apple\".contains(\"app\")", + "boundaries": [ + 0, + 7, + 8, + 16, + 17, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"apple\".matches(\"^a.*e$\")", + "boundaries": [ + 0, + 7, + 8, + 15, + 16, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"apple\".repeat(3)", + "boundaries": [ + 0, + 7, + 8, + 14, + 15, + 16 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"apple\".size()", + "boundaries": [ + 0, + 7, + 8, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"example@email.com\".matches(\"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}$\")", + "boundaries": [ + 0, + 19, + 20, + 27, + 28, + 79 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"gums\".reverse()", + "boundaries": [ + 0, + 6, + 7, + 14, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello beautiful world!\".camelCase()", + "boundaries": [ + 0, + 24, + 25, + 34, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello hello hello\".split(\" \")", + "boundaries": [ + 0, + 19, + 20, + 25, + 26, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello hello hello\".split(\" \", -1)", + "boundaries": [ + 0, + 19, + 20, + 25, + 26, + 29, + 31, + 32, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello hello hello\".split(\" \", 2)", + "boundaries": [ + 0, + 19, + 20, + 25, + 26, + 29, + 31, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello hello\".replace(\"he\", \"we\")", + "boundaries": [ + 0, + 13, + 14, + 21, + 22, + 26, + 28, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello hello\".replace(\"he\", \"we\", 0)", + "boundaries": [ + 0, + 13, + 14, + 21, + 22, + 26, + 28, + 32, + 34, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello hello\".replace(\"he\", \"we\", 1)", + "boundaries": [ + 0, + 13, + 14, + 21, + 22, + 26, + 28, + 32, + 34, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".indexOf(\"\")", + "boundaries": [ + 0, + 14, + 15, + 22, + 23, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".indexOf(\"\", 2)", + "boundaries": [ + 0, + 14, + 15, + 22, + 23, + 25, + 27, + 28 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".indexOf(\"ello\")", + "boundaries": [ + 0, + 14, + 15, + 22, + 23, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".indexOf(\"jello\")", + "boundaries": [ + 0, + 14, + 15, + 22, + 23, + 30 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".lastIndexOf(\"\")", + "boundaries": [ + 0, + 14, + 15, + 26, + 27, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".lastIndexOf(\"ello\")", + "boundaries": [ + 0, + 14, + 15, + 26, + 27, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".lastIndexOf(\"ello\", 6)", + "boundaries": [ + 0, + 14, + 15, + 26, + 27, + 33, + 35, + 36 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".lastIndexOf(\"jello\")", + "boundaries": [ + 0, + 14, + 15, + 26, + 27, + 34 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello world\".camelCase()", + "boundaries": [ + 0, + 13, + 14, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello world\".contains(\"world\")", + "boundaries": [ + 0, + 13, + 14, + 22, + 23, + 30 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "\"hello world\".indent(4, \"-\")", + "boundaries": [ + 0, + 13, + 14, + 20, + 21, + 22, + 24, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello world\".split(\" \")", + "boundaries": [ + 0, + 13, + 14, + 19, + 20, + 23 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "\"hello world\".title()", + "boundaries": [ + 0, + 13, + 14, + 19, + 20 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello world\".trimPrefix(\"hello \")", + "boundaries": [ + 0, + 13, + 14, + 24, + 25, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello world\".trimSuffix(\" world\")", + "boundaries": [ + 0, + 13, + 14, + 24, + 25, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello world\".upperAscii()", + "boundaries": [ + 0, + 13, + 14, + 24, + 25 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "\"hello\" + \" world\"", + "boundaries": [ + 0, + 8, + 10 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "\"hello\".charAt(4)", + "boundaries": [ + 0, + 7, + 8, + 14, + 15, + 16 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello\".endsWith(\"lo\")", + "boundaries": [ + 0, + 7, + 8, + 16, + 17, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello\".size()", + "boundaries": [ + 0, + 7, + 8, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello\".sort()", + "boundaries": [ + 0, + 7, + 8, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello\".startsWith(\"he\")", + "boundaries": [ + 0, + 7, + 8, + 18, + 19, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello_world\".camelCase()", + "boundaries": [ + 0, + 13, + 14, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"name\": \"world\",", + "boundaries": [ + 0, + 6, + 8, + 15 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "\"tacocat\".substring(0, 4)", + "boundaries": [ + 0, + 9, + 10, + 19, + 20, + 21, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"tacocat\".substring(4)", + "boundaries": [ + 0, + 9, + 10, + 19, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"this is a string: %s\\nand an integer: %d\".format([\"str\", 42])", + "boundaries": [ + 0, + 42, + 43, + 49, + 50, + 51, + 56, + 58, + 60, + 61 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "'''triple 'quoted' string'''", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "'[{\"name\": \"Alice\"}, {\"name\": \"Bob\"}]'.JSONArray()", + "boundaries": [ + 0, + 38, + 39, + 48, + 49 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "'{\"name\": \"Alice\", \"age\": 30}'.JSON()", + "boundaries": [ + 0, + 30, + 31, + 35, + 36 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "1.toJSON()", + "boundaries": [ + 0, + 1, + 2, + 8, + 9 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "123u + 0x1fU + 0x1f + 1.5e-3 + .5", + "boundaries": [ + 0, + 5, + 7, + 13, + 15, + 20, + 22, + 29, + 31 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "2 + 3", + "boundaries": [ + 0, + 2, + 4 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "3 in [1, 2, 4]", + "boundaries": [ + 0, + 2, + 5, + 6, + 7, + 9, + 10, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "A small helper command is available at `cmd/ceval` to evaluate CEL expressions against an input YAML or JSON file.", + "boundaries": [ + 0, + 2, + 8, + 15, + 23, + 26, + 36, + 39, + 51, + 54, + 63, + 67, + 79, + 87, + 90, + 96, + 101, + 104, + 109, + 113 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "B'bytes'", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "C,32", + "boundaries": [ + 0, + 1, + 2 + ], + "origin": "GO_TEMPLATE.md" + }, + { + "language": "cel", + "source": "CSV([\"Alice,30\", \"Bob,31\"])[0][0]", + "boundaries": [ + 0, + 3, + 4, + 5, + 15, + 17, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "Count int", + "boundaries": [ + 0, + 8 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "Example input file:", + "boundaries": [ + 0, + 8, + 14, + 18 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "Go,25", + "boundaries": [ + 0, + 2, + 3 + ], + "origin": "GO_TEMPLATE.md" + }, + { + "language": "cel", + "source": "Message string", + "boundaries": [ + 0, + 8 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "Output:", + "boundaries": [ + 0, + 6 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "R'raw\\dstring'", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "Run it with:", + "boundaries": [ + 0, + 4, + 7, + 11 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "[\"a\", \"b\", \"a\", \"c\"].distinct()", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 16, + 19, + 20, + 21, + 29, + 30 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[\"a\", \"b\", \"b\"].uniq()", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 15, + 16, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[\"a\", \"b\", \"c\"].reverse()", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 15, + 16, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[\"apple\", \"banana\", \"cherry\"].size()", + "boundaries": [ + 0, + 1, + 8, + 10, + 18, + 20, + 28, + 29, + 30, + 34, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[\"hello\", \"mellow\"].join(\" \")", + "boundaries": [ + 0, + 1, + 8, + 10, + 18, + 19, + 20, + 24, + 25, + 28 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[\"hello\", \"mellow\"].join()", + "boundaries": [ + 0, + 1, + 8, + 10, + 18, + 19, + 20, + 24, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "['c', 'b', 'a'].sort()", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 15, + 16, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 2, 3, 3, 3].distinct()", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 10, + 11, + 13, + 14, + 16, + 17, + 18, + 19, + 27, + 28 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3, 4].filter(e, e \u003e 2)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 10, + 11, + 12, + 13, + 19, + 20, + 21, + 23, + 25, + 27, + 28 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3, 4].reverse()", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 10, + 11, + 12, + 13, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3, 4].slice(1, 3)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 10, + 11, + 12, + 13, + 18, + 19, + 20, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3, 4].slice(2, 4)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 10, + 11, + 12, + 13, + 18, + 19, + 20, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3].all(e, e \u003e 0)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 17, + 19, + 21, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3].exists(e, e == 2)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 16, + 17, + 18, + 20, + 22, + 25, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3].exists_one(e, e == 2)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 20, + 21, + 22, + 24, + 26, + 29, + 30 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3].exists_one(e, e \u003e 1)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 20, + 21, + 22, + 24, + 26, + 28, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3].fold(e, acc, acc + e)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 14, + 15, + 16, + 18, + 21, + 23, + 27, + 29, + 30 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "[1, 2, 3].map(e, e * 2)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 17, + 19, + 21, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3].map(x, x \u003e 1, x + 1)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 17, + 19, + 21, + 22, + 24, + 26, + 28, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3][?2].orValue(5)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 21, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2][?2].orValue(5)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 18, + 19, + 20 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, [2, [3, 4]]].flatten()", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 6, + 8, + 9, + 10, + 12, + 13, + 14, + 15, + 16, + 17, + 24, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1,2,3,3,3].uniq().sum()", + "boundaries": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 16, + 17, + 18, + 19, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1,2,3].all(e, e \u003e 0)", + "boundaries": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 15, + 17, + 19, + 20 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "[1,2,3].filter(e, e \u003e 1)", + "boundaries": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 14, + 15, + 16, + 18, + 20, + 22, + 23 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "[1,2,3].map(e, e * 2)", + "boundaries": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 15, + 17, + 19, + 20 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "[1,2] + [3,4]", + "boundaries": [ + 0, + 1, + 2, + 3, + 4, + 6, + 8, + 9, + 10, + 11, + 12 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "[3, 2, 1].sort()", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 14, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[[1, 2], [3, 4]].flatten()", + "boundaries": [ + 0, + 1, + 2, + 3, + 5, + 6, + 7, + 9, + 10, + 11, + 13, + 14, + 15, + 16, + 17, + 24, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[].join(\"/\")", + "boundaries": [ + 0, + 1, + 2, + 3, + 7, + 8, + 11 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[{ name: \"John\" }].toJSON()", + "boundaries": [ + 0, + 1, + 3, + 7, + 9, + 16, + 17, + 18, + 19, + 25, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "`escaped.identifier-1`", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "a.?b.orValue(\"x\")", + "boundaries": [ + 0, + 1, + 2, + 3, + 4, + 5, + 12, + 13, + 16 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "aws.arnToMap(\"arn:aws:sns:eu-west-1:123:MMS-Topic\")", + "boundaries": [ + 0, + 3, + 4, + 12, + 13, + 50 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "aws.fromAWSMap(x).hello == \"world\"", + "boundaries": [ + 0, + 3, + 4, + 14, + 15, + 16, + 17, + 18, + 24, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "b\"abc\".size()", + "boundaries": [ + 0, + 6, + 7, + 11, + 12 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "b\"bytes\"", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "base64.decode(\"aGVsbG8=\")", + "boundaries": [ + 0, + 6, + 7, + 13, + 14, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "base64.encode(\"hello\")", + "boundaries": [ + 0, + 6, + 7, + 13, + 14, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "cond ? \"yes\" : \"no\"", + "boundaries": [ + 0, + 5, + 7, + 13, + 15 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "crypto.SHA1(\"hello\")", + "boundaries": [ + 0, + 6, + 7, + 11, + 12, + 19 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "crypto.SHA256(\"hello\")", + "boundaries": [ + 0, + 6, + 7, + 13, + 14, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "duration(\"30m\")", + "boundaries": [ + 0, + 8, + 9, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "duration(\"5h\")", + "boundaries": [ + 0, + 8, + 9, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "duration(\"7d\")", + "boundaries": [ + 0, + 8, + 9, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "dyn(tags).fold(tag, acc, merge(acc, {tag.key: tag.value}))", + "boundaries": [ + 0, + 3, + 4, + 8, + 9, + 10, + 14, + 15, + 18, + 20, + 23, + 25, + 30, + 31, + 34, + 36, + 37, + 40, + 41, + 44, + 46, + 49, + 50, + 55, + 56, + 57 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "exec:", + "boundaries": [ + 0, + 4 + ], + "origin": "GO_TEMPLATE.md" + }, + { + "language": "cel", + "source": "filepath.Base(\"/home/user/projects/gencel\")", + "boundaries": [ + 0, + 8, + 9, + 13, + 14, + 42 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Clean(\"/foo/bar/../baz\")", + "boundaries": [ + 0, + 8, + 9, + 14, + 15, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Dir(\"/home/user/projects/gencel\")", + "boundaries": [ + 0, + 8, + 9, + 12, + 13, + 41 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Ext(\"/opt/image.jpg\")", + "boundaries": [ + 0, + 8, + 9, + 12, + 13, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.IsAbs(\"/home/user/projects/gencel\")", + "boundaries": [ + 0, + 8, + 9, + 14, + 15, + 43 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.IsAbs(\"projects/gencel\")", + "boundaries": [ + 0, + 8, + 9, + 14, + 15, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Join([\"/home/user\", \"projects\", \"gencel\"])", + "boundaries": [ + 0, + 8, + 9, + 13, + 14, + 15, + 27, + 29, + 39, + 41, + 49, + 50 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Match(\"*.txt\", \"foo.json\")", + "boundaries": [ + 0, + 8, + 9, + 14, + 15, + 22, + 24, + 34 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Match(\"*.txt\", \"foo.txt\")", + "boundaries": [ + 0, + 8, + 9, + 14, + 15, + 22, + 24, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Rel(\"/foo/bar\", \"/foo/bar/baz\")", + "boundaries": [ + 0, + 8, + 9, + 12, + 13, + 23, + 25, + 39 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Split(\"/foo/bar/baz\")", + "boundaries": [ + 0, + 8, + 9, + 14, + 15, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "gomplate.Template{Expression: \"person.display_name\"},", + "boundaries": [ + 0, + 8, + 9, + 17, + 18, + 28, + 30, + 51, + 52 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "has(person.name)", + "boundaries": [ + 0, + 3, + 4, + 10, + 11, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "import \"github.com/flanksource/gomplate/v3\"", + "boundaries": [ + 0, + 7 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "jmespath(\"city\", { name: \"John\", age: 30, city: \"NY\" })", + "boundaries": [ + 0, + 8, + 9, + 15, + 17, + 19, + 23, + 25, + 31, + 33, + 36, + 38, + 40, + 42, + 46, + 48, + 53, + 54 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jq(\".[] | select(.age \u003e 25)\", [{ name: \"John\", age: 30 }, { name: \"Jane\", age: 25 }])", + "boundaries": [ + 0, + 2, + 3, + 28, + 30, + 31, + 33, + 37, + 39, + 45, + 47, + 50, + 52, + 55, + 56, + 58, + 60, + 64, + 66, + 72, + 74, + 77, + 79, + 82, + 83, + 84 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jq(\".name\", { name: \"John\", age: 30 })", + "boundaries": [ + 0, + 2, + 3, + 10, + 12, + 14, + 18, + 20, + 26, + 28, + 31, + 33, + 36, + 37 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jq(\"{name, age}\", { name: \"John\", age: 30, city: \"NY\" })", + "boundaries": [ + 0, + 2, + 3, + 16, + 18, + 20, + 24, + 26, + 32, + 34, + 37, + 39, + 41, + 43, + 47, + 49, + 54, + 55 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jsonpath(\"$.addresses[-1:].city\", { addresses: [{city:\"NYC\"},{city:\"SF\"}] })", + "boundaries": [ + 0, + 8, + 9, + 32, + 34, + 36, + 45, + 47, + 48, + 49, + 53, + 54, + 59, + 60, + 61, + 62, + 66, + 67, + 71, + 72, + 74, + 75 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jsonpath(\"$.items[0]\", { items: [\"apple\", \"banana\"] })", + "boundaries": [ + 0, + 8, + 9, + 21, + 23, + 25, + 30, + 32, + 33, + 40, + 42, + 50, + 52, + 53 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jsonpath(\"$.name\", { name: \"John\", age: 30 })", + "boundaries": [ + 0, + 8, + 9, + 17, + 19, + 21, + 25, + 27, + 33, + 35, + 38, + 40, + 43, + 44 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jsonpath(\"$.user.email\", '{\"user\": {\"email\": \"john@example.com\"}}')", + "boundaries": [ + 0, + 8, + 9, + 23, + 25, + 66 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.cpuAsMillicores(\"0.5\")", + "boundaries": [ + 0, + 3, + 4, + 19, + 20, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.cpuAsMillicores(\"1.234\")", + "boundaries": [ + 0, + 3, + 4, + 19, + 20, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.cpuAsMillicores(\"10m\")", + "boundaries": [ + 0, + 3, + 4, + 19, + 20, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.cpuAsMillicores(\"500m\")", + "boundaries": [ + 0, + 3, + 4, + 19, + 20, + 26 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "k8s.getHealth(deployment)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getHealth(pod)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 17 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getHealth(service)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getResourcesLimit(pod, \"cpu\")", + "boundaries": [ + 0, + 3, + 4, + 21, + 22, + 25, + 27, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getResourcesLimit(pod, \"memory\")", + "boundaries": [ + 0, + 3, + 4, + 21, + 22, + 25, + 27, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getResourcesRequests(pod, \"cpu\")", + "boundaries": [ + 0, + 3, + 4, + 24, + 25, + 28, + 30, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getResourcesRequests(pod, \"memory\")", + "boundaries": [ + 0, + 3, + 4, + 24, + 25, + 28, + 30, + 38 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getStatus(deployment)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getStatus(pod)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 17 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getStatus(service)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.isHealthy(deployment)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.isHealthy(pod)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 17 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.isHealthy(pod) \u0026\u0026 pod.status.?phase.orValue(\"\") == \"Running\"", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 17, + 19, + 22, + 25, + 26, + 32, + 33, + 34, + 39, + 40, + 47, + 48, + 50, + 52, + 55 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "k8s.isHealthy(service)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.labels(pod)", + "boundaries": [ + 0, + 3, + 4, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.memoryAsBytes(\"1.234Gi\")", + "boundaries": [ + 0, + 3, + 4, + 17, + 18, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.memoryAsBytes(\"10Ki\")", + "boundaries": [ + 0, + 3, + 4, + 17, + 18, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.memoryAsBytes(\"1Gi\")", + "boundaries": [ + 0, + 3, + 4, + 17, + 18, + 23 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "k8s.nodeProperties(node)", + "boundaries": [ + 0, + 3, + 4, + 18, + 19, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.podProperties(pod)", + "boundaries": [ + 0, + 3, + 4, + 17, + 18, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "keyValToMap(\"a=b,c=d\")", + "boundaries": [ + 0, + 11, + 12, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "keyValToMap(\"env=prod,region=us-east-1\")", + "boundaries": [ + 0, + 11, + 12, + 39 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "l[?index].or(obj.?field.subfield).or(obj.?other)", + "boundaries": [ + 0, + 1, + 2, + 3, + 8, + 9, + 10, + 12, + 13, + 16, + 17, + 18, + 23, + 24, + 32, + 33, + 34, + 36, + 37, + 40, + 41, + 42, + 47 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "m[?\"k\"]", + "boundaries": [ + 0, + 1, + 2, + 3, + 6 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "map[string]any{\"person\": Person{DisplayName: \"Ada\"}},", + "boundaries": [ + 0, + 3, + 4, + 10, + 11, + 14, + 15, + 23, + 25, + 31, + 32, + 43, + 45, + 50, + 51, + 52 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchLabel(config.labels, \"env\", \"!production\")", + "boundaries": [ + 0, + 10, + 11, + 17, + 18, + 24, + 26, + 31, + 33, + 46 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchLabel(config.labels, \"env\", \"prod,staging\")", + "boundaries": [ + 0, + 10, + 11, + 17, + 18, + 24, + 26, + 31, + 33, + 47 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchLabel(config.labels, \"optional\", \"!*\")", + "boundaries": [ + 0, + 10, + 11, + 17, + 18, + 24, + 26, + 36, + 38, + 42 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchLabel(config.labels, \"region\", \"us-*\")", + "boundaries": [ + 0, + 10, + 11, + 17, + 18, + 24, + 26, + 34, + 36, + 42 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchLabel(config.tags, \"cluster\", \"*-prod,*-staging\")", + "boundaries": [ + 0, + 10, + 11, + 17, + 18, + 22, + 24, + 33, + 35, + 53 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchLabel(labels, key, patterns)", + "boundaries": [ + 0, + 10, + 11, + 17, + 19, + 22, + 24, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchQuery(.config, \"type=Kubernetes::Pod tags.cluster=homelab\")", + "boundaries": [ + 0, + 10, + 11, + 12, + 18, + 20, + 63 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchQuery(.config, \"type=Kubernetes::Pod\")", + "boundaries": [ + 0, + 10, + 11, + 12, + 18, + 20, + 42 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchQuery(r, s)", + "boundaries": [ + 0, + 10, + 11, + 12, + 14, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Abs(-1)", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 11 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Add([1, 2, 3, 4, 5])", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 11, + 13, + 14, + 16, + 17, + 19, + 20, + 22, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Add([1,2,3])", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "math.Ceil(2.3)", + "boundaries": [ + 0, + 4, + 5, + 9, + 10, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Div(4, 2)", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Floor(2.3)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.IsFinite(1.0 / 0.0)", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 18, + 20, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.IsFinite(5.0)", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 17 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.IsInf(1.0 / 0.0)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 15, + 17, + 20 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.IsInf(5.0)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.IsNaN(0.0 / 0.0)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 15, + 17, + 20 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.IsNaN(5.0)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Mul([1, 2, 3, 4, 5])", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 11, + 13, + 14, + 16, + 17, + 19, + 20, + 22, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Pow(4, 2)", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Rem(4, 3)", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Round(2.3)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Round(2.5)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Round(2.7)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Seq([1, 5])", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 11, + 13, + 14, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Seq([1, 6, 2])", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 11, + 13, + 14, + 16, + 17, + 18 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Sign(-5)", + "boundaries": [ + 0, + 4, + 5, + 9, + 10, + 11, + 12 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Sign(0)", + "boundaries": [ + 0, + 4, + 5, + 9, + 10, + 11 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Sign(5)", + "boundaries": [ + 0, + 4, + 5, + 9, + 10, + 11 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Sqrt(16)", + "boundaries": [ + 0, + 4, + 5, + 9, + 10, + 12 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Sqrt(2)", + "boundaries": [ + 0, + 4, + 5, + 9, + 10, + 11 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Sub(5, 4)", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Trunc(-2.7)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 12, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Trunc(2.7)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.greatest([1, 2, 3, 4, 5])", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 15, + 16, + 18, + 19, + 21, + 22, + 24, + 25, + 27, + 28, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.greatest([1,2,3])", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "math.least([1, 2, 3, 4, 5])", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 12, + 13, + 15, + 16, + 18, + 19, + 21, + 22, + 24, + 25, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "obj.?a.?b.orValue(\"fallback\")", + "boundaries": [ + 0, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 17, + 18, + 28 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "obj.?field.or(m[?key])", + "boundaries": [ + 0, + 3, + 4, + 5, + 10, + 11, + 13, + 14, + 15, + 16, + 17, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "obj.?field.orValue(\"default\")", + "boundaries": [ + 0, + 3, + 4, + 5, + 10, + 11, + 18, + 19, + 28 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "obj.a.?d.orValue(\"fallback\")", + "boundaries": [ + 0, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 16, + 17, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "obj.a.b", + "boundaries": [ + 0, + 3, + 4, + 5, + 6 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "obj.a.d", + "boundaries": [ + 0, + 3, + 4, + 5, + 6 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "r\"\"\"raw triple \\d\"\"\"", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "r\"raw\\dstring\"", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "random.ASCII(5)", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.Alpha(5)", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.AlphaNum(5)", + "boundaries": [ + 0, + 6, + 7, + 15, + 16, + 17 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.Float(1, 10)", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 14, + 16, + 18 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.Item([\"a\", \"b\", \"c\"])", + "boundaries": [ + 0, + 6, + 7, + 11, + 12, + 13, + 16, + 18, + 21, + 23, + 26, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.Number(1, 10)", + "boundaries": [ + 0, + 6, + 7, + 13, + 14, + 15, + 17, + 19 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.String(5)", + "boundaries": [ + 0, + 6, + 7, + 13, + 14, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.String(5, [\"a\", \"d\"])", + "boundaries": [ + 0, + 6, + 7, + 13, + 14, + 15, + 17, + 18, + 21, + 23, + 26, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "range(0, 10, 2)", + "boundaries": [ + 0, + 5, + 6, + 7, + 9, + 11, + 13, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "range(2, 5)", + "boundaries": [ + 0, + 5, + 6, + 7, + 9, + 10 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "range(5)", + "boundaries": [ + 0, + 5, + 6, + 7 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Find(\"\\\\d+\", \"abc123def\")", + "boundaries": [ + 0, + 6, + 7, + 11, + 12, + 18, + 20, + 31 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Find(\"llo\", \"hello\")", + "boundaries": [ + 0, + 6, + 7, + 11, + 12, + 17, + 19, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Find(\"xyz\", \"hello\")", + "boundaries": [ + 0, + 6, + 7, + 11, + 12, + 17, + 19, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.FindAll(\"\\\\d\", 2, \"12345\")", + "boundaries": [ + 0, + 6, + 7, + 14, + 15, + 20, + 22, + 23, + 25, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.FindAll(\"a.\", -1, \"banana\")", + "boundaries": [ + 0, + 6, + 7, + 14, + 15, + 19, + 21, + 22, + 23, + 25, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.FindAll(\"z\", -1, \"hello\")", + "boundaries": [ + 0, + 6, + 7, + 14, + 15, + 18, + 20, + 21, + 22, + 24, + 31 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Match(\"\\\\d+\", \"abc123\")", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 19, + 21, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Match(\"^b\", \"apple\")", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 17, + 19, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Match(\"^h.llo\", \"hello\")", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 21, + 23, + 30 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.QuoteMeta(\"a.b\")", + "boundaries": [ + 0, + 6, + 7, + 16, + 17, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.QuoteMeta(\"abc\")", + "boundaries": [ + 0, + 6, + 7, + 16, + 17, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Replace(\"\\\\d+\", \"num\", \"abc123\")", + "boundaries": [ + 0, + 6, + 7, + 14, + 15, + 21, + 23, + 28, + 30, + 38 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Replace(\"a.\", \"x\", \"banana\")", + "boundaries": [ + 0, + 6, + 7, + 14, + 15, + 19, + 21, + 24, + 26, + 34 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Replace(\"z\", \"x\", \"apple\")", + "boundaries": [ + 0, + 6, + 7, + 14, + 15, + 18, + 20, + 23, + 25, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.ReplaceLiteral(\"a.\", \"x\", \"a.b c.d\")", + "boundaries": [ + 0, + 6, + 7, + 21, + 22, + 26, + 28, + 31, + 33, + 42 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.ReplaceLiteral(\"apple\", \"orange\", \"apple pie\")", + "boundaries": [ + 0, + 6, + 7, + 21, + 22, + 29, + 31, + 39, + 41, + 52 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Split(\"\\\\s\", 2, \"apple pie is delicious\")", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 18, + 20, + 21, + 23, + 47 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Split(\"a.\", -1, \"banana\")", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 17, + 19, + 20, + 21, + 23, + 31 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Split(\"z\", -1, \"hello\")", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 16, + 18, + 19, + 20, + 22, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "return err", + "boundaries": [ + 0, + 7 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "script: \u003e", + "boundaries": [ + 0, + 6, + 8 + ], + "origin": "GO_TEMPLATE.md" + }, + { + "language": "cel", + "source": "sets.contains([1, 2, 3, 4], [2, 3])", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 15, + 16, + 18, + 19, + 21, + 22, + 24, + 25, + 26, + 28, + 29, + 30, + 32, + 33, + 34 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.contains([], [1])", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.contains([], [])", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 15, + 16, + 18, + 19, + 20 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.equivalent([1, 2, 3], [3u, 2.0, 1])", + "boundaries": [ + 0, + 4, + 5, + 15, + 16, + 17, + 18, + 20, + 21, + 23, + 24, + 25, + 27, + 28, + 30, + 32, + 35, + 37, + 38, + 39 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.equivalent([1], [1, 1])", + "boundaries": [ + 0, + 4, + 5, + 15, + 16, + 17, + 18, + 19, + 21, + 22, + 23, + 25, + 26, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.equivalent([], [])", + "boundaries": [ + 0, + 4, + 5, + 15, + 16, + 17, + 18, + 20, + 21, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.intersects([1], [1, 2])", + "boundaries": [ + 0, + 4, + 5, + 15, + 16, + 17, + 18, + 19, + 21, + 22, + 23, + 25, + 26, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.intersects([1], [])", + "boundaries": [ + 0, + 4, + 5, + 15, + 16, + 17, + 18, + 19, + 21, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "strings.quote('single-quote with \"double quote\"')", + "boundaries": [ + 0, + 7, + 8, + 13, + 14, + 48 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Now()", + "boundaries": [ + 0, + 4, + 5, + 8, + 9 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Parse(\"02-01-2006\", \"26-09-2023\")", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 23, + 25, + 37 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Parse(\"15:04 02-01-2006\", \"14:30 26-09-2023\")", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 29, + 31, + 49 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Parse(\"2006-01-02\", \"2023-09-26\")", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 23, + 25, + 37 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseDuration(\"-2h45m\")", + "boundaries": [ + 0, + 4, + 5, + 18, + 19, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseDuration(\"1h30m\")", + "boundaries": [ + 0, + 4, + 5, + 18, + 19, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseDuration(\"30d12h\")", + "boundaries": [ + 0, + 4, + 5, + 18, + 19, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseDuration(\"7d\")", + "boundaries": [ + 0, + 4, + 5, + 18, + 19, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseInLocation(\"02-01-2006\", \"Europe/London\", \"26-09-2023\")", + "boundaries": [ + 0, + 4, + 5, + 20, + 21, + 33, + 35, + 50, + 52, + 64 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseInLocation(\"15:04 02-01-2006\", \"Asia/Tokyo\", \"14:30 26-09-2023\")", + "boundaries": [ + 0, + 4, + 5, + 20, + 21, + 39, + 41, + 53, + 55, + 73 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseInLocation(\"2006-01-02\", \"America/New_York\", \"2023-09-26\")", + "boundaries": [ + 0, + 4, + 5, + 20, + 21, + 33, + 35, + 53, + 55, + 67 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseLocal(\"2006-01-02 15:04\", \"2023-09-26 14:30\")", + "boundaries": [ + 0, + 4, + 5, + 15, + 16, + 34, + 36, + 54 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Since(time.Now())", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 15, + 16, + 19, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Since(time.Parse(\"2006-01-02\", \"2023-09-26\"))", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 15, + 16, + 21, + 22, + 34, + 36, + 48, + 49 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Since(timestamp(\"2023-01-01T00:00:00Z\"))", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 20, + 21, + 43, + 44 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "time.Until(time.Parse(\"2006-01-02\", \"2023-10-01\"))", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 15, + 16, + 21, + 22, + 34, + 36, + 48, + 49 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ZoneName()", + "boundaries": [ + 0, + 4, + 5, + 13, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ZoneOffset()", + "boundaries": [ + 0, + 4, + 5, + 15, + 16 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "timestamp(\"2023-01-01T00:00:00Z\")", + "boundaries": [ + 0, + 9, + 10, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "timestamp(\"2023-07-04T12:00:00Z\")", + "boundaries": [ + 0, + 9, + 10, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "true ? \"yes\" : \"no\"", + "boundaries": [ + 0, + 5, + 7, + 13, + 15 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "type Config struct {", + "boundaries": [ + 0, + 5, + 12, + 19 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "type Person struct {", + "boundaries": [ + 0, + 5, + 12, + 19 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "type(\"hello\")", + "boundaries": [ + 0, + 4, + 5, + 12 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "type(5)", + "boundaries": [ + 0, + 4, + 5, + 6 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "type([1, 2, 3])", + "boundaries": [ + 0, + 4, + 5, + 6, + 7, + 9, + 10, + 12, + 13, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "type({\"key\": \"value\"})", + "boundaries": [ + 0, + 4, + 5, + 6, + 11, + 13, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "urldecode(\"hello+world+%3F\")", + "boundaries": [ + 0, + 9, + 10, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "urlencode(\"hello world ?\")", + "boundaries": [ + 0, + 9, + 10, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "x \u003e 5 \u0026\u0026 y != null", + "boundaries": [ + 0, + 2, + 4, + 6, + 9, + 11, + 14 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "{\"a\": \"apple\", \"b\": \"banana\"}.all(k, k.startsWith(\"a\"))", + "boundaries": [ + 0, + 1, + 4, + 6, + 13, + 15, + 18, + 20, + 28, + 29, + 30, + 33, + 34, + 35, + 37, + 38, + 39, + 49, + 50, + 53, + 54 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{\"a\": \"apple\", \"b\": \"banana\"}.fold(k, v, acc, acc + v)", + "boundaries": [ + 0, + 1, + 4, + 6, + 13, + 15, + 18, + 20, + 28, + 29, + 30, + 34, + 35, + 36, + 38, + 39, + 41, + 44, + 46, + 50, + 52, + 53 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{\"a\": \"b\", \"c\": \"d\"}.mapToKeyVal()", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 16, + 19, + 20, + 21, + 32, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{\"a\": 1, \"b\": 2}.size()", + "boundaries": [ + 0, + 1, + 4, + 6, + 7, + 9, + 12, + 14, + 15, + 16, + 17, + 21, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{\"a\":1,\"b\":2}.keys()", + "boundaries": [ + 0, + 1, + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 13, + 14, + 18, + 19 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "{\"first\": \"John\", \"last\": \"Doe\"}.keys()", + "boundaries": [ + 0, + 1, + 8, + 10, + 16, + 18, + 24, + 26, + 31, + 32, + 33, + 37, + 38 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{\"first\": \"John\", \"last\": \"Doe\"}.omit([\"first\"])", + "boundaries": [ + 0, + 1, + 8, + 10, + 16, + 18, + 24, + 26, + 31, + 32, + 33, + 37, + 38, + 39, + 46, + 47 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{\"first\": \"John\"}.merge({\"last\": \"Doe\"})", + "boundaries": [ + 0, + 1, + 8, + 10, + 16, + 17, + 18, + 23, + 24, + 25, + 31, + 33, + 38, + 39 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{'a': 'x', 'b': 'y', 'c': 'z'}.?c.orValue('empty')", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 16, + 19, + 21, + 24, + 26, + 29, + 30, + 31, + 32, + 33, + 34, + 41, + 42, + 49 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{'a': 'x', 'b': 'y'}.?c.orValue('empty')", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 31, + 32, + 39 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{'a': 1, 'b': 2}.values()", + "boundaries": [ + 0, + 1, + 4, + 6, + 7, + 9, + 12, + 14, + 15, + 16, + 17, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{'name': 'John'}.toJSON()", + "boundaries": [ + 0, + 1, + 7, + 9, + 15, + 16, + 17, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{'name': 'aditya'}.toJSONPretty('\\t')", + "boundaries": [ + 0, + 1, + 7, + 9, + 17, + 18, + 19, + 31, + 32, + 36 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "}, gomplate.Template{", + "boundaries": [ + 0, + 1, + 3, + 11, + 12, + 20 + ], + "origin": "README.md" + }, + { + "language": "gomplate", + "source": "{{ $x := coll.Dict \"a\" 1 }}{{ $x }}", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "gomplate", + "source": "{{ .name | strings.ToUpper }}", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "gomplate", + "source": "{{ `raw string` }}", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "gomplate", + "source": "{{ printf \"%s-%d\" .name 3 }}", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "gomplate", + "source": "{{- if .enabled -}}on{{- else -}}off{{- end -}}", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "gomplate", + "source": "{{/* a comment with {{ braces }} in it */}}", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "jsonpath", + "source": "$..author", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "jsonpath", + "source": "$.items[*]", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "jsonpath", + "source": "$.items[0:2]", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "jsonpath", + "source": "$.store.book[0].title", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "jsonpath", + "source": "$.store.book[?(@.price \u003c 10)]", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "jsonpath", + "source": "$['quoted key']", + "boundaries": null, + "origin": "edge-case" + } +] diff --git a/web/packages/lang/src/generated/gomplate.config.json b/web/packages/lang/src/generated/gomplate.config.json new file mode 100644 index 000000000..decec0d59 --- /dev/null +++ b/web/packages/lang/src/generated/gomplate.config.json @@ -0,0 +1,55 @@ +{ + "comments": { + "blockComment": [ + "{{/*", + "*/}}" + ] + }, + "brackets": [ + [ + "{{", + "}}" + ], + [ + "(", + ")" + ], + [ + "[", + "]" + ] + ], + "autoClosingPairs": [ + { + "open": "{{", + "close": " }}" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "`", + "close": "`" + } + ], + "surroundingPairs": [ + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "`", + "close": "`" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" +} diff --git a/web/packages/lang/src/generated/gomplate.monarch.json b/web/packages/lang/src/generated/gomplate.monarch.json new file mode 100644 index 000000000..6c33e4fb4 --- /dev/null +++ b/web/packages/lang/src/generated/gomplate.monarch.json @@ -0,0 +1,282 @@ +{ + "defaultToken": "source", + "tokenPostfix": ".gomplate", + "brackets": [ + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + } + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "functions": [ + "add", + "append", + "assert", + "bool", + "coalesce", + "contains", + "csv", + "csvByColumn", + "csvByRow", + "default", + "dict", + "div", + "endsWith", + "fail", + "first", + "flatten", + "getHealth", + "getStatus", + "has", + "hasPrefix", + "hasSuffix", + "humanDuration", + "humanSize", + "in_business_hours", + "indent", + "isHealthy", + "isKind", + "isReady", + "jmespath", + "join", + "jq", + "json", + "jsonArray", + "jsonpath", + "keyValToMap", + "keys", + "kind", + "last", + "mapToKeyVal", + "matchLabel", + "merge", + "mul", + "neat", + "parseDateTime", + "pow", + "prepend", + "quote", + "rem", + "replaceAll", + "required", + "reverse", + "semver", + "semverCompare", + "seq", + "shellQuote", + "slice", + "sort", + "split", + "splitN", + "squote", + "startsWith", + "sub", + "ternary", + "title", + "toCSV", + "toJSON", + "toJSONPretty", + "toLower", + "toTOML", + "toUpper", + "toYAML", + "toml", + "trim", + "uniq", + "urlParse", + "urldecode", + "urlencode", + "values", + "xpath", + "yaml", + "yamlArray" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "tokenizer": { + "root": [ + [ + "^#\\s*gotemplate:.*$", + "comment.directive" + ], + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "[\\s\\S]", + "source" + ] + ], + "tmplComment": [ + [ + "\\*\\/-?\\}\\}", + { + "next": "@pop", + "token": "comment" + } + ], + [ + "[\\s\\S]", + "comment" + ] + ], + "tmplAction": [ + [ + "-?\\}\\}", + { + "next": "@pop", + "token": "delimiter.template" + } + ], + [ + "[ \\t\\r\\n]+", + "white" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "`[^`]*`", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", + "number" + ], + [ + "\\$[A-Za-z_][A-Za-z0-9_]*", + "variable" + ], + [ + "\\$", + "variable" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "variable.field" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@keywords": "keyword", + "@builtins": "function.builtin", + "@functions": "function", + "@default": "identifier" + } + } + ], + [ + "[()\\[\\]]", + "@brackets" + ], + [ + "\\|", + "operator.pipe" + ], + [ + ":=|=", + "operator" + ], + [ + ",", + "delimiter" + ] + ] + } +} diff --git a/web/packages/lang/src/generated/index.ts b/web/packages/lang/src/generated/index.ts new file mode 100644 index 000000000..e3447267d --- /dev/null +++ b/web/packages/lang/src/generated/index.ts @@ -0,0 +1,72 @@ +// Code generated by cmd/genmonarch. DO NOT EDIT. +// +// Regenerate with: make monarch +// The tokenizers come from cel-go's CEL.g4 and text/template's lexer; the +// function catalogue is read from a live cel.Env and gomplate's FuncMap. + +import type { ConformanceCase, LanguageDefinition, GomplateSpec } from "../types"; + +import celMonarch from "./cel.monarch.json"; +import celConfig from "./cel.config.json"; +import gomplateMonarch from "./gomplate.monarch.json"; +import gomplateConfig from "./gomplate.config.json"; +import jsonGomplateMonarch from "./json-gomplate.monarch.json"; +import jsonGomplateConfig from "./json-gomplate.config.json"; +import jsonpathMonarch from "./jsonpath.monarch.json"; +import jsonpathConfig from "./jsonpath.config.json"; +import textGomplateMonarch from "./text-gomplate.monarch.json"; +import textGomplateConfig from "./text-gomplate.config.json"; +import yamlGomplateMonarch from "./yaml-gomplate.monarch.json"; +import yamlGomplateConfig from "./yaml-gomplate.config.json"; +import specJson from "./spec.json"; +import conformanceJson from "./conformance.json"; + +export const spec = specJson as GomplateSpec; + +/** Token boundaries produced by the languages' real lexers. */ +export const conformance = conformanceJson as ConformanceCase[]; + +/** Every language id this package registers. */ +export const LANGUAGE_IDS = [ + "cel", + "gomplate", + "json-gomplate", + "jsonpath", + "text-gomplate", + "yaml-gomplate", +] as const; + +export type LanguageId = (typeof LANGUAGE_IDS)[number]; + +export const definitions: Record = { + "cel": { + id: "cel", + monarch: celMonarch as unknown as LanguageDefinition["monarch"], + configuration: celConfig as unknown as LanguageDefinition["configuration"], + }, + "gomplate": { + id: "gomplate", + monarch: gomplateMonarch as unknown as LanguageDefinition["monarch"], + configuration: gomplateConfig as unknown as LanguageDefinition["configuration"], + }, + "json-gomplate": { + id: "json-gomplate", + monarch: jsonGomplateMonarch as unknown as LanguageDefinition["monarch"], + configuration: jsonGomplateConfig as unknown as LanguageDefinition["configuration"], + }, + "jsonpath": { + id: "jsonpath", + monarch: jsonpathMonarch as unknown as LanguageDefinition["monarch"], + configuration: jsonpathConfig as unknown as LanguageDefinition["configuration"], + }, + "text-gomplate": { + id: "text-gomplate", + monarch: textGomplateMonarch as unknown as LanguageDefinition["monarch"], + configuration: textGomplateConfig as unknown as LanguageDefinition["configuration"], + }, + "yaml-gomplate": { + id: "yaml-gomplate", + monarch: yamlGomplateMonarch as unknown as LanguageDefinition["monarch"], + configuration: yamlGomplateConfig as unknown as LanguageDefinition["configuration"], + }, +}; diff --git a/web/packages/lang/src/generated/json-gomplate.config.json b/web/packages/lang/src/generated/json-gomplate.config.json new file mode 100644 index 000000000..c087049ba --- /dev/null +++ b/web/packages/lang/src/generated/json-gomplate.config.json @@ -0,0 +1,31 @@ +{ + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" +} diff --git a/web/packages/lang/src/generated/json-gomplate.monarch.json b/web/packages/lang/src/generated/json-gomplate.monarch.json new file mode 100644 index 000000000..63a506dce --- /dev/null +++ b/web/packages/lang/src/generated/json-gomplate.monarch.json @@ -0,0 +1,353 @@ +{ + "defaultToken": "", + "tokenPostfix": ".json-gomplate", + "brackets": [ + { + "open": "{", + "close": "}", + "token": "delimiter.curly" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "functions": [ + "add", + "append", + "assert", + "bool", + "coalesce", + "contains", + "csv", + "csvByColumn", + "csvByRow", + "default", + "dict", + "div", + "endsWith", + "fail", + "first", + "flatten", + "getHealth", + "getStatus", + "has", + "hasPrefix", + "hasSuffix", + "humanDuration", + "humanSize", + "in_business_hours", + "indent", + "isHealthy", + "isKind", + "isReady", + "jmespath", + "join", + "jq", + "json", + "jsonArray", + "jsonpath", + "keyValToMap", + "keys", + "kind", + "last", + "mapToKeyVal", + "matchLabel", + "merge", + "mul", + "neat", + "parseDateTime", + "pow", + "prepend", + "quote", + "rem", + "replaceAll", + "required", + "reverse", + "semver", + "semverCompare", + "seq", + "shellQuote", + "slice", + "sort", + "split", + "splitN", + "squote", + "startsWith", + "sub", + "ternary", + "title", + "toCSV", + "toJSON", + "toJSONPretty", + "toLower", + "toTOML", + "toUpper", + "toYAML", + "toml", + "trim", + "uniq", + "urlParse", + "urldecode", + "urlencode", + "values", + "xpath", + "yaml", + "yamlArray" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "tokenizer": { + "root": [ + [ + "^#\\s*gotemplate:.*$", + "comment.directive" + ], + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "(\"(?:[^\"\\\\{]|\\\\.)*\")(\\s*)(:)", + [ + "type.json", + "white", + "delimiter" + ] + ], + [ + "\"", + { + "next": "@jsonString", + "token": "string" + } + ], + [ + "\\b(?:true|false|null)\\b", + "keyword.constant" + ], + [ + "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?", + "number" + ], + [ + "[{}\\[\\]]", + "@brackets" + ], + [ + "[,:]", + "delimiter" + ], + [ + "[\\s\\S]", + "" + ] + ], + "jsonString": [ + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "\"", + { + "next": "@pop", + "token": "string" + } + ], + [ + "\\\\.", + "string.escape" + ], + [ + "[^\"\\\\{]+", + "string" + ], + [ + "[\\s\\S]", + "string" + ] + ], + "tmplComment": [ + [ + "\\*\\/-?\\}\\}", + { + "next": "@pop", + "token": "comment" + } + ], + [ + "[\\s\\S]", + "comment" + ] + ], + "tmplAction": [ + [ + "-?\\}\\}", + { + "next": "@pop", + "token": "delimiter.template" + } + ], + [ + "[ \\t\\r\\n]+", + "white" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "`[^`]*`", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", + "number" + ], + [ + "\\$[A-Za-z_][A-Za-z0-9_]*", + "variable" + ], + [ + "\\$", + "variable" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "variable.field" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@keywords": "keyword", + "@builtins": "function.builtin", + "@functions": "function", + "@default": "identifier" + } + } + ], + [ + "[()\\[\\]]", + "@brackets" + ], + [ + "\\|", + "operator.pipe" + ], + [ + ":=|=", + "operator" + ], + [ + ",", + "delimiter" + ] + ] + } +} diff --git a/web/packages/lang/src/generated/jsonpath.config.json b/web/packages/lang/src/generated/jsonpath.config.json new file mode 100644 index 000000000..a5ec4c9db --- /dev/null +++ b/web/packages/lang/src/generated/jsonpath.config.json @@ -0,0 +1,31 @@ +{ + "brackets": [ + [ + "[", + "]" + ], + [ + "(", + ")" + ] + ], + "autoClosingPairs": [ + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*" +} diff --git a/web/packages/lang/src/generated/jsonpath.monarch.json b/web/packages/lang/src/generated/jsonpath.monarch.json new file mode 100644 index 000000000..ed02d2e1b --- /dev/null +++ b/web/packages/lang/src/generated/jsonpath.monarch.json @@ -0,0 +1,103 @@ +{ + "defaultToken": "", + "tokenPostfix": ".jsonpath", + "brackets": [ + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "filterOperators": [ + "!", + "!=", + "\u0026\u0026", + "*", + "+", + "-", + "/", + "\u003c", + "\u003c=", + "==", + "=~", + "\u003e", + "\u003e=", + "||" + ], + "tokenizer": { + "root": [ + [ + "\\$", + "variable.root" + ], + [ + "@", + "variable.current" + ], + [ + "\\.\\.", + "operator.descendant" + ], + [ + "\\*", + "operator.wildcard" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "delimiter" + ], + [ + "\\?\\(", + "keyword.filter" + ], + [ + "[\\[\\]()]", + "@brackets" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "\\b(?:true|false|null)\\b", + "keyword.constant" + ], + [ + "-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?", + "number" + ], + [ + "(?:==|!=|\u003c=|\u003e=|\u0026\u0026|\\|\\||=~|[\u003c\u003e!+\\-*/])", + "operator" + ], + [ + ":", + "operator.slice" + ], + [ + ",", + "delimiter" + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + "variable.field" + ] + ] + } +} diff --git a/web/packages/lang/src/generated/spec.json b/web/packages/lang/src/generated/spec.json new file mode 100644 index 000000000..57a4fd95c --- /dev/null +++ b/web/packages/lang/src/generated/spec.json @@ -0,0 +1,6189 @@ +{ + "cel": { + "namespaces": [ + "aws", + "base64", + "crypto", + "data", + "filepath", + "json", + "k8s", + "lists", + "math", + "net", + "optional", + "random", + "regexp", + "sets", + "strings", + "test", + "time", + "uuid" + ], + "keywords": [ + "as", + "break", + "const", + "continue", + "else", + "false", + "for", + "function", + "if", + "import", + "in", + "let", + "loop", + "namespace", + "null", + "package", + "return", + "true", + "type", + "var", + "void", + "while" + ], + "types": [ + "bool", + "bytes", + "double", + "dyn", + "duration", + "int", + "list", + "map", + "null_type", + "string", + "timestamp", + "type", + "uint" + ], + "macros": [ + { + "name": "all", + "argCount": 2, + "receiverStyle": true, + "doc": "tests whether all elements in the input list or all keys in a map\nsatisfy the given predicate. The all macro behaves in a manner consistent with\nthe Logical AND operator including in how it absorbs errors and short-circuits.", + "examples": [ + "[1, 2, 3].all(x, x \u003e 0) // true", + "[1, 2, 0].all(x, x \u003e 0) // false", + "['apple', 'banana', 'cherry'].all(fruit, fruit.size() \u003e 3) // true", + "[3.14, 2.71, 1.61].all(num, num \u003c 3.0) // false", + "{'a': 1, 'b': 2, 'c': 3}.all(key, key != 'b') // false", + "// an empty list or map as the range will result in a trivially true result\n[].all(x, x \u003e 0) // true" + ] + }, + { + "name": "exists", + "argCount": 2, + "receiverStyle": true, + "doc": "tests whether any value in the list or any key in the map\nsatisfies the predicate expression. The exists macro behaves in a manner\nconsistent with the Logical OR operator including in how it absorbs errors and\nshort-circuits.", + "examples": [ + "[1, 2, 3].exists(i, i % 2 != 0) // true", + "[0, -1, 5].exists(num, num \u003c 0) // true", + "{'x': 'foo', 'y': 'bar'}.exists(key, key.startsWith('z')) // false", + "// an empty list or map as the range will result in a trivially false result\n[].exists(i, i \u003e 0) // false", + "// test whether a key name equalling 'iss' exists in the map and the\n// value contains the substring 'cel.dev'\n// tokens = {'sub': 'me', 'iss': 'https://issuer.cel.dev'}\ntokens.exists(k, k == 'iss' \u0026\u0026 tokens[k].contains('cel.dev'))" + ] + }, + { + "name": "exists_one", + "argCount": 2, + "receiverStyle": true, + "doc": "tests whether exactly one list element or map key satisfies\nthe predicate expression. This macro does not short-circuit in order to remain\nconsistent with logical operators being the only operators which can absorb\nerrors within CEL.", + "examples": [ + "[1, 2, 2].exists_one(i, i \u003c 2) // true", + "{'a': 'hello', 'aa': 'hellohello'}.exists_one(k, k.startsWith('a')) // false", + "[1, 2, 3, 4].exists_one(num, num % 2 == 0) // false", + "// ensure exactly one key in the map ends in @acme.co\n{'wiley@acme.co': 'coyote', 'aa@milne.co': 'bear'}.exists_one(k, k.endsWith('@acme.co')) // true" + ] + }, + { + "name": "filter", + "argCount": 2, + "receiverStyle": true, + "doc": "returns a list containing only the elements from the input list\nthat satisfy the given predicate", + "examples": [ + "[1, 2, 3].filter(x, x \u003e 1) // [2, 3]", + "['cat', 'dog', 'bird', 'fish'].filter(pet, pet.size() == 3) // ['cat', 'dog']", + "[{'a': 10, 'b': 5, 'c': 20}].map(m, m.filter(key, m[key] \u003e 10)) // [['c']]", + "// filter a list to select only emails with the @cel.dev suffix\n['alice@buf.io', 'tristan@cel.dev'].filter(v, v.endsWith('@cel.dev')) // ['tristan@cel.dev']", + "// filter a map into a list, selecting only the values for keys that start with 'http-auth'\n{'http-auth-agent': 'secret', 'user-agent': 'mozilla'}.filter(k,\n k.startsWith('http-auth')) // ['secret']" + ] + }, + { + "name": "fold", + "argCount": 3, + "receiverStyle": true, + "doc": "Folds a list using an element variable, an accumulator variable, and a step expression.", + "examples": [ + "[1, 2, 3].fold(e, acc, acc + e) // 6" + ] + }, + { + "name": "fold", + "argCount": 4, + "receiverStyle": true, + "doc": "Folds a map using key/value variables, an accumulator variable, and a step expression.", + "examples": [ + "{\"a\": \"apple\", \"b\": \"banana\"}.fold(k, v, acc, acc + v) // \"applebanana\"" + ] + }, + { + "name": "greatest", + "argCount": 0, + "receiverStyle": true + }, + { + "name": "has", + "argCount": 1, + "receiverStyle": false, + "doc": "check a protocol buffer message for the presence of a field, or check a map\nfor the presence of a string key.\nOnly map accesses using the select notation are supported.", + "examples": [ + "// true if the 'address' field exists in the 'user' message\nhas(user.address)", + "// test whether the 'key_name' is set on the map which defines it\nhas({'key_name': 'value'}.key_name) // true", + "// test whether the 'id' field is set to a non-default value on the Expr{} message literal\nhas(Expr{}.id) // false" + ] + }, + { + "name": "least", + "argCount": 0, + "receiverStyle": true + }, + { + "name": "map", + "argCount": 2, + "receiverStyle": true, + "doc": "the three-argument form of map transforms all elements in the input range.", + "examples": [ + "[1, 2, 3].map(x, x * 2) // [2, 4, 6]", + "[5, 10, 15].map(x, x / 5) // [1, 2, 3]", + "['apple', 'banana'].map(fruit, fruit.upperAscii()) // ['APPLE', 'BANANA']", + "// Combine all map key-value pairs into a list\n{'hi': 'you', 'howzit': 'bruv'}.map(k,\n k + \":\" + {'hi': 'you', 'howzit': 'bruv'}[k]) // ['hi:you', 'howzit:bruv']" + ] + }, + { + "name": "map", + "argCount": 3, + "receiverStyle": true, + "doc": "the four-argument form of the map transforms only elements which satisfy\nthe predicate which is equivalent to chaining the filter and three-argument\nmap macros together.", + "examples": [ + "// multiply only numbers divisible two, by 2\n[1, 2, 3, 4].map(num, num % 2 == 0, num * 2) // [4, 8]" + ] + }, + { + "name": "optFlatMap", + "argCount": 2, + "receiverStyle": true, + "doc": "perform computation on the value if present and produce an optional value within the computation", + "examples": [ + "// m = {'key': {}}\nm.?key.optFlatMap(k, k.?subkey) // optional.none()", + "// m = {'key': {'subkey': 'value'}}\nm.?key.optFlatMap(k, k.?subkey) // optional.of('value')" + ] + }, + { + "name": "optMap", + "argCount": 2, + "receiverStyle": true, + "doc": "perform computation on the value if present and return the result as an optional", + "examples": [ + "// sub with the prefix 'dev.cel' or optional.none()\nrequest.auth.tokens.?sub.optMap(id, 'dev.cel.' + id)", + "optional.none().optMap(i, i * 2) // optional.none()" + ] + }, + { + "name": "sortBy", + "argCount": 2, + "receiverStyle": true + } + ], + "functions": [ + { + "name": "Age", + "overloads": [ + { + "id": "duration.Age", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Duration" + } + ] + }, + { + "name": "Append", + "overloads": [ + { + "id": "Append_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "CSV", + "overloads": [ + { + "id": "CSV_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "Contains", + "overloads": [ + { + "id": "Contains_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "Dict", + "overloads": [ + { + "id": "Dict_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "Duration", + "overloads": [ + { + "id": "duration.Duration", + "args": [ + "string" + ], + "result": "google.protobuf.Duration" + } + ] + }, + { + "name": "GetHealth", + "overloads": [ + { + "id": "GetHealth_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "GetStatus", + "overloads": [ + { + "id": "GetStatus_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "Has", + "overloads": [ + { + "id": "Has_interface{}_any", + "args": [ + "dyn", + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "HumanDuration", + "overloads": [ + { + "id": "HumanDuration_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "HumanSize", + "overloads": [ + { + "id": "HumanSize_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "IsHealthy", + "overloads": [ + { + "id": "IsHealthy_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "bool" + } + ] + }, + { + "name": "IsReady", + "overloads": [ + { + "id": "IsReady_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "bool" + } + ] + }, + { + "name": "JSON", + "memberOnly": true, + "overloads": [ + { + "id": ".string.JSON()", + "args": [ + "string" + ], + "result": "dyn", + "member": true + } + ] + }, + { + "name": "JSONArray", + "memberOnly": true, + "overloads": [ + { + "id": ".string.JSONArray()", + "args": [ + "string" + ], + "result": "dyn", + "member": true + } + ] + }, + { + "name": "Prepend", + "overloads": [ + { + "id": "Prepend_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "Semver", + "overloads": [ + { + "id": "Semver_string", + "args": [ + "string" + ], + "result": "dyn" + } + ] + }, + { + "name": "SemverCompare", + "overloads": [ + { + "id": "SemverCompare_string_string", + "args": [ + "string", + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "Sort", + "overloads": [ + { + "id": "Sort_string", + "args": [ + "string" + ], + "result": "dyn" + } + ] + }, + { + "name": "SplitN", + "overloads": [ + { + "id": "SplitN_string_int_interface{}", + "args": [ + "string", + "int", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "TOML", + "overloads": [ + { + "id": "TOML_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "YAML", + "overloads": [ + { + "id": "YAML_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "YAMLArray", + "overloads": [ + { + "id": "data.YAMLArray_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "abbrev", + "memberOnly": true, + "overloads": [ + { + "id": "stringsAbbrevWidthAndOffsetGen", + "args": [ + "string", + "int", + "int" + ], + "result": "string", + "member": true + }, + { + "id": "stringsAbbrevWidthGen", + "args": [ + "string", + "int" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "age", + "overloads": [ + { + "id": "duration.age", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Duration" + } + ] + }, + { + "name": "arnToMap", + "overloads": [ + { + "id": "arnToMap_overload", + "args": [ + "string" + ], + "result": "map(string, string)" + } + ] + }, + { + "name": "aws.arnToMap", + "namespace": "aws", + "overloads": [ + { + "id": "aws.arnToMap_overload", + "args": [ + "string" + ], + "result": "map(string, string)" + } + ] + }, + { + "name": "aws.fromAWSMap", + "namespace": "aws", + "overloads": [ + { + "id": "aws.fromAWSMap_overload", + "args": [ + "list(map(string, string))" + ], + "result": "map(string, string)" + } + ] + }, + { + "name": "base64.decode", + "namespace": "base64", + "overloads": [ + { + "id": "base64_decode_string", + "args": [ + "string" + ], + "result": "bytes" + } + ] + }, + { + "name": "base64.encode", + "namespace": "base64", + "overloads": [ + { + "id": "base64_encode_bytes", + "args": [ + "bytes" + ], + "result": "string" + } + ] + }, + { + "name": "bool", + "doc": "convert a value to a boolean", + "overloads": [ + { + "id": "bool_to_bool", + "args": [ + "bool" + ], + "result": "bool" + }, + { + "id": "string_to_bool", + "args": [ + "string" + ], + "result": "bool" + } + ], + "examples": [ + "bool(true) // true", + "bool('true') // true\nbool('false') // false" + ] + }, + { + "name": "bytes", + "doc": "convert a value to bytes", + "overloads": [ + { + "id": "bytes_to_bytes", + "args": [ + "bytes" + ], + "result": "bytes" + }, + { + "id": "string_to_bytes", + "args": [ + "string" + ], + "result": "bytes" + } + ], + "examples": [ + "bytes(b'abc') // b'abc'", + "bytes('hello') // b'hello'" + ] + }, + { + "name": "camelCase", + "memberOnly": true, + "overloads": [ + { + "id": "string_camel_case", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "charAt", + "memberOnly": true, + "overloads": [ + { + "id": "string_char_at_int", + "args": [ + "string", + "int" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "coalesce", + "overloads": [ + { + "id": "coalesce_1", + "args": [ + "dyn" + ], + "result": "dyn" + }, + { + "id": "coalesce_2", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + }, + { + "id": "coalesce_3", + "args": [ + "dyn", + "dyn", + "dyn" + ], + "result": "dyn" + }, + { + "id": "coalesce_4", + "args": [ + "dyn", + "dyn", + "dyn", + "dyn" + ], + "result": "dyn" + }, + { + "id": "coalesce_5", + "args": [ + "dyn", + "dyn", + "dyn", + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "contains", + "memberOnly": true, + "doc": "test whether a string contains a substring", + "overloads": [ + { + "id": "contains_string", + "args": [ + "string", + "string" + ], + "result": "bool", + "member": true + }, + { + "id": "list_a_contains_bool", + "args": [ + "list(\u003cA\u003e)", + "\u003cA\u003e" + ], + "result": "bool", + "member": true + } + ], + "examples": [ + "'hello world'.contains('o w') // true\n'hello world'.contains('goodbye') // false" + ] + }, + { + "name": "crypto.SHA1", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA1_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA1Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA1Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "crypto.SHA224", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA224_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA224Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA224Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "crypto.SHA256", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA256_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA256Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA256Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "crypto.SHA384", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA384_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA384Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA384Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "crypto.SHA512", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA512_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA512Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA512Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "crypto.SHA512_224", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA512_224_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA512_224Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA512_224Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "crypto.SHA512_256", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA512_256_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA512_256Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA512_256Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "data.CSVByColumn", + "namespace": "data", + "overloads": [ + { + "id": "data.CSVByColumn_string", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "data.CSVByRow", + "namespace": "data", + "overloads": [ + { + "id": "data.CSVByRow_string", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "debug", + "overloads": [ + { + "id": "debug_dyn", + "args": [ + "dyn" + ], + "result": "dyn" + }, + { + "id": "debug_string_dyn", + "args": [ + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "distinct", + "memberOnly": true, + "overloads": [ + { + "id": "list_distinct", + "args": [ + "list(\u003cT\u003e)" + ], + "result": "list(\u003cT\u003e)", + "member": true + } + ] + }, + { + "name": "double", + "doc": "convert a value to a double", + "overloads": [ + { + "id": "double_to_double", + "args": [ + "double" + ], + "result": "double" + }, + { + "id": "int64_to_double", + "args": [ + "int" + ], + "result": "double" + }, + { + "id": "string_to_double", + "args": [ + "string" + ], + "result": "double" + }, + { + "id": "uint64_to_double", + "args": [ + "uint" + ], + "result": "double" + } + ], + "examples": [ + "double(1.23) // 1.23", + "double(123) // 123.0", + "double('1.23') // 1.23", + "double(123u) // 123.0" + ] + }, + { + "name": "duration", + "doc": "convert a value to a google.protobuf.Duration", + "overloads": [ + { + "id": "double.duration", + "args": [ + "double" + ], + "result": "google.protobuf.Duration" + }, + { + "id": "duration_to_duration", + "args": [ + "google.protobuf.Duration" + ], + "result": "google.protobuf.Duration" + }, + { + "id": "string_to_duration", + "args": [ + "string" + ], + "result": "google.protobuf.Duration" + } + ], + "examples": [ + "duration(duration('1s')) // duration('1s')", + "duration('1h2m3s') // duration('3723s')" + ] + }, + { + "name": "dyn", + "doc": "indicate that the type is dynamic for type-checking purposes", + "overloads": [ + { + "id": "to_dyn", + "args": [ + "\u003cA\u003e" + ], + "result": "dyn" + } + ], + "examples": [ + "dyn(1) // 1" + ] + }, + { + "name": "endsWith", + "memberOnly": true, + "doc": "test whether a string ends with a substring suffix", + "overloads": [ + { + "id": "ends_with_string", + "args": [ + "string", + "string" + ], + "result": "bool", + "member": true + } + ], + "examples": [ + "'hello world'.endsWith('world') // true\n'hello world'.endsWith('hello') // false" + ] + }, + { + "name": "f", + "overloads": [ + { + "id": "f_string_any", + "args": [ + "string", + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.Base", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Base_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.Clean", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Clean_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.Dir", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Dir_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.Ext", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Ext_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.FromSlash", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.FromSlash_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.IsAbs", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.IsAbs_interface{}", + "args": [ + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "filepath.Join", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Join_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.Match", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Match_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "filepath.Rel", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Rel_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "filepath.Split", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Split_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "filepath.ToSlash", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.ToSlash_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.VolumeName", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.VolumeName_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "find", + "memberOnly": true, + "overloads": [ + { + "id": "string_find_string", + "args": [ + "string", + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "findAll", + "memberOnly": true, + "overloads": [ + { + "id": "string_find_all_string", + "args": [ + "string", + "string" + ], + "result": "list(string)", + "member": true + }, + { + "id": "string_find_all_string_int", + "args": [ + "string", + "string", + "int" + ], + "result": "list(string)", + "member": true + } + ] + }, + { + "name": "first", + "overloads": [ + { + "id": "dyn_first", + "args": [ + "dyn" + ], + "result": "dyn", + "member": true + }, + { + "id": "first_dyn", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "flatten", + "memberOnly": true, + "overloads": [ + { + "id": "list_flatten", + "args": [ + "list(list(\u003cT\u003e))" + ], + "result": "list(\u003cT\u003e)", + "member": true + }, + { + "id": "list_flatten_int", + "args": [ + "list(dyn)", + "int" + ], + "result": "list(dyn)", + "member": true + } + ] + }, + { + "name": "format", + "memberOnly": true, + "overloads": [ + { + "id": "string_format", + "args": [ + "string", + "list(dyn)" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "fromAWSMap", + "overloads": [ + { + "id": "fromAWSMap_overload", + "args": [ + "list(map(string, string))" + ], + "result": "map(string, string)" + } + ] + }, + { + "name": "getDate", + "memberOnly": true, + "doc": "get the 1-based day of the month from a timestamp, UTC unless an IANA timezone is specified.", + "overloads": [ + { + "id": "timestamp_to_day_of_month_1_based", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_day_of_month_1_based_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getDate() // 14", + "timestamp('2023-07-01T05:00:00Z').getDate('America/Los_Angeles') // 30" + ] + }, + { + "name": "getDayOfMonth", + "memberOnly": true, + "doc": "get the 0-based day of the month from a timestamp, UTC unless an IANA timezone is specified.", + "overloads": [ + { + "id": "timestamp_to_day_of_month", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_day_of_month_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getDayOfMonth() // 13", + "timestamp('2023-07-01T05:00:00Z').getDayOfMonth('America/Los_Angeles') // 29" + ] + }, + { + "name": "getDayOfWeek", + "memberOnly": true, + "doc": "get the 0-based day of the week from a timestamp, UTC unless an IANA timezone is specified.", + "overloads": [ + { + "id": "timestamp_to_day_of_week", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_day_of_week_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getDayOfWeek() // 5", + "timestamp('2023-07-16T05:00:00Z').getDayOfWeek('America/Los_Angeles') // 6" + ] + }, + { + "name": "getDayOfYear", + "memberOnly": true, + "doc": "get the 0-based day of the year from a timestamp, UTC unless an IANA timezone is specified.", + "overloads": [ + { + "id": "timestamp_to_day_of_year", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_day_of_year_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-01-02T00:00:00Z').getDayOfYear() // 1", + "timestamp('2023-01-01T05:00:00Z').getDayOfYear('America/Los_Angeles') // 364" + ] + }, + { + "name": "getEscapedPath", + "memberOnly": true, + "overloads": [ + { + "id": "url_get_escaped_path", + "args": [ + "kubernetes.URL" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "getFullYear", + "memberOnly": true, + "doc": "get the 0-based full year from a timestamp, UTC unless an IANA timezone is specified.", + "overloads": [ + { + "id": "timestamp_to_year", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_year_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getFullYear() // 2023", + "timestamp('2023-01-01T05:30:00Z').getFullYear('-08:00') // 2022" + ] + }, + { + "name": "getHost", + "memberOnly": true, + "overloads": [ + { + "id": "url_get_host", + "args": [ + "kubernetes.URL" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "getHostname", + "memberOnly": true, + "overloads": [ + { + "id": "url_get_hostname", + "args": [ + "kubernetes.URL" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "getHours", + "memberOnly": true, + "doc": "get the hours portion from a timestamp, or convert a duration to hours", + "overloads": [ + { + "id": "duration_to_hours", + "args": [ + "google.protobuf.Duration" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_hours", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_hours_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getHours() // 10", + "timestamp('2023-07-14T10:30:45.123Z').getHours('America/Los_Angeles') // 2", + "duration('3723s').getHours() // 1" + ] + }, + { + "name": "getMilliseconds", + "memberOnly": true, + "doc": "get the milliseconds portion from a timestamp", + "overloads": [ + { + "id": "duration_to_milliseconds", + "args": [ + "google.protobuf.Duration" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_milliseconds", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_milliseconds_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getMilliseconds() // 123", + "timestamp('2023-07-14T10:30:45.123Z').getMilliseconds('America/Los_Angeles') // 123" + ] + }, + { + "name": "getMinutes", + "memberOnly": true, + "doc": "get the minutes portion from a timestamp, or convert a duration to minutes", + "overloads": [ + { + "id": "duration_to_minutes", + "args": [ + "google.protobuf.Duration" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_minutes", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_minutes_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getMinutes() // 30", + "timestamp('2023-07-14T10:30:45.123Z').getMinutes('America/Los_Angeles') // 30", + "duration('3723s').getMinutes() // 62" + ] + }, + { + "name": "getMonth", + "memberOnly": true, + "doc": "get the 0-based month from a timestamp, UTC unless an IANA timezone is specified.", + "overloads": [ + { + "id": "timestamp_to_month", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_month_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getMonth() // 6", + "timestamp('2023-01-01T05:30:00Z').getMonth('America/Los_Angeles') // 11" + ] + }, + { + "name": "getPort", + "memberOnly": true, + "overloads": [ + { + "id": "url_get_port", + "args": [ + "kubernetes.URL" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "getQuery", + "memberOnly": true, + "overloads": [ + { + "id": "url_get_query", + "args": [ + "kubernetes.URL" + ], + "result": "map(string, list(string))", + "member": true + } + ] + }, + { + "name": "getScheme", + "memberOnly": true, + "overloads": [ + { + "id": "url_get_scheme", + "args": [ + "kubernetes.URL" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "getSeconds", + "memberOnly": true, + "doc": "get the seconds portion from a timestamp, or convert a duration to seconds", + "overloads": [ + { + "id": "duration_to_seconds", + "args": [ + "google.protobuf.Duration" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_seconds", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_seconds_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getSeconds() // 45", + "timestamp('2023-07-14T10:30:45.123Z').getSeconds('America/Los_Angeles') // 45", + "duration('3723.456s').getSeconds() // 3723" + ] + }, + { + "name": "hasValue", + "memberOnly": true, + "doc": "determine whether the optional contains a value", + "overloads": [ + { + "id": "optional_hasValue", + "args": [ + "optional_type(\u003cV\u003e)" + ], + "result": "bool", + "member": true + } + ], + "examples": [ + "optional.of({1: 2}).hasValue() // true" + ] + }, + { + "name": "in", + "overloads": [ + { + "id": "in_list", + "args": [ + "\u003cA\u003e", + "list(\u003cA\u003e)" + ], + "result": "bool" + }, + { + "id": "in_map", + "args": [ + "\u003cA\u003e", + "map(\u003cA\u003e, \u003cB\u003e)" + ], + "result": "bool" + } + ] + }, + { + "name": "in_business_hours", + "overloads": [ + { + "id": "in_business_hours_string", + "args": [ + "string" + ], + "result": "dyn" + } + ] + }, + { + "name": "indent", + "memberOnly": true, + "overloads": [ + { + "id": "string_indent", + "args": [ + "string", + "string" + ], + "result": "string", + "member": true + }, + { + "id": "string_indent_with_width", + "args": [ + "string", + "int", + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "indexOf", + "memberOnly": true, + "overloads": [ + { + "id": "list_a_index_of_int", + "args": [ + "list(\u003cA\u003e)", + "\u003cA\u003e" + ], + "result": "int", + "member": true + }, + { + "id": "string_index_of_string", + "args": [ + "string", + "string" + ], + "result": "int", + "member": true + }, + { + "id": "string_index_of_string_int", + "args": [ + "string", + "string", + "int" + ], + "result": "int", + "member": true + } + ] + }, + { + "name": "int", + "doc": "convert a value to an int", + "overloads": [ + { + "id": "double_to_int64", + "args": [ + "double" + ], + "result": "int" + }, + { + "id": "duration_to_int64", + "args": [ + "google.protobuf.Duration" + ], + "result": "int" + }, + { + "id": "int64_to_int64", + "args": [ + "int" + ], + "result": "int" + }, + { + "id": "string_to_int64", + "args": [ + "string" + ], + "result": "int" + }, + { + "id": "timestamp_to_int64", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int" + }, + { + "id": "uint64_to_int64", + "args": [ + "uint" + ], + "result": "int" + } + ], + "examples": [ + "int(123) // 123", + "int(123.45) // 123", + "int(duration('1s')) // 1000000000", + "int('123') // 123\nint('-456') // -456", + "int(timestamp('1970-01-01T00:00:01Z')) // 1", + "int(123u) // 123" + ] + }, + { + "name": "isSorted", + "memberOnly": true, + "overloads": [ + { + "id": "list_bool_is_sorted_bool", + "args": [ + "list(bool)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_bytes_is_sorted_bool", + "args": [ + "list(bytes)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_double_is_sorted_bool", + "args": [ + "list(double)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_duration_is_sorted_bool", + "args": [ + "list(google.protobuf.Duration)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_int_is_sorted_bool", + "args": [ + "list(int)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_string_is_sorted_bool", + "args": [ + "list(string)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_timestamp_is_sorted_bool", + "args": [ + "list(google.protobuf.Timestamp)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_uint_is_sorted_bool", + "args": [ + "list(uint)" + ], + "result": "bool", + "member": true + } + ] + }, + { + "name": "isURL", + "overloads": [ + { + "id": "is_url_string", + "args": [ + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "jmespath", + "overloads": [ + { + "id": "jmespath_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "join", + "memberOnly": true, + "overloads": [ + { + "id": "list_join", + "args": [ + "list(string)" + ], + "result": "string", + "member": true + }, + { + "id": "list_join_string", + "args": [ + "list(string)", + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "jq", + "overloads": [ + { + "id": "jq_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "json.encode", + "namespace": "json", + "overloads": [ + { + "id": "json_encode_dyn", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "jsonpath", + "overloads": [ + { + "id": "jsonpath_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "k8s.cpuAsMillicores", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.cpuAsMillicores_string", + "args": [ + "string" + ], + "result": "int" + } + ] + }, + { + "name": "k8s.getHealth", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.getHealth_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "k8s.getResourcesLimit", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.getResourcesLimit_obj_str_int", + "args": [ + "google.protobuf.Any", + "string" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "k8s.getResourcesRequests", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.getResourcesRequests_obj_str_int", + "args": [ + "google.protobuf.Any", + "string" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "k8s.getStatus", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.getStatus_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "k8s.isHealthy", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.isHealthy_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "bool" + } + ] + }, + { + "name": "k8s.isReady", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.isReady_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "bool" + } + ] + }, + { + "name": "k8s.is_healthy", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.is_healthy_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "bool" + } + ] + }, + { + "name": "k8s.labels", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.labels_map_map", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "k8s.memoryAsBytes", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.memoryAsBytes_string", + "args": [ + "string" + ], + "result": "int" + } + ] + }, + { + "name": "k8s.neat", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s_neat", + "args": [ + "dyn" + ], + "result": "string" + }, + { + "id": "k8s_neat_with_option", + "args": [ + "dyn", + "string" + ], + "result": "string" + } + ] + }, + { + "name": "k8s.nodeProperties", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.nodeProperties_list_dyn_map", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "k8s.podProperties", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.podProperties_list_dyn_map", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "kebabCase", + "memberOnly": true, + "overloads": [ + { + "id": "string_kebab_case", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "keyValToMap", + "overloads": [ + { + "id": "keyValToMap_interface{}", + "args": [ + "google.protobuf.Any" + ], + "result": "map(string, google.protobuf.Any)" + } + ] + }, + { + "name": "keys", + "memberOnly": true, + "overloads": [ + { + "id": "map_keys", + "args": [ + "map(string, google.protobuf.Any)" + ], + "result": "list(string)", + "member": true + } + ] + }, + { + "name": "last", + "overloads": [ + { + "id": "dyn_last", + "args": [ + "dyn" + ], + "result": "dyn", + "member": true + }, + { + "id": "last_dyn", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "lastIndexOf", + "memberOnly": true, + "overloads": [ + { + "id": "list_a_last_index_of_int", + "args": [ + "list(\u003cA\u003e)", + "\u003cA\u003e" + ], + "result": "int", + "member": true + }, + { + "id": "string_last_index_of_string", + "args": [ + "string", + "string" + ], + "result": "int", + "member": true + }, + { + "id": "string_last_index_of_string_int", + "args": [ + "string", + "string", + "int" + ], + "result": "int", + "member": true + } + ] + }, + { + "name": "lists.range", + "namespace": "lists", + "overloads": [ + { + "id": "lists_range", + "args": [ + "int" + ], + "result": "list(int)" + } + ] + }, + { + "name": "lowerAscii", + "memberOnly": true, + "overloads": [ + { + "id": "string_lower_ascii", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "mapToKeyVal", + "overloads": [ + { + "id": "mapToKeyVal_interface{}", + "args": [ + "map(string, google.protobuf.Any)" + ], + "result": "string" + } + ] + }, + { + "name": "match", + "memberOnly": true, + "overloads": [ + { + "id": "string_match_string", + "args": [ + "string", + "string" + ], + "result": "bool", + "member": true + } + ] + }, + { + "name": "matchLabel", + "overloads": [ + { + "id": "matchLabel_map_string_string", + "args": [ + "map(string, dyn)", + "string", + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "matches", + "doc": "test whether a string matches an RE2 regular expression", + "overloads": [ + { + "id": "matches", + "args": [ + "string", + "string" + ], + "result": "bool" + }, + { + "id": "matches_string", + "args": [ + "string", + "string" + ], + "result": "bool", + "member": true + } + ], + "examples": [ + "matches('123-456', '^[0-9]+(-[0-9]+)?$') // true\nmatches('hello', '^h.*o$') // true", + "'123-456'.matches('^[0-9]+(-[0-9]+)?$') // true\n'hello'.matches('^h.*o$') // true" + ] + }, + { + "name": "math.Abs", + "namespace": "math", + "overloads": [ + { + "id": "math.Abs_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Add", + "namespace": "math", + "overloads": [ + { + "id": "math.Add_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Ceil", + "namespace": "math", + "overloads": [ + { + "id": "math.Ceil_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Div", + "namespace": "math", + "overloads": [ + { + "id": "math.Div_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Floor", + "namespace": "math", + "overloads": [ + { + "id": "math.Floor_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.IsFloat", + "namespace": "math", + "overloads": [ + { + "id": "math.IsFloat_interface{}", + "args": [ + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "math.IsInt", + "namespace": "math", + "overloads": [ + { + "id": "math.IsInt_interface{}", + "args": [ + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "math.IsNum", + "namespace": "math", + "overloads": [ + { + "id": "math.IsNum_interface{}", + "args": [ + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "math.Mul", + "namespace": "math", + "overloads": [ + { + "id": "math.Mul_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Pow", + "namespace": "math", + "overloads": [ + { + "id": "math.Pow_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Rem", + "namespace": "math", + "overloads": [ + { + "id": "math.Rem_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Round", + "namespace": "math", + "overloads": [ + { + "id": "math.Round_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Seq", + "namespace": "math", + "overloads": [ + { + "id": "math.Seq_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Sub", + "namespace": "math", + "overloads": [ + { + "id": "math.Sub_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.abs", + "namespace": "math", + "overloads": [ + { + "id": "math_abs_double", + "args": [ + "double" + ], + "result": "double" + }, + { + "id": "math_abs_int", + "args": [ + "int" + ], + "result": "int" + }, + { + "id": "math_abs_uint", + "args": [ + "uint" + ], + "result": "uint" + } + ] + }, + { + "name": "math.bitAnd", + "namespace": "math", + "overloads": [ + { + "id": "math_bitAnd_int_int", + "args": [ + "int", + "int" + ], + "result": "int" + }, + { + "id": "math_bitAnd_uint_uint", + "args": [ + "uint", + "uint" + ], + "result": "uint" + } + ] + }, + { + "name": "math.bitNot", + "namespace": "math", + "overloads": [ + { + "id": "math_bitNot_int_int", + "args": [ + "int" + ], + "result": "int" + }, + { + "id": "math_bitNot_uint_uint", + "args": [ + "uint" + ], + "result": "uint" + } + ] + }, + { + "name": "math.bitOr", + "namespace": "math", + "overloads": [ + { + "id": "math_bitOr_int_int", + "args": [ + "int", + "int" + ], + "result": "int" + }, + { + "id": "math_bitOr_uint_uint", + "args": [ + "uint", + "uint" + ], + "result": "uint" + } + ] + }, + { + "name": "math.bitShiftLeft", + "namespace": "math", + "overloads": [ + { + "id": "math_bitShiftLeft_int_int", + "args": [ + "int", + "int" + ], + "result": "int" + }, + { + "id": "math_bitShiftLeft_uint_int", + "args": [ + "uint", + "int" + ], + "result": "uint" + } + ] + }, + { + "name": "math.bitShiftRight", + "namespace": "math", + "overloads": [ + { + "id": "math_bitShiftRight_int_int", + "args": [ + "int", + "int" + ], + "result": "int" + }, + { + "id": "math_bitShiftRight_uint_int", + "args": [ + "uint", + "int" + ], + "result": "uint" + } + ] + }, + { + "name": "math.bitXor", + "namespace": "math", + "overloads": [ + { + "id": "math_bitXor_int_int", + "args": [ + "int", + "int" + ], + "result": "int" + }, + { + "id": "math_bitXor_uint_uint", + "args": [ + "uint", + "uint" + ], + "result": "uint" + } + ] + }, + { + "name": "math.ceil", + "namespace": "math", + "overloads": [ + { + "id": "math_ceil_double", + "args": [ + "double" + ], + "result": "double" + } + ] + }, + { + "name": "math.containsFloat", + "namespace": "math", + "overloads": [ + { + "id": "math.containsFloat_interface{}", + "args": [ + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "math.floor", + "namespace": "math", + "overloads": [ + { + "id": "math_floor_double", + "args": [ + "double" + ], + "result": "double" + } + ] + }, + { + "name": "math.isFinite", + "namespace": "math", + "overloads": [ + { + "id": "math_isFinite_double", + "args": [ + "double" + ], + "result": "bool" + } + ] + }, + { + "name": "math.isInf", + "namespace": "math", + "overloads": [ + { + "id": "math_isInf_double", + "args": [ + "double" + ], + "result": "bool" + } + ] + }, + { + "name": "math.isNaN", + "namespace": "math", + "overloads": [ + { + "id": "math_isNaN_double", + "args": [ + "double" + ], + "result": "bool" + } + ] + }, + { + "name": "math.round", + "namespace": "math", + "overloads": [ + { + "id": "math_round_double", + "args": [ + "double" + ], + "result": "double" + } + ] + }, + { + "name": "math.sign", + "namespace": "math", + "overloads": [ + { + "id": "math_sign_double", + "args": [ + "double" + ], + "result": "double" + }, + { + "id": "math_sign_int", + "args": [ + "int" + ], + "result": "int" + }, + { + "id": "math_sign_uint", + "args": [ + "uint" + ], + "result": "uint" + } + ] + }, + { + "name": "math.sqrt", + "namespace": "math", + "overloads": [ + { + "id": "math_sqrt_double", + "args": [ + "double" + ], + "result": "double" + }, + { + "id": "math_sqrt_int", + "args": [ + "int" + ], + "result": "double" + }, + { + "id": "math_sqrt_uint", + "args": [ + "uint" + ], + "result": "double" + } + ] + }, + { + "name": "math.trunc", + "namespace": "math", + "overloads": [ + { + "id": "math_trunc_double", + "args": [ + "double" + ], + "result": "double" + } + ] + }, + { + "name": "max", + "memberOnly": true, + "overloads": [ + { + "id": "list_bool_max_bool", + "args": [ + "list(bool)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_bytes_max_bytes", + "args": [ + "list(bytes)" + ], + "result": "bytes", + "member": true + }, + { + "id": "list_double_max_double", + "args": [ + "list(double)" + ], + "result": "double", + "member": true + }, + { + "id": "list_duration_max_duration", + "args": [ + "list(google.protobuf.Duration)" + ], + "result": "google.protobuf.Duration", + "member": true + }, + { + "id": "list_int_max_int", + "args": [ + "list(int)" + ], + "result": "int", + "member": true + }, + { + "id": "list_string_max_string", + "args": [ + "list(string)" + ], + "result": "string", + "member": true + }, + { + "id": "list_timestamp_max_timestamp", + "args": [ + "list(google.protobuf.Timestamp)" + ], + "result": "google.protobuf.Timestamp", + "member": true + }, + { + "id": "list_uint_max_uint", + "args": [ + "list(uint)" + ], + "result": "uint", + "member": true + } + ] + }, + { + "name": "merge", + "overloads": [ + { + "id": "merge_map[string]interface{}", + "args": [ + "map(string, dyn)", + "map(string, dyn)" + ], + "result": "map(string, dyn)", + "member": true + }, + { + "id": "merge_map_map", + "args": [ + "map(dyn, dyn)", + "map(dyn, dyn)" + ], + "result": "map(dyn, dyn)" + } + ] + }, + { + "name": "min", + "memberOnly": true, + "overloads": [ + { + "id": "list_bool_min_bool", + "args": [ + "list(bool)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_bytes_min_bytes", + "args": [ + "list(bytes)" + ], + "result": "bytes", + "member": true + }, + { + "id": "list_double_min_double", + "args": [ + "list(double)" + ], + "result": "double", + "member": true + }, + { + "id": "list_duration_min_duration", + "args": [ + "list(google.protobuf.Duration)" + ], + "result": "google.protobuf.Duration", + "member": true + }, + { + "id": "list_int_min_int", + "args": [ + "list(int)" + ], + "result": "int", + "member": true + }, + { + "id": "list_string_min_string", + "args": [ + "list(string)" + ], + "result": "string", + "member": true + }, + { + "id": "list_timestamp_min_timestamp", + "args": [ + "list(google.protobuf.Timestamp)" + ], + "result": "google.protobuf.Timestamp", + "member": true + }, + { + "id": "list_uint_min_uint", + "args": [ + "list(uint)" + ], + "result": "uint", + "member": true + } + ] + }, + { + "name": "net.ContainsCIDR", + "namespace": "net", + "overloads": [ + { + "id": "net.ContainsCIDR_string_string", + "args": [ + "string", + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "net.IsValidIP", + "namespace": "net", + "overloads": [ + { + "id": "net.IsValidIP_string", + "args": [ + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "omit", + "memberOnly": true, + "overloads": [ + { + "id": "omit_interface{}", + "args": [ + "map(string, google.protobuf.Any)", + "list(string)" + ], + "result": "map(string, google.protobuf.Any)", + "member": true + } + ] + }, + { + "name": "optional.none", + "namespace": "optional", + "doc": "singleton value representing an optional without a value", + "overloads": [ + { + "id": "optional_none", + "args": [], + "result": "optional_type(\u003cV\u003e)" + } + ], + "examples": [ + "optional.none()" + ] + }, + { + "name": "optional.of", + "namespace": "optional", + "doc": "create a new optional_type(T) with a value where any value is considered valid", + "overloads": [ + { + "id": "optional_of", + "args": [ + "\u003cV\u003e" + ], + "result": "optional_type(\u003cV\u003e)" + } + ], + "examples": [ + "optional.of(1) // optional(1)" + ] + }, + { + "name": "optional.ofNonZeroValue", + "namespace": "optional", + "doc": "create a new optional_type(T) with a value, if the value is not a zero or empty value", + "overloads": [ + { + "id": "optional_ofNonZeroValue", + "args": [ + "\u003cV\u003e" + ], + "result": "optional_type(\u003cV\u003e)" + } + ], + "examples": [ + "optional.ofNonZeroValue(null) // optional.none()\noptional.ofNonZeroValue(\"\") // optional.none()\noptional.ofNonZeroValue(\"hello\") // optional.of('hello')" + ] + }, + { + "name": "or", + "memberOnly": true, + "doc": "chain optional expressions together, picking the first valued optional expression", + "overloads": [ + { + "id": "optional_or_optional", + "args": [ + "optional_type(\u003cV\u003e)", + "optional_type(\u003cV\u003e)" + ], + "result": "optional_type(\u003cV\u003e)", + "member": true + } + ], + "examples": [ + "optional.none().or(optional.of(1)) // optional.of(1)\n// either a value from the first list, a value from the second, or optional.none()\n[1, 2, 3][?x].or([3, 4, 5][?y])" + ] + }, + { + "name": "orValue", + "memberOnly": true, + "doc": "chain optional expressions together picking the first valued optional or the default value", + "overloads": [ + { + "id": "optional_orValue_value", + "args": [ + "optional_type(\u003cV\u003e)", + "\u003cV\u003e" + ], + "result": "\u003cV\u003e", + "member": true + } + ], + "examples": [ + "// pick the value for the given key if the key exists, otherwise return 'you'\n{'hello': 'world', 'goodbye': 'cruel world'}[?greeting].orValue('you')" + ] + }, + { + "name": "pick", + "memberOnly": true, + "overloads": [ + { + "id": "pick_interface{}", + "args": [ + "google.protobuf.Any", + "list(string)" + ], + "result": "google.protobuf.Any", + "member": true + } + ] + }, + { + "name": "quote", + "memberOnly": true, + "overloads": [ + { + "id": "string_quote", + "args": [ + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "random.ASCII", + "namespace": "random", + "overloads": [ + { + "id": "random.ASCII_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "random.Alpha", + "namespace": "random", + "overloads": [ + { + "id": "random.Alpha_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "random.AlphaNum", + "namespace": "random", + "overloads": [ + { + "id": "random.AlphaNum_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "random.Float", + "namespace": "random", + "overloads": [ + { + "id": "random.Float_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "random.Item", + "namespace": "random", + "overloads": [ + { + "id": "random.Item_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "random.Number", + "namespace": "random", + "overloads": [ + { + "id": "random.Number_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "random.String", + "namespace": "random", + "overloads": [ + { + "id": "random.String_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "regexp.Find", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.Find_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "regexp.FindAll", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.FindAll_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "regexp.Match", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.Match_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "regexp.QuoteMeta", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.QuoteMeta_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "regexp.Replace", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.Replace_interface{}_interface{}_interface{}", + "args": [ + "dyn", + "dyn", + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "regexp.ReplaceLiteral", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.ReplaceLiteral_interface{}_interface{}_interface{}", + "args": [ + "dyn", + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "regexp.Split", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.Split_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "repeat", + "memberOnly": true, + "overloads": [ + { + "id": "string_repeat", + "args": [ + "string", + "int" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "replace", + "memberOnly": true, + "overloads": [ + { + "id": "string_replace_string_string", + "args": [ + "string", + "string", + "string" + ], + "result": "string", + "member": true + }, + { + "id": "string_replace_string_string_int", + "args": [ + "string", + "string", + "string", + "int" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "replaceAll", + "memberOnly": true, + "overloads": [ + { + "id": "ReplaceAll_string_string_interface{}", + "args": [ + "string", + "string", + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "replaceAllRegex", + "memberOnly": true, + "overloads": [ + { + "id": "string_replaceAllRegex_string", + "args": [ + "string", + "string", + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "reverse", + "memberOnly": true, + "overloads": [ + { + "id": "list_reverse", + "args": [ + "list(\u003cT\u003e)" + ], + "result": "list(\u003cT\u003e)", + "member": true + }, + { + "id": "string_reverse", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "runeCount", + "memberOnly": true, + "overloads": [ + { + "id": "string_rune_count", + "args": [ + "string" + ], + "result": "int", + "member": true + } + ] + }, + { + "name": "sets.contains", + "namespace": "sets", + "overloads": [ + { + "id": "list_sets_contains_list", + "args": [ + "list(\u003cT\u003e)", + "list(\u003cT\u003e)" + ], + "result": "bool" + } + ] + }, + { + "name": "sets.equivalent", + "namespace": "sets", + "overloads": [ + { + "id": "list_sets_equivalent_list", + "args": [ + "list(\u003cT\u003e)", + "list(\u003cT\u003e)" + ], + "result": "bool" + } + ] + }, + { + "name": "sets.intersects", + "namespace": "sets", + "overloads": [ + { + "id": "list_sets_intersects_list", + "args": [ + "list(\u003cT\u003e)", + "list(\u003cT\u003e)" + ], + "result": "bool" + } + ] + }, + { + "name": "shellQuote", + "memberOnly": true, + "overloads": [ + { + "id": "string_shell_quote", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "size", + "doc": "compute the size of a list or map, the number of characters in a string,\nor the number of bytes in a sequence", + "overloads": [ + { + "id": "bytes_size", + "args": [ + "bytes" + ], + "result": "int", + "member": true + }, + { + "id": "list_size", + "args": [ + "list(\u003cA\u003e)" + ], + "result": "int", + "member": true + }, + { + "id": "map_size", + "args": [ + "map(\u003cA\u003e, \u003cB\u003e)" + ], + "result": "int", + "member": true + }, + { + "id": "size_bytes", + "args": [ + "bytes" + ], + "result": "int" + }, + { + "id": "size_list", + "args": [ + "list(\u003cA\u003e)" + ], + "result": "int" + }, + { + "id": "size_map", + "args": [ + "map(\u003cA\u003e, \u003cB\u003e)" + ], + "result": "int" + }, + { + "id": "size_string", + "args": [ + "string" + ], + "result": "int" + }, + { + "id": "string_size", + "args": [ + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "size(b'123') // 3", + "b'123'.size() // 3", + "size([1, 2, 3]) // 3", + "[1, 2, 3].size() // 3", + "size({'a': 1, 'b': 2}) // 2", + "{'a': 1, 'b': 2}.size() // 2", + "size('hello') // 5", + "'hello'.size() // 5" + ] + }, + { + "name": "slice", + "memberOnly": true, + "overloads": [ + { + "id": "list_slice", + "args": [ + "list(\u003cT\u003e)", + "int", + "int" + ], + "result": "list(\u003cT\u003e)", + "member": true + } + ] + }, + { + "name": "slug", + "memberOnly": true, + "overloads": [ + { + "id": "string_slug", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "snakeCase", + "memberOnly": true, + "overloads": [ + { + "id": "string_snakeCase", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "sort", + "memberOnly": true, + "overloads": [ + { + "id": "list_bool_sort", + "args": [ + "list(bool)" + ], + "result": "list(bool)", + "member": true + }, + { + "id": "list_bytes_sort", + "args": [ + "list(bytes)" + ], + "result": "list(bytes)", + "member": true + }, + { + "id": "list_double_sort", + "args": [ + "list(double)" + ], + "result": "list(double)", + "member": true + }, + { + "id": "list_google.protobuf.Duration_sort", + "args": [ + "list(google.protobuf.Duration)" + ], + "result": "list(google.protobuf.Duration)", + "member": true + }, + { + "id": "list_google.protobuf.Timestamp_sort", + "args": [ + "list(google.protobuf.Timestamp)" + ], + "result": "list(google.protobuf.Timestamp)", + "member": true + }, + { + "id": "list_int_sort", + "args": [ + "list(int)" + ], + "result": "list(int)", + "member": true + }, + { + "id": "list_string_sort", + "args": [ + "list(string)" + ], + "result": "list(string)", + "member": true + }, + { + "id": "list_uint_sort", + "args": [ + "list(uint)" + ], + "result": "list(uint)", + "member": true + } + ] + }, + { + "name": "sortBy", + "memberOnly": true, + "overloads": [ + { + "id": "sortBy_interface{}", + "args": [ + "list(google.protobuf.Any)", + "string" + ], + "result": "list(google.protobuf.Any)", + "member": true + } + ] + }, + { + "name": "split", + "memberOnly": true, + "overloads": [ + { + "id": "string_split_string", + "args": [ + "string", + "string" + ], + "result": "list(string)", + "member": true + }, + { + "id": "string_split_string_int", + "args": [ + "string", + "string", + "int" + ], + "result": "list(string)", + "member": true + } + ] + }, + { + "name": "splitRegex", + "memberOnly": true, + "overloads": [ + { + "id": "string_splitRegex_string", + "args": [ + "string", + "string" + ], + "result": "list(string)", + "member": true + } + ] + }, + { + "name": "squote", + "memberOnly": true, + "overloads": [ + { + "id": "string_squote", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "startsWith", + "memberOnly": true, + "doc": "test whether a string starts with a substring prefix", + "overloads": [ + { + "id": "starts_with_string", + "args": [ + "string", + "string" + ], + "result": "bool", + "member": true + } + ], + "examples": [ + "'hello world'.startsWith('hello') // true\n'hello world'.startsWith('world') // false" + ] + }, + { + "name": "string", + "doc": "convert a value to a string", + "overloads": [ + { + "id": "bool_to_string", + "args": [ + "bool" + ], + "result": "string" + }, + { + "id": "bytes_to_string", + "args": [ + "bytes" + ], + "result": "string" + }, + { + "id": "double_to_string", + "args": [ + "double" + ], + "result": "string" + }, + { + "id": "duration_to_string", + "args": [ + "google.protobuf.Duration" + ], + "result": "string" + }, + { + "id": "int64_to_string", + "args": [ + "int" + ], + "result": "string" + }, + { + "id": "string_to_string", + "args": [ + "string" + ], + "result": "string" + }, + { + "id": "timestamp_to_string", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "string" + }, + { + "id": "uint64_to_string", + "args": [ + "uint" + ], + "result": "string" + } + ], + "examples": [ + "string('hello') // 'hello'", + "string(true) // 'true'", + "string(b'hello') // 'hello'", + "string(-1.23e4) // '-12300'", + "string(duration('1h30m')) // '5400s'", + "string(-123) // '-123'", + "string(timestamp('1970-01-01T00:00:00Z')) // '1970-01-01T00:00:00Z'", + "string(123u) // '123'" + ] + }, + { + "name": "strings.quote", + "namespace": "strings", + "overloads": [ + { + "id": "strings_quote", + "args": [ + "string" + ], + "result": "string" + } + ] + }, + { + "name": "substring", + "memberOnly": true, + "overloads": [ + { + "id": "string_substring_int", + "args": [ + "string", + "int" + ], + "result": "string", + "member": true + }, + { + "id": "string_substring_int_int", + "args": [ + "string", + "int", + "int" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "sum", + "memberOnly": true, + "overloads": [ + { + "id": "list_double_sum_double", + "args": [ + "list(double)" + ], + "result": "double", + "member": true + }, + { + "id": "list_duration_sum_duration", + "args": [ + "list(google.protobuf.Duration)" + ], + "result": "google.protobuf.Duration", + "member": true + }, + { + "id": "list_int_sum_int", + "args": [ + "list(int)" + ], + "result": "int", + "member": true + }, + { + "id": "list_uint_sum_uint", + "args": [ + "list(uint)" + ], + "result": "uint", + "member": true + } + ] + }, + { + "name": "test.Assert", + "namespace": "test", + "overloads": [ + { + "id": "test.Assert_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "test.Fail", + "namespace": "test", + "overloads": [ + { + "id": "test.Fail_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "test.IsKind", + "namespace": "test", + "overloads": [ + { + "id": "test.IsKind_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "test.Kind", + "namespace": "test", + "overloads": [ + { + "id": "test.Kind_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "test.Required", + "namespace": "test", + "overloads": [ + { + "id": "test.Required_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "test.Ternary", + "namespace": "test", + "overloads": [ + { + "id": "test.Ternary_interface{}_interface{}_interface{}", + "args": [ + "dyn", + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Hour", + "namespace": "time", + "overloads": [ + { + "id": "time.Hour_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.InTimeRange", + "namespace": "time", + "overloads": [ + { + "id": "time.InTimeRange_any_string_string", + "args": [ + "google.protobuf.Any", + "string", + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "time.Microsecond", + "namespace": "time", + "overloads": [ + { + "id": "time.Microsecond_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Millisecond", + "namespace": "time", + "overloads": [ + { + "id": "time.Millisecond_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Minute", + "namespace": "time", + "overloads": [ + { + "id": "time.Minute_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Nanosecond", + "namespace": "time", + "overloads": [ + { + "id": "time.Nanosecond_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Now", + "namespace": "time", + "overloads": [ + { + "id": "time.Now_", + "args": [], + "result": "dyn" + } + ] + }, + { + "name": "time.Parse", + "namespace": "time", + "overloads": [ + { + "id": "time.Parse_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.ParseDateTime", + "namespace": "time", + "overloads": [ + { + "id": "time.ParseDateTime_string", + "args": [ + "string" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.ParseDuration", + "namespace": "time", + "overloads": [ + { + "id": "time.ParseDuration_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.ParseInLocation", + "namespace": "time", + "overloads": [ + { + "id": "time.ParseInLocation_string_string_interface{}", + "args": [ + "string", + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.ParseLocal", + "namespace": "time", + "overloads": [ + { + "id": "time.ParseLocal_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Second", + "namespace": "time", + "overloads": [ + { + "id": "time.Second_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Since", + "namespace": "time", + "overloads": [ + { + "id": "time.Since_gotime.Time", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Unix", + "namespace": "time", + "overloads": [ + { + "id": "time.Unix_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Until", + "namespace": "time", + "overloads": [ + { + "id": "time.Until_gotime.Time", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.ZoneName", + "namespace": "time", + "overloads": [ + { + "id": "time.ZoneName_", + "args": [], + "result": "string" + } + ] + }, + { + "name": "time.ZoneOffset", + "namespace": "time", + "overloads": [ + { + "id": "time.ZoneOffset_", + "args": [], + "result": "int" + } + ] + }, + { + "name": "timestamp", + "doc": "convert a value to a google.protobuf.Timestamp", + "overloads": [ + { + "id": "int64_to_timestamp", + "args": [ + "int" + ], + "result": "google.protobuf.Timestamp" + }, + { + "id": "string_to_timestamp", + "args": [ + "string" + ], + "result": "google.protobuf.Timestamp" + }, + { + "id": "timestamp_to_timestamp", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "google.protobuf.Timestamp" + } + ], + "examples": [ + "timestamp(timestamp('2023-01-01T00:00:00Z')) // timestamp('2023-01-01T00:00:00Z')", + "timestamp(1) // timestamp('1970-01-01T00:00:01Z')", + "timestamp('2025-01-01T12:34:56Z') // timestamp('2025-01-01T12:34:56Z')" + ] + }, + { + "name": "title", + "memberOnly": true, + "overloads": [ + { + "id": "string_title", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "toCSV", + "overloads": [ + { + "id": "toCSV_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "toJSON", + "memberOnly": true, + "overloads": [ + { + "id": "dyn_toJSON", + "args": [ + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "toJSONPretty", + "memberOnly": true, + "overloads": [ + { + "id": "toJSONPretty_interface{}", + "args": [ + "dyn", + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "toLower", + "memberOnly": true, + "overloads": [ + { + "id": "string_toLower", + "args": [ + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "toTOML", + "overloads": [ + { + "id": "toTOML_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "toUpper", + "memberOnly": true, + "overloads": [ + { + "id": "string_toUpper", + "args": [ + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "toYAML", + "overloads": [ + { + "id": "toYAML_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "trim", + "memberOnly": true, + "overloads": [ + { + "id": "string_trim", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "trimPrefix", + "memberOnly": true, + "overloads": [ + { + "id": "string_trimPrefix", + "args": [ + "string", + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "trimSpace", + "memberOnly": true, + "overloads": [ + { + "id": "string_trimSpace", + "args": [ + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "trimSuffix", + "memberOnly": true, + "overloads": [ + { + "id": "string_trimSuffix", + "args": [ + "string", + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "trunc", + "memberOnly": true, + "overloads": [ + { + "id": "string_trunc", + "args": [ + "int", + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "type", + "doc": "convert a value to its type identifier", + "overloads": [ + { + "id": "type", + "args": [ + "\u003cA\u003e" + ], + "result": "type(\u003cA\u003e)" + } + ], + "examples": [ + "type(1) // int\ntype('hello') // string\ntype(int) // type\ntype(type) // type" + ] + }, + { + "name": "uint", + "doc": "convert a value to a uint", + "overloads": [ + { + "id": "double_to_uint64", + "args": [ + "double" + ], + "result": "uint" + }, + { + "id": "int64_to_uint64", + "args": [ + "int" + ], + "result": "uint" + }, + { + "id": "string_to_uint64", + "args": [ + "string" + ], + "result": "uint" + }, + { + "id": "uint64_to_uint64", + "args": [ + "uint" + ], + "result": "uint" + } + ], + "examples": [ + "uint(123u) // 123u", + "uint(123.45) // 123u", + "uint(123) // 123u", + "uint('123') // 123u" + ] + }, + { + "name": "uniq", + "memberOnly": true, + "overloads": [ + { + "id": "uniq_interface{}", + "args": [ + "list(dyn)" + ], + "result": "list(dyn)", + "member": true + } + ] + }, + { + "name": "upperAscii", + "memberOnly": true, + "overloads": [ + { + "id": "string_upper_ascii", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "url", + "overloads": [ + { + "id": "string_to_url", + "args": [ + "string" + ], + "result": "kubernetes.URL" + } + ] + }, + { + "name": "urldecode", + "overloads": [ + { + "id": "urldecode.string", + "args": [ + "string" + ], + "result": "string" + } + ] + }, + { + "name": "urlencode", + "overloads": [ + { + "id": "urlencode.string", + "args": [ + "string" + ], + "result": "string" + } + ] + }, + { + "name": "uuid.HashUUID", + "namespace": "uuid", + "overloads": [ + { + "id": "uuid.HashUUID_list", + "args": [ + "list(dyn)" + ], + "result": "string" + } + ] + }, + { + "name": "uuid.IsValid", + "namespace": "uuid", + "overloads": [ + { + "id": "uuid.IsValid_interface{}", + "args": [ + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "uuid.Nil", + "namespace": "uuid", + "overloads": [ + { + "id": "uuid.Nil_", + "args": [], + "result": "string" + } + ] + }, + { + "name": "uuid.Parse", + "namespace": "uuid", + "overloads": [ + { + "id": "uuid.Parse_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "uuid.V1", + "namespace": "uuid", + "overloads": [ + { + "id": "uuid.V1_", + "args": [], + "result": "string" + } + ] + }, + { + "name": "uuid.V4", + "namespace": "uuid", + "overloads": [ + { + "id": "uuid.V4_", + "args": [], + "result": "string" + } + ] + }, + { + "name": "value", + "memberOnly": true, + "doc": "obtain the value contained by the optional, error if optional.none()", + "overloads": [ + { + "id": "optional_value", + "args": [ + "optional_type(\u003cV\u003e)" + ], + "result": "\u003cV\u003e", + "member": true + } + ], + "examples": [ + "optional.of(1).value() // 1\noptional.none().value() // error" + ] + }, + { + "name": "values", + "memberOnly": true, + "overloads": [ + { + "id": "map_values", + "args": [ + "map(string, google.protobuf.Any)" + ], + "result": "list(google.protobuf.Any)", + "member": true + } + ] + }, + { + "name": "wordWrap", + "memberOnly": true, + "overloads": [ + { + "id": "WordWrap_interface{}", + "args": [ + "string", + "int" + ], + "result": "string", + "member": true + }, + { + "id": "stringsWordWrapSeqAndWidthGen", + "args": [ + "string", + "int", + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "xpath", + "overloads": [ + { + "id": "xpath_string_string", + "args": [ + "string", + "string" + ], + "result": "dyn" + } + ] + } + ] + }, + "gotemplate": { + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "delimiters": { + "left": "{{", + "right": "}}", + "leftComment": "/*", + "rightComment": "*/", + "trimMarker": "-" + }, + "functions": [ + { + "name": "add", + "signature": "(n ...interface {}) interface {}" + }, + { + "name": "append", + "signature": "(v interface {}, list interface {}) ([]interface {}, error)" + }, + { + "name": "assert", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "base64.Decode", + "namespace": "base64", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "base64.DecodeBytes", + "namespace": "base64", + "signature": "(in interface {}) ([]uint8, error)" + }, + { + "name": "base64.Encode", + "namespace": "base64", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "bool", + "signature": "(s interface {}) bool" + }, + { + "name": "coalesce", + "signature": "(args ...interface {}) interface {}" + }, + { + "name": "coll.Append", + "namespace": "coll", + "signature": "(v interface {}, list interface {}) ([]interface {}, error)" + }, + { + "name": "coll.Coalesce", + "namespace": "coll", + "signature": "(args ...interface {}) interface {}" + }, + { + "name": "coll.Dict", + "namespace": "coll", + "signature": "(in ...interface {}) (map[string]interface {}, error)" + }, + { + "name": "coll.First", + "namespace": "coll", + "signature": "(in interface {}) interface {}" + }, + { + "name": "coll.Flatten", + "namespace": "coll", + "signature": "(args ...interface {}) ([]interface {}, error)" + }, + { + "name": "coll.Has", + "namespace": "coll", + "signature": "(in interface {}, key string) bool" + }, + { + "name": "coll.JQ", + "namespace": "coll", + "signature": "(jqExpr string, in interface {}) (interface {}, error)" + }, + { + "name": "coll.Keys", + "namespace": "coll", + "signature": "(in map[string]interface {}) []string" + }, + { + "name": "coll.Last", + "namespace": "coll", + "signature": "(in interface {}) interface {}" + }, + { + "name": "coll.Merge", + "namespace": "coll", + "signature": "(dst map[string]interface {}, src ...map[string]interface {}) (map[string]interface {}, error)" + }, + { + "name": "coll.Omit", + "namespace": "coll", + "signature": "(args ...interface {}) (map[string]interface {}, error)" + }, + { + "name": "coll.Pick", + "namespace": "coll", + "signature": "(args ...interface {}) (map[string]interface {}, error)" + }, + { + "name": "coll.Prepend", + "namespace": "coll", + "signature": "(v interface {}, list interface {}) ([]interface {}, error)" + }, + { + "name": "coll.Reverse", + "namespace": "coll", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "coll.Slice", + "namespace": "coll", + "signature": "(args ...interface {}) []interface {}" + }, + { + "name": "coll.Sort", + "namespace": "coll", + "signature": "(args ...interface {}) ([]interface {}, error)" + }, + { + "name": "coll.Uniq", + "namespace": "coll", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "coll.Values", + "namespace": "coll", + "signature": "(in map[string]interface {}) []interface {}" + }, + { + "name": "contains", + "signature": "(s string, substr string) bool" + }, + { + "name": "conv.Atoi", + "namespace": "conv", + "signature": "(s interface {}) int" + }, + { + "name": "conv.Bool", + "namespace": "conv", + "signature": "(s interface {}) bool" + }, + { + "name": "conv.Default", + "namespace": "conv", + "signature": "(def interface {}, in interface {}) interface {}" + }, + { + "name": "conv.Dict", + "namespace": "conv", + "signature": "(in ...interface {}) (map[string]interface {}, error)" + }, + { + "name": "conv.Has", + "namespace": "conv", + "signature": "(in interface {}, key string) bool" + }, + { + "name": "conv.Join", + "namespace": "conv", + "signature": "(in interface {}, sep string) (string, error)" + }, + { + "name": "conv.ParseFloat", + "namespace": "conv", + "signature": "(s interface {}, bitSize int) float64" + }, + { + "name": "conv.ParseInt", + "namespace": "conv", + "signature": "(s interface {}, base int, bitSize int) int64" + }, + { + "name": "conv.ParseUint", + "namespace": "conv", + "signature": "(s interface {}, base int, bitSize int) uint64" + }, + { + "name": "conv.Slice", + "namespace": "conv", + "signature": "(args ...interface {}) []interface {}" + }, + { + "name": "conv.ToBool", + "namespace": "conv", + "signature": "(in interface {}) bool" + }, + { + "name": "conv.ToBools", + "namespace": "conv", + "signature": "(in ...interface {}) []bool" + }, + { + "name": "conv.ToFloat64", + "namespace": "conv", + "signature": "(in interface {}) float64" + }, + { + "name": "conv.ToFloat64s", + "namespace": "conv", + "signature": "(in ...interface {}) []float64" + }, + { + "name": "conv.ToInt", + "namespace": "conv", + "signature": "(in interface {}) int" + }, + { + "name": "conv.ToInt64", + "namespace": "conv", + "signature": "(in interface {}) int64" + }, + { + "name": "conv.ToInt64s", + "namespace": "conv", + "signature": "(in ...interface {}) []int64" + }, + { + "name": "conv.ToInts", + "namespace": "conv", + "signature": "(in ...interface {}) []int" + }, + { + "name": "conv.ToString", + "namespace": "conv", + "signature": "(in interface {}) string" + }, + { + "name": "conv.ToStrings", + "namespace": "conv", + "signature": "(in ...interface {}) []string" + }, + { + "name": "conv.URL", + "namespace": "conv", + "signature": "(s interface {}) (*url.URL, error)" + }, + { + "name": "crypto.SHA1", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA1Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "crypto.SHA224", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA224Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "crypto.SHA256", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA256Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "crypto.SHA384", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA384Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "crypto.SHA512", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA512Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "crypto.SHA512_224", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA512_224Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "crypto.SHA512_256", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA512_256Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "csv", + "signature": "(args ...string) ([][]string, error)" + }, + { + "name": "csvByColumn", + "signature": "(args ...string) (map[string][]string, error)" + }, + { + "name": "csvByRow", + "signature": "(args ...string) ([]map[string]string, error)" + }, + { + "name": "data.CSV", + "namespace": "data", + "signature": "(args ...string) ([][]string, error)" + }, + { + "name": "data.CSVByColumn", + "namespace": "data", + "signature": "(args ...string) (map[string][]string, error)" + }, + { + "name": "data.CSVByRow", + "namespace": "data", + "signature": "(args ...string) ([]map[string]string, error)" + }, + { + "name": "data.JSON", + "namespace": "data", + "signature": "(in interface {}) (map[string]interface {}, error)" + }, + { + "name": "data.JSONArray", + "namespace": "data", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "data.TOML", + "namespace": "data", + "signature": "(in interface {}) (interface {}, error)" + }, + { + "name": "data.ToCSV", + "namespace": "data", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "data.ToJSON", + "namespace": "data", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "data.ToJSONPretty", + "namespace": "data", + "signature": "(indent string, in interface {}) (string, error)" + }, + { + "name": "data.ToTOML", + "namespace": "data", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "data.ToYAML", + "namespace": "data", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "data.YAML", + "namespace": "data", + "signature": "(in interface {}) (map[string]interface {}, error)" + }, + { + "name": "data.YAMLArray", + "namespace": "data", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "default", + "signature": "(def interface {}, in interface {}) interface {}" + }, + { + "name": "dict", + "signature": "(in ...interface {}) (map[string]interface {}, error)" + }, + { + "name": "div", + "signature": "(a interface {}, b interface {}) (interface {}, error)" + }, + { + "name": "endsWith", + "signature": "(s string, suffix string) bool" + }, + { + "name": "fail", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "filepath.Base", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "filepath.Clean", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "filepath.Dir", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "filepath.Ext", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "filepath.FromSlash", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "filepath.IsAbs", + "namespace": "filepath", + "signature": "(in interface {}) bool" + }, + { + "name": "filepath.Join", + "namespace": "filepath", + "signature": "(elem ...interface {}) string" + }, + { + "name": "filepath.Match", + "namespace": "filepath", + "signature": "(pattern interface {}, name interface {}) (bool, error)" + }, + { + "name": "filepath.Rel", + "namespace": "filepath", + "signature": "(basepath interface {}, targpath interface {}) (string, error)" + }, + { + "name": "filepath.Split", + "namespace": "filepath", + "signature": "(in interface {}) []string" + }, + { + "name": "filepath.ToSlash", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "filepath.VolumeName", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "first", + "signature": "(in interface {}) interface {}" + }, + { + "name": "flatten", + "signature": "(args ...interface {}) ([]interface {}, error)" + }, + { + "name": "getHealth", + "signature": "(in interface {}) kubernetes.HealthStatus" + }, + { + "name": "getStatus", + "signature": "(in interface {}) string" + }, + { + "name": "has", + "signature": "(in interface {}, key string) bool" + }, + { + "name": "hasPrefix", + "signature": "(s string, prefix string) bool" + }, + { + "name": "hasSuffix", + "signature": "(s string, suffix string) bool" + }, + { + "name": "humanDuration", + "signature": "(duration interface {}) string" + }, + { + "name": "humanSize", + "signature": "(size interface {}) string" + }, + { + "name": "in_business_hours", + "signature": "(value string) (interface {}, error)" + }, + { + "name": "indent", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "isHealthy", + "signature": "(in interface {}) bool" + }, + { + "name": "isKind", + "signature": "(kind string, arg interface {}) bool" + }, + { + "name": "isReady", + "signature": "(in interface {}) bool" + }, + { + "name": "jmespath", + "signature": "(jmesPath string, in interface {}) (interface {}, error)" + }, + { + "name": "join", + "signature": "(in interface {}, sep string) (string, error)" + }, + { + "name": "jq", + "signature": "(jqExpr string, in interface {}) (interface {}, error)" + }, + { + "name": "json", + "signature": "(in interface {}) (map[string]interface {}, error)" + }, + { + "name": "jsonArray", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "jsonpath", + "signature": "(jsonPath string, in interface {}) (interface {}, error)" + }, + { + "name": "k8s.GetHealth", + "namespace": "k8s", + "signature": "(in interface {}) kubernetes.HealthStatus" + }, + { + "name": "k8s.GetHealthMap", + "namespace": "k8s", + "signature": "(in interface {}) map[string]string" + }, + { + "name": "k8s.GetStatus", + "namespace": "k8s", + "signature": "(in interface {}) string" + }, + { + "name": "k8s.IsHealthy", + "namespace": "k8s", + "signature": "(in interface {}) bool" + }, + { + "name": "k8s.IsReady", + "namespace": "k8s", + "signature": "(in interface {}) bool" + }, + { + "name": "k8s.Neat", + "namespace": "k8s", + "signature": "(in string) (string, error)" + }, + { + "name": "keyValToMap", + "signature": "(s string) (map[string]string, error)" + }, + { + "name": "keys", + "signature": "(in map[string]interface {}) []string" + }, + { + "name": "kind", + "signature": "(arg interface {}) string" + }, + { + "name": "last", + "signature": "(in interface {}) interface {}" + }, + { + "name": "mapToKeyVal", + "signature": "(m map[string]interface {}) string" + }, + { + "name": "matchLabel", + "signature": "(labels map[string]interface {}, key string, valuePatterns ...string) bool" + }, + { + "name": "math.Abs", + "namespace": "math", + "signature": "(n interface {}) interface {}" + }, + { + "name": "math.Add", + "namespace": "math", + "signature": "(n ...interface {}) interface {}" + }, + { + "name": "math.Ceil", + "namespace": "math", + "signature": "(n interface {}) interface {}" + }, + { + "name": "math.Div", + "namespace": "math", + "signature": "(a interface {}, b interface {}) (interface {}, error)" + }, + { + "name": "math.Floor", + "namespace": "math", + "signature": "(n interface {}) interface {}" + }, + { + "name": "math.IsFloat", + "namespace": "math", + "signature": "(n interface {}) bool" + }, + { + "name": "math.IsInt", + "namespace": "math", + "signature": "(n interface {}) bool" + }, + { + "name": "math.IsNum", + "namespace": "math", + "signature": "(n interface {}) bool" + }, + { + "name": "math.Max", + "namespace": "math", + "signature": "(a interface {}, b ...interface {}) (interface {}, error)" + }, + { + "name": "math.Min", + "namespace": "math", + "signature": "(a interface {}, b ...interface {}) (interface {}, error)" + }, + { + "name": "math.Mul", + "namespace": "math", + "signature": "(n ...interface {}) interface {}" + }, + { + "name": "math.Pow", + "namespace": "math", + "signature": "(a interface {}, b interface {}) interface {}" + }, + { + "name": "math.Rem", + "namespace": "math", + "signature": "(a interface {}, b interface {}) interface {}" + }, + { + "name": "math.Round", + "namespace": "math", + "signature": "(n interface {}) interface {}" + }, + { + "name": "math.Seq", + "namespace": "math", + "signature": "(n ...interface {}) ([]int64, error)" + }, + { + "name": "math.Sub", + "namespace": "math", + "signature": "(a interface {}, b interface {}) interface {}" + }, + { + "name": "merge", + "signature": "(dst map[string]interface {}, src ...map[string]interface {}) (map[string]interface {}, error)" + }, + { + "name": "mul", + "signature": "(n ...interface {}) interface {}" + }, + { + "name": "neat", + "signature": "(in string) (string, error)" + }, + { + "name": "net.ContainsCIDR", + "namespace": "net", + "signature": "(cidr string, ip string) bool" + }, + { + "name": "net.IsValidIP", + "namespace": "net", + "signature": "(ip string) bool" + }, + { + "name": "parseDateTime", + "signature": "(timeStr string) *time.Time" + }, + { + "name": "path.Base", + "namespace": "path", + "signature": "(in interface {}) string" + }, + { + "name": "path.Clean", + "namespace": "path", + "signature": "(in interface {}) string" + }, + { + "name": "path.Dir", + "namespace": "path", + "signature": "(in interface {}) string" + }, + { + "name": "path.Ext", + "namespace": "path", + "signature": "(in interface {}) string" + }, + { + "name": "path.IsAbs", + "namespace": "path", + "signature": "(in interface {}) bool" + }, + { + "name": "path.Join", + "namespace": "path", + "signature": "(elem ...interface {}) string" + }, + { + "name": "path.Match", + "namespace": "path", + "signature": "(pattern interface {}, name interface {}) (bool, error)" + }, + { + "name": "path.Split", + "namespace": "path", + "signature": "(in interface {}) []string" + }, + { + "name": "pow", + "signature": "(a interface {}, b interface {}) interface {}" + }, + { + "name": "prepend", + "signature": "(v interface {}, list interface {}) ([]interface {}, error)" + }, + { + "name": "quote", + "signature": "(in interface {}) string" + }, + { + "name": "random.ASCII", + "namespace": "random", + "signature": "(count interface {}) (string, error)" + }, + { + "name": "random.Alpha", + "namespace": "random", + "signature": "(count interface {}) (string, error)" + }, + { + "name": "random.AlphaNum", + "namespace": "random", + "signature": "(count interface {}) (string, error)" + }, + { + "name": "random.Float", + "namespace": "random", + "signature": "(args ...interface {}) (float64, error)" + }, + { + "name": "random.Item", + "namespace": "random", + "signature": "(items interface {}) (interface {}, error)" + }, + { + "name": "random.Number", + "namespace": "random", + "signature": "(args ...interface {}) (int64, error)" + }, + { + "name": "random.String", + "namespace": "random", + "signature": "(count interface {}, args ...interface {}) (string, error)" + }, + { + "name": "regexp.Find", + "namespace": "regexp", + "signature": "(re interface {}, input interface {}) (string, error)" + }, + { + "name": "regexp.FindAll", + "namespace": "regexp", + "signature": "(args ...interface {}) ([]string, error)" + }, + { + "name": "regexp.Match", + "namespace": "regexp", + "signature": "(re interface {}, input interface {}) bool" + }, + { + "name": "regexp.QuoteMeta", + "namespace": "regexp", + "signature": "(in interface {}) string" + }, + { + "name": "regexp.Replace", + "namespace": "regexp", + "signature": "(re interface {}, replacement interface {}, input interface {}) string" + }, + { + "name": "regexp.ReplaceLiteral", + "namespace": "regexp", + "signature": "(re interface {}, replacement interface {}, input interface {}) (string, error)" + }, + { + "name": "regexp.Split", + "namespace": "regexp", + "signature": "(args ...interface {}) ([]string, error)" + }, + { + "name": "rem", + "signature": "(a interface {}, b interface {}) interface {}" + }, + { + "name": "replaceAll", + "signature": "(old string, new string, s interface {}) string" + }, + { + "name": "required", + "signature": "(args ...interface {}) (interface {}, error)" + }, + { + "name": "reverse", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "semver", + "signature": "(version string) (*semver.Version, error)" + }, + { + "name": "semverCompare", + "signature": "(constraint string, version string) (bool, error)" + }, + { + "name": "seq", + "signature": "(n ...interface {}) ([]int64, error)" + }, + { + "name": "shellQuote", + "signature": "(in interface {}) string" + }, + { + "name": "slice", + "signature": "(args ...interface {}) []interface {}" + }, + { + "name": "sort", + "signature": "(args ...interface {}) ([]interface {}, error)" + }, + { + "name": "split", + "signature": "(s string, sep string) []string" + }, + { + "name": "splitN", + "signature": "(s string, sep string, n int) []string" + }, + { + "name": "squote", + "signature": "(in interface {}) string" + }, + { + "name": "startsWith", + "signature": "(s string, prefix string) bool" + }, + { + "name": "strings.Abbrev", + "namespace": "strings", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "strings.CamelCase", + "namespace": "strings", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "strings.Contains", + "namespace": "strings", + "signature": "(substr string, s interface {}) bool" + }, + { + "name": "strings.HasPrefix", + "namespace": "strings", + "signature": "(prefix string, s interface {}) bool" + }, + { + "name": "strings.HasSuffix", + "namespace": "strings", + "signature": "(suffix string, s interface {}) bool" + }, + { + "name": "strings.HumanDuration", + "namespace": "strings", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "strings.HumanSize", + "namespace": "strings", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "strings.Indent", + "namespace": "strings", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "strings.KebabCase", + "namespace": "strings", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "strings.Quote", + "namespace": "strings", + "signature": "(in interface {}) string" + }, + { + "name": "strings.Repeat", + "namespace": "strings", + "signature": "(count int, s interface {}) (string, error)" + }, + { + "name": "strings.ReplaceAll", + "namespace": "strings", + "signature": "(old string, new string, s interface {}) string" + }, + { + "name": "strings.RuneCount", + "namespace": "strings", + "signature": "(args ...interface {}) (int, error)" + }, + { + "name": "strings.Semver", + "namespace": "strings", + "signature": "(in string) (*semver.Version, error)" + }, + { + "name": "strings.SemverCompare", + "namespace": "strings", + "signature": "(v1 string, v2 string) (bool, error)" + }, + { + "name": "strings.SemverMap", + "namespace": "strings", + "signature": "(in string) (map[string]string, error)" + }, + { + "name": "strings.ShellQuote", + "namespace": "strings", + "signature": "(in interface {}) string" + }, + { + "name": "strings.Slug", + "namespace": "strings", + "signature": "(in interface {}) string" + }, + { + "name": "strings.SnakeCase", + "namespace": "strings", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "strings.Sort", + "namespace": "strings", + "signature": "(list interface {}) ([]string, error)" + }, + { + "name": "strings.Split", + "namespace": "strings", + "signature": "(sep string, s interface {}) []string" + }, + { + "name": "strings.SplitN", + "namespace": "strings", + "signature": "(sep string, n int, s interface {}) []string" + }, + { + "name": "strings.Squote", + "namespace": "strings", + "signature": "(in interface {}) string" + }, + { + "name": "strings.Title", + "namespace": "strings", + "signature": "(s interface {}) string" + }, + { + "name": "strings.ToLower", + "namespace": "strings", + "signature": "(s interface {}) string" + }, + { + "name": "strings.ToUpper", + "namespace": "strings", + "signature": "(s interface {}) string" + }, + { + "name": "strings.Trim", + "namespace": "strings", + "signature": "(cutset string, s interface {}) string" + }, + { + "name": "strings.TrimPrefix", + "namespace": "strings", + "signature": "(cutset string, s interface {}) string" + }, + { + "name": "strings.TrimSpace", + "namespace": "strings", + "signature": "(s interface {}) string" + }, + { + "name": "strings.TrimSuffix", + "namespace": "strings", + "signature": "(cutset string, s interface {}) string" + }, + { + "name": "strings.Trunc", + "namespace": "strings", + "signature": "(length int, s interface {}) string" + }, + { + "name": "strings.WordWrap", + "namespace": "strings", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "sub", + "signature": "(a interface {}, b interface {}) interface {}" + }, + { + "name": "ternary", + "signature": "(tval interface {}, fval interface {}, b interface {}) interface {}" + }, + { + "name": "test.Assert", + "namespace": "test", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "test.Fail", + "namespace": "test", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "test.IsKind", + "namespace": "test", + "signature": "(kind string, arg interface {}) bool" + }, + { + "name": "test.Kind", + "namespace": "test", + "signature": "(arg interface {}) string" + }, + { + "name": "test.Required", + "namespace": "test", + "signature": "(args ...interface {}) (interface {}, error)" + }, + { + "name": "test.Ternary", + "namespace": "test", + "signature": "(tval interface {}, fval interface {}, b interface {}) interface {}" + }, + { + "name": "time.Hour", + "namespace": "time", + "signature": "(n interface {}) time.Duration" + }, + { + "name": "time.InBusinessHour", + "namespace": "time", + "signature": "(value string) (interface {}, error)" + }, + { + "name": "time.InTimeRange", + "namespace": "time", + "signature": "(t interface {}, start string, end string) (bool, error)" + }, + { + "name": "time.Microsecond", + "namespace": "time", + "signature": "(n interface {}) time.Duration" + }, + { + "name": "time.Millisecond", + "namespace": "time", + "signature": "(n interface {}) time.Duration" + }, + { + "name": "time.Minute", + "namespace": "time", + "signature": "(n interface {}) time.Duration" + }, + { + "name": "time.Nanosecond", + "namespace": "time", + "signature": "(n interface {}) time.Duration" + }, + { + "name": "time.Now", + "namespace": "time", + "signature": "() time.Time" + }, + { + "name": "time.Parse", + "namespace": "time", + "signature": "(layout string, value interface {}) (time.Time, error)" + }, + { + "name": "time.ParseDuration", + "namespace": "time", + "signature": "(n interface {}) (time.Duration, error)" + }, + { + "name": "time.ParseInLocation", + "namespace": "time", + "signature": "(layout string, location string, value interface {}) (time.Time, error)" + }, + { + "name": "time.ParseLocal", + "namespace": "time", + "signature": "(layout string, value interface {}) (time.Time, error)" + }, + { + "name": "time.Second", + "namespace": "time", + "signature": "(n interface {}) time.Duration" + }, + { + "name": "time.Since", + "namespace": "time", + "signature": "(n time.Time) time.Duration" + }, + { + "name": "time.Unix", + "namespace": "time", + "signature": "(in interface {}) (time.Time, error)" + }, + { + "name": "time.Until", + "namespace": "time", + "signature": "(n time.Time) time.Duration" + }, + { + "name": "time.ZoneName", + "namespace": "time", + "signature": "() string" + }, + { + "name": "time.ZoneOffset", + "namespace": "time", + "signature": "() int" + }, + { + "name": "title", + "signature": "(s interface {}) string" + }, + { + "name": "toCSV", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "toJSON", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "toJSONPretty", + "signature": "(indent string, in interface {}) (string, error)" + }, + { + "name": "toLower", + "signature": "(s interface {}) string" + }, + { + "name": "toTOML", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "toUpper", + "signature": "(s interface {}) string" + }, + { + "name": "toYAML", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "toml", + "signature": "(in interface {}) (interface {}, error)" + }, + { + "name": "trim", + "signature": "(s string, cutset string) string" + }, + { + "name": "uniq", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "urlParse", + "signature": "(s interface {}) (*url.URL, error)" + }, + { + "name": "urldecode", + "signature": "(input string) (string, error)" + }, + { + "name": "urlencode", + "signature": "(input string) string" + }, + { + "name": "uuid.HashUUID", + "namespace": "uuid", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "uuid.IsValid", + "namespace": "uuid", + "signature": "(in interface {}) (bool, error)" + }, + { + "name": "uuid.Nil", + "namespace": "uuid", + "signature": "() (string, error)" + }, + { + "name": "uuid.Parse", + "namespace": "uuid", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "uuid.V1", + "namespace": "uuid", + "signature": "() (string, error)" + }, + { + "name": "uuid.V4", + "namespace": "uuid", + "signature": "() (string, error)" + }, + { + "name": "values", + "signature": "(in map[string]interface {}) []interface {}" + }, + { + "name": "xpath", + "signature": "(xpathStr string, xmlStr string) ([]string, error)" + }, + { + "name": "yaml", + "signature": "(in interface {}) (map[string]interface {}, error)" + }, + { + "name": "yamlArray", + "signature": "(in interface {}) ([]interface {}, error)" + } + ] + } +} diff --git a/web/packages/lang/src/generated/text-gomplate.config.json b/web/packages/lang/src/generated/text-gomplate.config.json new file mode 100644 index 000000000..c087049ba --- /dev/null +++ b/web/packages/lang/src/generated/text-gomplate.config.json @@ -0,0 +1,31 @@ +{ + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" +} diff --git a/web/packages/lang/src/generated/text-gomplate.monarch.json b/web/packages/lang/src/generated/text-gomplate.monarch.json new file mode 100644 index 000000000..829720674 --- /dev/null +++ b/web/packages/lang/src/generated/text-gomplate.monarch.json @@ -0,0 +1,287 @@ +{ + "defaultToken": "", + "tokenPostfix": ".text-gomplate", + "brackets": [ + { + "open": "{", + "close": "}", + "token": "delimiter.curly" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "functions": [ + "add", + "append", + "assert", + "bool", + "coalesce", + "contains", + "csv", + "csvByColumn", + "csvByRow", + "default", + "dict", + "div", + "endsWith", + "fail", + "first", + "flatten", + "getHealth", + "getStatus", + "has", + "hasPrefix", + "hasSuffix", + "humanDuration", + "humanSize", + "in_business_hours", + "indent", + "isHealthy", + "isKind", + "isReady", + "jmespath", + "join", + "jq", + "json", + "jsonArray", + "jsonpath", + "keyValToMap", + "keys", + "kind", + "last", + "mapToKeyVal", + "matchLabel", + "merge", + "mul", + "neat", + "parseDateTime", + "pow", + "prepend", + "quote", + "rem", + "replaceAll", + "required", + "reverse", + "semver", + "semverCompare", + "seq", + "shellQuote", + "slice", + "sort", + "split", + "splitN", + "squote", + "startsWith", + "sub", + "ternary", + "title", + "toCSV", + "toJSON", + "toJSONPretty", + "toLower", + "toTOML", + "toUpper", + "toYAML", + "toml", + "trim", + "uniq", + "urlParse", + "urldecode", + "urlencode", + "values", + "xpath", + "yaml", + "yamlArray" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "tokenizer": { + "root": [ + [ + "^#\\s*gotemplate:.*$", + "comment.directive" + ], + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "[\\s\\S]", + "source" + ] + ], + "tmplComment": [ + [ + "\\*\\/-?\\}\\}", + { + "next": "@pop", + "token": "comment" + } + ], + [ + "[\\s\\S]", + "comment" + ] + ], + "tmplAction": [ + [ + "-?\\}\\}", + { + "next": "@pop", + "token": "delimiter.template" + } + ], + [ + "[ \\t\\r\\n]+", + "white" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "`[^`]*`", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", + "number" + ], + [ + "\\$[A-Za-z_][A-Za-z0-9_]*", + "variable" + ], + [ + "\\$", + "variable" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "variable.field" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@keywords": "keyword", + "@builtins": "function.builtin", + "@functions": "function", + "@default": "identifier" + } + } + ], + [ + "[()\\[\\]]", + "@brackets" + ], + [ + "\\|", + "operator.pipe" + ], + [ + ":=|=", + "operator" + ], + [ + ",", + "delimiter" + ] + ] + } +} diff --git a/web/packages/lang/src/generated/yaml-gomplate.config.json b/web/packages/lang/src/generated/yaml-gomplate.config.json new file mode 100644 index 000000000..6f112c25c --- /dev/null +++ b/web/packages/lang/src/generated/yaml-gomplate.config.json @@ -0,0 +1,38 @@ +{ + "comments": { + "lineComment": "#", + "blockComment": [ + "", + "" + ] + }, + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" +} diff --git a/web/packages/lang/src/generated/yaml-gomplate.monarch.json b/web/packages/lang/src/generated/yaml-gomplate.monarch.json new file mode 100644 index 000000000..79a7c9908 --- /dev/null +++ b/web/packages/lang/src/generated/yaml-gomplate.monarch.json @@ -0,0 +1,434 @@ +{ + "defaultToken": "", + "tokenPostfix": ".yaml-gomplate", + "brackets": [ + { + "open": "{", + "close": "}", + "token": "delimiter.curly" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "functions": [ + "add", + "append", + "assert", + "bool", + "coalesce", + "contains", + "csv", + "csvByColumn", + "csvByRow", + "default", + "dict", + "div", + "endsWith", + "fail", + "first", + "flatten", + "getHealth", + "getStatus", + "has", + "hasPrefix", + "hasSuffix", + "humanDuration", + "humanSize", + "in_business_hours", + "indent", + "isHealthy", + "isKind", + "isReady", + "jmespath", + "join", + "jq", + "json", + "jsonArray", + "jsonpath", + "keyValToMap", + "keys", + "kind", + "last", + "mapToKeyVal", + "matchLabel", + "merge", + "mul", + "neat", + "parseDateTime", + "pow", + "prepend", + "quote", + "rem", + "replaceAll", + "required", + "reverse", + "semver", + "semverCompare", + "seq", + "shellQuote", + "slice", + "sort", + "split", + "splitN", + "squote", + "startsWith", + "sub", + "ternary", + "title", + "toCSV", + "toJSON", + "toJSONPretty", + "toLower", + "toTOML", + "toUpper", + "toYAML", + "toml", + "trim", + "uniq", + "urlParse", + "urldecode", + "urlencode", + "values", + "xpath", + "yaml", + "yamlArray" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "tokenizer": { + "root": [ + [ + "^#\\s*gotemplate:.*$", + "comment.directive" + ], + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "#.*$", + "comment" + ], + [ + "^---\\s*$", + "keyword.directive" + ], + [ + "^\\.\\.\\.\\s*$", + "keyword.directive" + ], + [ + "^(\\s*)(-\\s+)([^-\\s#\"'][^:#]*?)(\\s*)(:)(?=\\s|$)", + [ + "white", + "delimiter.list", + "type.yaml", + "white", + "delimiter" + ] + ], + [ + "^(\\s*)([^-\\s#\"'][^:#]*?)(\\s*)(:)(?=\\s|$)", + [ + "white", + "type.yaml", + "white", + "delimiter" + ] + ], + [ + "^\\s*-\\s", + "delimiter.list" + ], + [ + "[\u0026*][A-Za-z0-9_-]+", + "variable.anchor" + ], + [ + "!!?[A-Za-z0-9_/-]*", + "type" + ], + [ + "[|\u003e][-+]?", + "keyword.scalar" + ], + [ + "\"", + { + "next": "@yamlDouble", + "token": "string" + } + ], + [ + "'", + { + "next": "@yamlSingle", + "token": "string" + } + ], + [ + "\\b(?:true|false|null|~|yes|no|on|off)\\b", + "keyword.constant" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)\\b", + "number" + ], + [ + "[{}\\[\\]]", + "@brackets" + ], + [ + ",", + "delimiter" + ], + [ + "[\\s\\S]", + "" + ] + ], + "yamlDouble": [ + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "\"", + { + "next": "@pop", + "token": "string" + } + ], + [ + "\\\\.", + "string.escape" + ], + [ + "[^\"\\\\{]+", + "string" + ], + [ + "[\\s\\S]", + "string" + ] + ], + "yamlSingle": [ + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "'", + { + "next": "@pop", + "token": "string" + } + ], + [ + "\\\\.", + "string.escape" + ], + [ + "[^'\\\\{]+", + "string" + ], + [ + "[\\s\\S]", + "string" + ] + ], + "tmplComment": [ + [ + "\\*\\/-?\\}\\}", + { + "next": "@pop", + "token": "comment" + } + ], + [ + "[\\s\\S]", + "comment" + ] + ], + "tmplAction": [ + [ + "-?\\}\\}", + { + "next": "@pop", + "token": "delimiter.template" + } + ], + [ + "[ \\t\\r\\n]+", + "white" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "`[^`]*`", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", + "number" + ], + [ + "\\$[A-Za-z_][A-Za-z0-9_]*", + "variable" + ], + [ + "\\$", + "variable" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "variable.field" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@keywords": "keyword", + "@builtins": "function.builtin", + "@functions": "function", + "@default": "identifier" + } + } + ], + [ + "[()\\[\\]]", + "@brackets" + ], + [ + "\\|", + "operator.pipe" + ], + [ + ":=|=", + "operator" + ], + [ + ",", + "delimiter" + ] + ] + } +} diff --git a/web/packages/lang/src/hover.ts b/web/packages/lang/src/hover.ts new file mode 100644 index 000000000..749975f0b --- /dev/null +++ b/web/packages/lang/src/hover.ts @@ -0,0 +1,140 @@ +import type { GomplateSpec, Monaco, SpecFunction, SpecMacro } from "./types"; + +/** + * Indexes are built per registration, not once per module. + * + * A host's catalogue arrives from its `/api/spec` after the editor has already + * mounted, so anything computed at module scope is frozen before the host can + * speak. `setSpec` re-registers with a fresh index instead. + */ +export function registerCelHover(monaco: Monaco, languageId: string, spec: GomplateSpec) { + return monaco.languages.registerHoverProvider(languageId, celHoverProvider(spec)); +} + +export function registerGoTemplateHover(monaco: Monaco, languageId: string, spec: GomplateSpec) { + return monaco.languages.registerHoverProvider(languageId, goTemplateHoverProvider(spec)); +} + +/** + * The providers the register functions install, exposed so a test can drive + * them over a real model rather than through Monaco's registry, which offers no + * way to enumerate what is registered. + */ +export function celHoverProvider(spec: GomplateSpec) { + const functions = new Map(spec.cel.functions.map((f) => [f.name, f])); + const macrosByName = new Map(); + for (const macro of spec.cel.macros) { + macrosByName.set(macro.name, [...(macrosByName.get(macro.name) ?? []), macro]); + } + + return { + provideHover(model: Model, position: Position) { + const word = dottedWordAt(model, position); + if (!word) return null; + + const fn = functions.get(word.text) ?? functions.get(word.leaf); + if (fn) return { range: word.range, contents: [{ value: functionDocumentation(fn) }] }; + + const macros = macrosByName.get(word.leaf); + if (macros) { + return { + range: word.range, + contents: macros.map((macro) => ({ value: macroDocumentation(macro) })), + }; + } + return null; + }, + }; +} + +export function goTemplateHoverProvider(spec: GomplateSpec) { + const functions = new Map( + spec.gotemplate.functions.map((f) => [f.name, f]), + ); + + return { + provideHover(model: Model, position: Position) { + const word = dottedWordAt(model, position); + const fn = word && functions.get(word.text); + if (!fn || !word) return null; + return { range: word.range, contents: [{ value: functionDocumentation(fn) }] }; + }, + }; +} + +/** Renders a function as markdown: signature, overloads, docs, examples. */ +export function functionDocumentation(fn: SpecFunction): string { + const lines: string[] = []; + lines.push("```", fn.signature ? `${fn.name}${fn.signature}` : fn.name, "```"); + + if (fn.memberOnly) { + lines.push("", "_Callable only in member position: `value." + leafOf(fn.name) + "(...)`._"); + } + if (fn.doc) lines.push("", fn.doc); + + if (fn.overloads?.length) { + lines.push("", "**Overloads**", ""); + for (const overload of fn.overloads) { + const receiver = overload.member && overload.args.length > 0 ? `${overload.args[0]}.` : ""; + const args = overload.member ? overload.args.slice(1) : overload.args; + lines.push(`- \`${receiver}${leafOf(fn.name)}(${args.join(", ")}) -> ${overload.result}\``); + } + } + if (fn.examples?.length) { + lines.push("", "**Examples**", "", "```cel", ...fn.examples, "```"); + } + return lines.join("\n"); +} + +/** Renders a macro as markdown. */ +export function macroDocumentation(macro: SpecMacro): string { + const shape = macro.receiverStyle ? `value.${macro.name}(...)` : `${macro.name}(...)`; + const arity = macro.argCount === 0 ? "variadic" : `${macro.argCount} arguments`; + const lines = ["```", shape, "```", "", `_Macro, ${arity}. Expanded at parse time._`]; + if (macro.doc) lines.push("", macro.doc); + if (macro.examples?.length) lines.push("", "```cel", ...macro.examples, "```"); + return lines.join("\n"); +} + +function leafOf(name: string) { + const dot = name.lastIndexOf("."); + return dot < 0 ? name : name.slice(dot + 1); +} + +interface Model { + getLineContent(line: number): string; +} + +interface Position { + lineNumber: number; + column: number; +} + +/** + * Monaco's own word lookup stops at a dot, so `k8s.isHealthy` would be read as + * `isHealthy`. The dotted name is what the spec is keyed by, so widen it here. + */ +function dottedWordAt(model: Model, position: Position) { + const line = model.getLineContent(position.lineNumber); + const isWord = (c: string) => /[A-Za-z0-9_.]/.test(c); + + let start = position.column - 1; + while (start > 0 && isWord(line[start - 1]!)) start--; + let end = position.column - 1; + while (end < line.length && isWord(line[end]!)) end++; + if (start === end) return null; + + const text = line.slice(start, end).replace(/^\.+|\.+$/g, ""); + if (!text) return null; + + return { + text, + leaf: leafOf(text), + range: { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: start + 1, + endColumn: end + 1, + }, + }; +} diff --git a/web/packages/lang/src/index.ts b/web/packages/lang/src/index.ts new file mode 100644 index 000000000..687c6c15e --- /dev/null +++ b/web/packages/lang/src/index.ts @@ -0,0 +1,170 @@ +import { LANGUAGE_IDS, definitions, spec } from "./generated"; +import type { LanguageId } from "./generated"; +import { registerCompletion } from "./completion"; +import type { EnvironmentSource } from "./completion"; +import { registerCelHover, registerGoTemplateHover } from "./hover"; +import { pathFlavour } from "./environment"; +import { attributesFor } from "./attributes"; +import { mergeSpec } from "./merge"; +import { defineThemes } from "./theme"; +import type { GomplateSpec, LanguageDefinition, Monaco } from "./types"; + +export { spec, LANGUAGE_IDS, definitions }; +export type { LanguageId }; +export * from "./types"; +export { GOMPLATE_DARK_THEME, GOMPLATE_LIGHT_THEME } from "./theme"; +export { + childEntries, + isIdentifier, + kindOf, + pathExpression, + pathFlavour, + resolvePath, + summarize, +} from "./environment"; +export type { EnvironmentEntry, PathSegment, ValueKind } from "./environment"; +export { environmentPrefixAt } from "./prefix"; +export type { EnvironmentPrefix } from "./prefix"; +export type { EnvironmentSource } from "./completion"; +export { mergeSpec } from "./merge"; +export { attributesFor, celAttributes, goTemplateAttributes } from "./attributes"; +export type { Attributes } from "./attributes"; + +export interface RegisterOptions { + /** Which languages to register. Defaults to all of them. */ + languages?: readonly LanguageId[]; + /** Register completion providers. Defaults to true. */ + completions?: boolean; + /** Register hover providers. Defaults to true. */ + hovers?: boolean; + /** Define the gomplate colour themes. Defaults to true. */ + themes?: boolean; + /** + * The document expressions are evaluated against, so completion can offer the + * key paths it actually contains. + * + * A getter rather than a value: registration happens once, before the first + * editor mounts, while the document keeps being edited afterwards. It is + * called on every completion request. + */ + environment?: EnvironmentSource; + /** + * A host's own catalogue, merged over gomplate's. + * + * Usually left unset here and supplied later through `setSpec`, because it + * arrives from the host's `GET /api/spec` after the editor has mounted. + */ + spec?: GomplateSpec; +} + +/** What `registerGomplateLanguages` hands back. */ +export interface RegisteredLanguages { + /** + * Replaces the catalogue and re-applies it. + * + * Registration has to happen in `beforeMount`, before the first model exists, + * while a host's spec arrives over the network afterwards — so gating + * registration on the fetch would stall the editor. Register with the baked + * catalogue instead and call this when the response lands: the tokenizers are + * re-applied with the merged word lists, and completion and hover are + * re-registered against the merged functions. + */ + setSpec(spec: GomplateSpec | undefined): void; + dispose(): void; +} + +/** + * Registers gomplate's languages with a Monaco instance. + * + * Safe to call more than once: a language already registered is left alone, so + * a component tree with several editors does not need to coordinate. The + * returned handle removes only the providers this call added. + */ +export function registerGomplateLanguages( + monaco: Monaco, + options: RegisterOptions = {}, +): RegisteredLanguages { + const { languages = LANGUAGE_IDS, completions = true, hovers = true, themes = true } = options; + + if (themes) defineThemes(monaco); + + const known = new Set(monaco.languages.getLanguages().map((l) => l.id)); + const selected: LanguageId[] = []; + + for (const id of languages) { + const definition: LanguageDefinition | undefined = definitions[id]; + if (!definition) { + throw new Error( + `unknown gomplate language "${id}"; expected one of ${LANGUAGE_IDS.join(", ")}`, + ); + } + selected.push(id); + + // Only the registration itself is once-only. The tokenizer and the + // providers are re-applied below for every selected language, whether or + // not this call is the one that introduced it -- otherwise a second editor + // would get a handle whose setSpec silently does nothing. + if (known.has(id)) continue; + monaco.languages.register({ id }); + monaco.languages.setLanguageConfiguration(id, definition.configuration); + } + + const applySpec = (merged: GomplateSpec) => { + for (const id of selected) { + const definition = definitions[id]!; + const attributes = attributesFor(id, merged); + // Spread over the generated definition rather than replacing it: the + // tokenizer rules and the grammar-derived lists (CEL's `operators`) are + // not the spec's to change. + monaco.languages.setMonarchTokensProvider( + id, + attributes ? { ...definition.monarch, ...attributes } : definition.monarch, + ); + + const flavour = pathFlavour(id); + const installed: { dispose(): void }[] = []; + if (completions) { + installed.push( + registerCompletion(monaco, id, { spec: merged, environment: options.environment }), + ); + } + if (hovers && flavour === "cel") installed.push(registerCelHover(monaco, id, merged)); + if (hovers && flavour === "gotemplate") { + installed.push(registerGoTemplateHover(monaco, id, merged)); + } + replaceProviders(monaco, id, installed); + } + }; + + applySpec(mergeSpec(spec, options.spec)); + + return { + setSpec(next) { + applySpec(mergeSpec(spec, next)); + }, + dispose() { + for (const id of selected) replaceProviders(monaco, id, []); + }, + }; +} + +/** + * One set of completion and hover providers per language, per Monaco. + * + * Monaco stacks providers rather than replacing them, so registering twice for + * a language shows every suggestion twice. Tracking them here means the latest + * registration wins instead, and a component tree with several editors does not + * have to coordinate. + */ +const providersByLanguage = new WeakMap>(); + +function replaceProviders(monaco: Monaco, id: string, installed: { dispose(): void }[]) { + let byLanguage = providersByLanguage.get(monaco); + if (!byLanguage) { + byLanguage = new Map(); + providersByLanguage.set(monaco, byLanguage); + } + for (const disposable of byLanguage.get(id) ?? []) disposable.dispose(); + if (installed.length === 0) byLanguage.delete(id); + else byLanguage.set(id, installed); +} diff --git a/web/packages/lang/src/merge.ts b/web/packages/lang/src/merge.ts new file mode 100644 index 000000000..44fc4e24d --- /dev/null +++ b/web/packages/lang/src/merge.ts @@ -0,0 +1,69 @@ +import type { CelSpec, GoTemplateSpec, GomplateSpec, SpecFunction, SpecMacro } from "./types"; + +/** + * Folds a host's catalogue into the one this package ships. + * + * A host binary registers functions on top of gomplate's — mission-control's + * `catalog.query`, `gitops.source` — and serves the result from `/api/spec`. + * That response already *contains* gomplate's own functions, so merging is + * mostly a union; the interesting case is a host that overrides a name, where + * the host wins because its binary is what will actually evaluate. + */ +export function mergeSpec(base: GomplateSpec, incoming: GomplateSpec | undefined): GomplateSpec { + if (!incoming) return base; + return { + cel: mergeCel(base.cel, incoming.cel), + gotemplate: mergeGoTemplate(base.gotemplate, incoming.gotemplate), + }; +} + +function mergeCel(base: CelSpec, incoming: CelSpec): CelSpec { + return { + namespaces: union(base.namespaces, incoming.namespaces), + keywords: union(base.keywords, incoming.keywords), + types: union(base.types, incoming.types), + variables: union(base.variables ?? [], incoming.variables ?? []), + // Keyed by arity as well as name: `map` is registered twice, at 2 and 3 + // arguments, and folding those together would drop an overload the hover + // list already renders separately. + macros: keyed( + base.macros, + incoming.macros, + (macro) => `${macro.name}/${macro.argCount}/${macro.receiverStyle}`, + ), + functions: byName(base.functions, incoming.functions), + }; +} + +function mergeGoTemplate(base: GoTemplateSpec, incoming: GoTemplateSpec): GoTemplateSpec { + return { + namespaces: union(base.namespaces, incoming.namespaces), + keywords: union(base.keywords, incoming.keywords), + builtins: union(base.builtins, incoming.builtins), + // Delimiters are a property of the binary's parser, not a list to merge: + // a host that has changed them means it, and half of a pair would be worse + // than either. + delimiters: incoming.delimiters ?? base.delimiters, + functions: byName(base.functions, incoming.functions), + }; +} + +function union(base: readonly string[], incoming: readonly string[]): string[] { + return [...new Set([...base, ...incoming])].sort(); +} + +/** Keyed by name, incoming wins, order stable and alphabetical. */ +function byName(base: readonly T[], incoming: readonly T[]): T[] { + return keyed(base, incoming, (item) => item.name); +} + +function keyed( + base: readonly T[], + incoming: readonly T[], + key: (item: T) => string, +): T[] { + const merged = new Map(); + for (const item of base) merged.set(key(item), item); + for (const item of incoming) merged.set(key(item), item); + return [...merged.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); +} diff --git a/web/packages/lang/src/prefix.ts b/web/packages/lang/src/prefix.ts new file mode 100644 index 000000000..d78276c91 --- /dev/null +++ b/web/packages/lang/src/prefix.ts @@ -0,0 +1,173 @@ +import { spec } from "./generated"; +import { pathFlavour } from "./environment"; +import type { PathSegment } from "./environment"; + +/** The subset of a Monaco model this module reads. */ +export interface PrefixModel { + getLineContent(line: number): string; +} + +export interface PrefixPosition { + lineNumber: number; + column: number; +} + +/** A path expression under construction at the cursor. */ +export interface EnvironmentPrefix { + /** The segments already committed, left of the trailing dot. */ + segments: PathSegment[]; + /** The partially typed leaf, empty right after a dot. */ + leaf: string; + /** + * 1-based columns spanning the whole expression typed so far, root marker + * included. A completion replaces this range with a freshly rendered path, so + * the inserted text is always well formed rather than glued onto what is + * already there. + */ + startColumn: number; + endColumn: number; + /** The text in that range, which Monaco filters candidates against. */ + typed: string; +} + +const IDENT = /[A-Za-z0-9_]/; +const SUBSCRIPT = /^(?:(\d+)|"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')$/; + +/** + * Reads the path expression the cursor sits in, or null when it sits somewhere + * the document's keys have no meaning. + * + * `dottedWordAt` in `hover.ts` cannot serve here: it scans past the cursor and + * strips the trailing dot, and the trailing dot is precisely the signal that a + * child is being completed. + */ +export function environmentPrefixAt( + model: PrefixModel, + position: PrefixPosition, + languageId: string, +): EnvironmentPrefix | null { + const flavour = pathFlavour(languageId); + if (!flavour) return null; + + const line = model.getLineContent(position.lineNumber); + const before = line.slice(0, position.column - 1); + + if (flavour === "gotemplate" && !insideAction(before)) return null; + + let leafStart = before.length; + while (leafStart > 0 && IDENT.test(before[leafStart - 1]!)) leafStart--; + const leaf = before.slice(leafStart); + const head = before.slice(0, leafStart); + + const chain = parseChain(head); + if (!chain) return null; + // Without a dot the leaf can only be a root name, so anything that looks like + // the tail of a path before it (`items[0]name`) is malformed, not a prefix. + if (leaf !== "" && !chain.dotted && chain.segments.length > 0) return null; + + // `chain.start` already sits on the leading `.` when there is one, so a go + // template needs no adjustment; JSONPath's `$` sits one place further left. + let start = chain.start; + if (flavour === "gotemplate" && !chain.rooted) return null; + if (flavour === "jsonpath") { + const marker = head[chain.start - 1]; + if (marker !== "$" && marker !== "@") return null; + start -= 1; + } + if (flavour === "cel" && chain.rooted) return null; // a leading `.` is not CEL + + return { + segments: chain.segments, + leaf, + startColumn: start + 1, + endColumn: position.column, + typed: before.slice(start), + }; +} + +interface Chain { + segments: PathSegment[]; + /** Index in `head` where the segment list starts, root marker excluded. */ + start: number; + /** Whether `head` ends with the dot that opens a new segment. */ + dotted: boolean; + /** Whether the outermost segment is preceded by a `.`, as go templates need. */ + rooted: boolean; +} + +/** + * Parses the trailing path of `head` backwards. + * + * Backwards because a path has no left boundary of its own: it ends where the + * expression around it begins, and that is only knowable by walking off it. + */ +function parseChain(head: string): Chain | null { + let pos = head.length; + const dotted = pos > 0 && head[pos - 1] === "."; + if (dotted) pos--; + + const reversed: PathSegment[] = []; + let rooted = false; + + for (;;) { + if (pos > 0 && head[pos - 1] === "]") { + const open = head.lastIndexOf("[", pos - 2); + if (open < 0) return null; + const segment = parseSubscript(head.slice(open + 1, pos - 1)); + if (segment === null) return null; + reversed.push(segment); + pos = open; + rooted = false; + continue; + } + if (pos > 0 && IDENT.test(head[pos - 1]!)) { + let start = pos; + while (start > 0 && IDENT.test(head[start - 1]!)) start--; + reversed.push(head.slice(start, pos)); + pos = start; + rooted = pos > 0 && head[pos - 1] === "."; + if (rooted) { + pos--; + continue; + } + break; + } + break; + } + + // Only the trailing dot was consumed, so that dot is itself the root marker. + if (reversed.length === 0 && dotted) rooted = true; + + return { segments: reversed.reverse(), start: pos, dotted, rooted }; +} + +function parseSubscript(inner: string): PathSegment | null { + const match = SUBSCRIPT.exec(inner.trim()); + if (!match) return null; + if (match[1] !== undefined) return Number(match[1]); + const quoted = match[2] ?? match[3]!; + try { + return JSON.parse(`"${quoted.replace(/\\'/g, "'")}"`) as string; + } catch { + return null; + } +} + +/** + * Whether the cursor sits between template delimiters. Outside them the text is + * literal output, where a key path means nothing. + */ +function insideAction(before: string): boolean { + const { left, right, leftComment, rightComment } = spec.gotemplate.delimiters; + + const open = before.lastIndexOf(left); + if (open < 0) return false; + if (before.lastIndexOf(right) > open) return false; + + // `{{/*` opens with the ordinary left delimiter, so an unterminated comment + // reads as an open action unless it is checked for separately. + const comment = before.lastIndexOf(leftComment); + if (comment >= open && before.lastIndexOf(rightComment) < comment) return false; + + return true; +} diff --git a/web/packages/lang/src/spec.ts b/web/packages/lang/src/spec.ts new file mode 100644 index 000000000..2d8ae5052 --- /dev/null +++ b/web/packages/lang/src/spec.ts @@ -0,0 +1,25 @@ +import { spec } from "./generated"; +import type { GomplateSpec, SpecFunction, SpecMacro } from "./types"; + +export { spec }; +export type { GomplateSpec, SpecFunction, SpecMacro }; + +/** Looks up a CEL function by its fully qualified name. */ +export function celFunction(name: string): SpecFunction | undefined { + return spec.cel.functions.find((fn) => fn.name === name); +} + +/** Looks up a go-template function by its fully qualified name. */ +export function goTemplateFunction(name: string): SpecFunction | undefined { + return spec.gotemplate.functions.find((fn) => fn.name === name); +} + +/** Every CEL function in a namespace, e.g. all of `k8s.*`. */ +export function celNamespace(namespace: string): SpecFunction[] { + return spec.cel.functions.filter((fn) => fn.namespace === namespace); +} + +/** Every go-template function in a namespace. */ +export function goTemplateNamespace(namespace: string): SpecFunction[] { + return spec.gotemplate.functions.filter((fn) => fn.namespace === namespace); +} diff --git a/web/packages/lang/src/theme.ts b/web/packages/lang/src/theme.ts new file mode 100644 index 000000000..a10f4beba --- /dev/null +++ b/web/packages/lang/src/theme.ts @@ -0,0 +1,109 @@ +import type { Monaco } from "./types"; + +export const GOMPLATE_LIGHT_THEME = "gomplate-light"; +export const GOMPLATE_DARK_THEME = "gomplate-dark"; + +/** + * Token rules shared by both themes. Only the colours differ, so the token + * vocabulary stays in one place. + * + * The token names are the ones the generated tokenizers emit; anything not + * listed falls back to the base theme, which is why the themes inherit from + * `vs` and `vs-dark` rather than starting from nothing. + */ +const TOKENS = [ + "namespace", + "function", + "function.member", + "function.builtin", + "keyword.macro", + "keyword.constant", + "keyword.directive", + "operator.optional", + "operator.pipe", + "delimiter.template", + "variable", + "variable.field", + "variable.anchor", + "variable.root", + "variable.current", + "identifier.escaped", + "string.bytes", + "number.uint", + "number.float", + "type.yaml", + "type.json", + "comment.directive", +] as const; + +type Palette = Record<(typeof TOKENS)[number], string>; + +const LIGHT: Palette = { + namespace: "267F99", + function: "795E26", + "function.member": "795E26", + "function.builtin": "0000FF", + "keyword.macro": "AF00DB", + "keyword.constant": "0000FF", + "keyword.directive": "AF00DB", + "operator.optional": "AF00DB", + "operator.pipe": "AF00DB", + "delimiter.template": "AF00DB", + variable: "001080", + "variable.field": "001080", + "variable.anchor": "267F99", + "variable.root": "AF00DB", + "variable.current": "AF00DB", + "identifier.escaped": "001080", + "string.bytes": "A31515", + "number.uint": "098658", + "number.float": "098658", + "type.yaml": "0451A5", + "type.json": "0451A5", + "comment.directive": "008000", +}; + +const DARK: Palette = { + namespace: "4EC9B0", + function: "DCDCAA", + "function.member": "DCDCAA", + "function.builtin": "569CD6", + "keyword.macro": "C586C0", + "keyword.constant": "569CD6", + "keyword.directive": "C586C0", + "operator.optional": "C586C0", + "operator.pipe": "C586C0", + "delimiter.template": "C586C0", + variable: "9CDCFE", + "variable.field": "9CDCFE", + "variable.anchor": "4EC9B0", + "variable.root": "C586C0", + "variable.current": "C586C0", + "identifier.escaped": "9CDCFE", + "string.bytes": "CE9178", + "number.uint": "B5CEA8", + "number.float": "B5CEA8", + "type.yaml": "9CDCFE", + "type.json": "9CDCFE", + "comment.directive": "6A9955", +}; + +/** Defines the gomplate themes. Idempotent -- Monaco overwrites by name. */ +export function defineThemes(monaco: Monaco) { + monaco.editor.defineTheme(GOMPLATE_LIGHT_THEME, { + base: "vs", + inherit: true, + rules: rulesFor(LIGHT), + colors: {}, + }); + monaco.editor.defineTheme(GOMPLATE_DARK_THEME, { + base: "vs-dark", + inherit: true, + rules: rulesFor(DARK), + colors: {}, + }); +} + +function rulesFor(palette: Palette) { + return TOKENS.map((token) => ({ token, foreground: palette[token] })); +} diff --git a/web/packages/lang/src/types.ts b/web/packages/lang/src/types.ts new file mode 100644 index 000000000..4392998ed --- /dev/null +++ b/web/packages/lang/src/types.ts @@ -0,0 +1,90 @@ +import type * as monaco from "monaco-editor"; + +/** The subset of the Monaco namespace this package needs. */ +export type Monaco = typeof monaco; + +/** One generated language: its tokenizer and its editor configuration. */ +export interface LanguageDefinition { + id: string; + monarch: monaco.languages.IMonarchLanguage; + configuration: monaco.languages.LanguageConfiguration; +} + +/** One typed signature of a CEL function. */ +export interface Overload { + id: string; + args: string[]; + result: string; + member?: boolean; +} + +/** A callable name, with every registered overload. */ +export interface SpecFunction { + name: string; + namespace?: string; + /** Callable only as `x.f()`, never as `f(x)`. */ + memberOnly?: boolean; + doc?: string; + /** Go signature; go-template functions only. */ + signature?: string; + overloads?: Overload[]; + examples?: string[]; +} + +/** A CEL macro. Expanded at parse time, so never a function. */ +export interface SpecMacro { + name: string; + argCount: number; + receiverStyle: boolean; + doc?: string; + examples?: string[]; +} + +export interface CelSpec { + namespaces: string[]; + keywords: string[]; + types: string[]; + variables?: string[]; + macros: SpecMacro[]; + functions: SpecFunction[]; +} + +export interface Delimiters { + left: string; + right: string; + leftComment: string; + rightComment: string; + trimMarker: string; +} + +export interface GoTemplateSpec { + namespaces: string[]; + keywords: string[]; + builtins: string[]; + delimiters: Delimiters; + functions: SpecFunction[]; +} + +/** The full catalogue, generated from gomplate's own registries. */ +export interface GomplateSpec { + cel: CelSpec; + gotemplate: GoTemplateSpec; +} + +/** + * One snippet plus the token boundaries the language's real lexer produces. + * + * Generated by running cel-go's own ANTLR lexer over the snippet, so the + * tokenizer is checked against the parser gomplate evaluates with rather than + * against a snapshot of its own output. Snippets for languages whose lexer is + * not reachable carry no boundaries; they are validated by their real parser at + * generation time and only smoke-tested here. + */ +export interface ConformanceCase { + language: string; + source: string; + /** 0-based offsets where a token starts, whitespace excluded. */ + boundaries?: number[]; + /** Where the snippet came from, so a failure is traceable. */ + origin: string; +} diff --git a/web/packages/lang/test/completion.test.ts b/web/packages/lang/test/completion.test.ts new file mode 100644 index 000000000..c0a9ef173 --- /dev/null +++ b/web/packages/lang/test/completion.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +// The package root entry is the browser bundle; the editor API entry is the +// headless surface, which is all a model and a completion provider need. +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; +import { pathExpression, registerGomplateLanguages, spec } from "../src"; +import { completionProvider } from "../src/completion"; + +registerGomplateLanguages(monaco, { completions: false, hovers: false }); + +/** A payload with the shapes that make path rendering interesting. */ +const DOCUMENT = { + pod: { + metadata: { + name: "web-7d4f", + labels: { app: "web", "app.kubernetes.io/name": "web" }, + }, + spec: { containers: [{ name: "app", image: "nginx:1.27" }] }, + status: { phase: "Running" }, + }, + count: 3, +}; + +const environment = () => DOCUMENT as unknown; + +const FIELD_KINDS = [ + monaco.languages.CompletionItemKind.Field, + monaco.languages.CompletionItemKind.Folder, +]; + +/** + * Runs the real provider with the cursor at the end of `text`, and returns only + * the items that came from the document — the catalogue items are asserted on + * separately. + */ +function documentItems(languageId: string, text: string, source = environment) { + return suggest(languageId, text, source).filter((item) => FIELD_KINDS.includes(item.kind)); +} + +function suggest(languageId: string, text: string, source?: () => unknown) { + const model = monaco.editor.createModel(text, languageId); + const lines = text.split("\n"); + const position = new monaco.Position(lines.length, lines[lines.length - 1]!.length + 1); + try { + return completionProvider(monaco, languageId, { + spec, + environment: source, + }).provideCompletionItems(model, position).suggestions; + } finally { + model.dispose(); + } +} + +function labels(languageId: string, text: string) { + return documentItems(languageId, text).map((item) => String(item.label)); +} + +function itemFor(languageId: string, text: string, label: string) { + return documentItems(languageId, text).find((candidate) => candidate.label === label)!; +} + +function insertFor(languageId: string, text: string, label: string) { + return itemFor(languageId, text, label)?.insertText; +} + +describe("completing keys of the document", () => { + it.each([ + ["cel", "pod."], + ["gomplate", "{{ .pod."], + ["jsonpath", "$.pod."], + ])("offers the children of a map in %s", (languageId, text) => { + expect(labels(languageId, text)).toEqual(["metadata", "spec", "status"]); + }); + + it.each([ + ["cel", "pod.metadata.", "name", "pod.metadata.name"], + ["gomplate", "{{ .pod.metadata.", "name", ".pod.metadata.name"], + ["jsonpath", "$.pod.metadata.", "name", "$.pod.metadata.name"], + ])("inserts a whole %s path, not just the leaf", (languageId, text, label, expected) => { + expect(insertFor(languageId, text, label)).toBe(expected); + }); + + it("completes a partially typed leaf", () => { + expect(labels("cel", "pod.metadata.na")).toContain("name"); + }); + + it("offers the top-level keys at the root of an expression", () => { + expect(labels("cel", "")).toEqual(["pod", "count"]); + }); + + it("replaces the whole path typed so far, so nothing is glued onto it", () => { + const item = documentItems("cel", "pod.metadata.").find((i) => i.label === "name")!; + const range = item.range as monaco.IRange; + expect(range.startColumn).toBe(1); + expect(range.endColumn).toBe("pod.metadata.".length + 1); + }); + + it.each([ + ["cel", "pod.spec.containers.", "0", "pod.spec.containers.0"], + ["cel", "pod.metadata.la", "labels", "pod.metadata.labels"], + ["cel", "pod.metadata.labels.", "app.kubernetes.io/name", "pod.metadata.labels.app.kubernetes.io/name"], + ["gomplate", "{{ .pod.", "metadata", ".pod.metadata"], + ["jsonpath", "$.pod.", "metadata", "$.pod.metadata"], + ])( + "filters on the text typed, not the rendered path, in %s", + (languageId, text, label, expected) => { + // Monaco matches a candidate against the model text from the range start + // to the cursor. A `filterText` of `pod.items[0]` never matches what the + // author typed to get there — `pod.items.` — so the item disappears. + const item = itemFor(languageId, text, label); + expect(item.filterText).toBe(expected); + // Whatever the author typed to reach this item still leads it. + expect(text).toContain(String(item.filterText).slice(0, -label.length)); + }, + ); + + it("carries the type and a sample so the shape is readable without running", () => { + const items = documentItems("cel", "pod.metadata."); + expect(items.find((i) => i.label === "name")!.detail).toBe('string · "web-7d4f"'); + expect(items.find((i) => i.label === "labels")!.detail).toBe("object · 2 keys"); + expect(documentItems("cel", "pod.spec.")[0]!.detail).toBe("array · 1 item"); + }); + + it("sorts document keys ahead of the function catalogue", () => { + const suggestions = suggest("cel", "pod.", environment); + const key = suggestions.find((item) => item.label === "metadata")!; + const catalogue = suggestions.filter((item) => !FIELD_KINDS.includes(item.kind)); + expect(catalogue.length).toBeGreaterThan(0); + for (const item of catalogue) { + expect(String(key.sortText) < String(item.sortText)).toBe(true); + } + }); +}); + +describe("where a key path has no meaning", () => { + it("offers nothing outside a template action", () => { + // The same text inside `{{ }}` completes; as literal output it is prose. + expect(labels("gomplate", "hello .pod.")).toEqual([]); + expect(labels("gomplate", "{{ .pod.")).not.toEqual([]); + }); + + it("offers nothing inside a template comment", () => { + expect(labels("gomplate", "{{/* .pod.")).toEqual([]); + }); + + it("requires the go-template leading dot", () => { + expect(labels("gomplate", "{{ pod.")).toEqual([]); + }); + + it("requires a JSONPath root marker", () => { + expect(labels("jsonpath", "pod.")).toEqual([]); + }); + + it("rejects a leading dot in CEL, which has no root object", () => { + expect(labels("cel", ".pod.")).toEqual([]); + }); + + it("offers nothing for an unknown path", () => { + expect(labels("cel", "nope.")).toEqual([]); + }); + + it("falls back to the catalogue when no document is supplied", () => { + const suggestions = suggest("cel", "pod.", undefined); + expect(suggestions.filter((item) => FIELD_KINDS.includes(item.kind))).toEqual([]); + expect(suggestions.some((item) => item.label === "size")).toBe(true); + }); +}); + +describe("lists", () => { + it("offers indices rather than the element's keys", () => { + // `containers.name` parses in none of these languages. Offering the index + // instead is what keeps the inserted expression evaluable: the item rewrites + // the trailing dot into a subscript rather than appending to it. + expect(labels("cel", "pod.spec.containers.")).toEqual(["0"]); + expect(insertFor("cel", "pod.spec.containers.", "0")).toBe("pod.spec.containers[0]"); + }); + + it("completes through an index", () => { + expect(labels("cel", "pod.spec.containers[0].")).toEqual(["name", "image"]); + expect(insertFor("cel", "pod.spec.containers[0].", "image")).toBe( + "pod.spec.containers[0].image", + ); + }); + + it("has no index to offer in a go template, which reaches one through `index`", () => { + expect(labels("gomplate", "{{ .pod.spec.containers.")).toEqual([]); + }); +}); + +describe("keys that are not identifiers", () => { + it("subscripts them in CEL and JSONPath", () => { + expect(insertFor("cel", "pod.metadata.labels.", "app.kubernetes.io/name")).toBe( + 'pod.metadata.labels["app.kubernetes.io/name"]', + ); + expect(insertFor("jsonpath", "$.pod.metadata.labels.", "app.kubernetes.io/name")).toBe( + '$.pod.metadata.labels["app.kubernetes.io/name"]', + ); + }); + + it("omits them for go templates, which need `index` rather than a path", () => { + expect(labels("gomplate", "{{ .pod.metadata.labels.")).toEqual(["app"]); + }); +}); + +describe("pathExpression", () => { + it.each([ + ["cel", ["pod", "metadata"], "pod.metadata"], + ["cel", ["pod", 0], "pod[0]"], + ["yaml-gomplate", ["pod", "metadata"], ".pod.metadata"], + ["gomplate", [], "."], + ["jsonpath", [], "$"], + ["jsonpath", ["a b"], '$["a b"]'], + ])("renders %s paths", (languageId, segments, expected) => { + expect(pathExpression(languageId, segments as (string | number)[])).toBe(expected); + }); + + it("has no rendering for a go-template list element", () => { + expect(pathExpression("gomplate", ["items", 0])).toBeNull(); + }); + + it("has no rendering for an unknown language", () => { + expect(pathExpression("klingon", ["a"])).toBeNull(); + }); +}); diff --git a/web/packages/lang/test/conformance.test.ts b/web/packages/lang/test/conformance.test.ts new file mode 100644 index 000000000..9683e34e4 --- /dev/null +++ b/web/packages/lang/test/conformance.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; +import { conformance } from "../src/generated"; +import { registerGomplateLanguages } from "../src"; + +registerGomplateLanguages(monaco, { completions: false, hovers: false }); + +/** + * Token start offsets Monarch produces for a single-line snippet, excluding + * whitespace-only tokens so the comparison matches the lexer's own view. + */ +function monarchBoundaries(source: string, languageId: string): number[] { + const [tokens = []] = monaco.editor.tokenize(source, languageId); + const out: number[] = []; + + tokens.forEach((token, i) => { + const end = i + 1 < tokens.length ? tokens[i + 1]!.offset : source.length; + if (source.slice(token.offset, end).trim() === "") return; + out.push(token.offset); + }); + return out; +} + +const byLanguage = new Map(); +for (const testCase of conformance) { + byLanguage.set(testCase.language, [...(byLanguage.get(testCase.language) ?? []), testCase]); +} + +describe("conformance corpus", () => { + it("covers every language and is not trivially small", () => { + expect(conformance.length).toBeGreaterThan(50); + expect([...byLanguage.keys()].sort()).toEqual(["cel", "gomplate", "jsonpath"]); + }); + + describe("cel token boundaries agree with cel-go's own lexer", () => { + const cases = (byLanguage.get("cel") ?? []).filter((c) => c.boundaries?.length); + + it("has boundaries for every CEL case", () => { + expect(cases.length).toBe(byLanguage.get("cel")?.length); + }); + + for (const testCase of cases) { + it(`${testCase.source} (${testCase.origin})`, () => { + // Every boundary Monarch produces must be a boundary the real lexer + // also has -- that is, the tokenizer never ends a token part-way + // through a real one. This is where the subtle bugs live: a + // triple-quoted string cut short after two quotes, `0x1f` truncated to + // `0`, `123u` split into a number and an identifier. + // + // The reverse does not hold, and should not be asserted: Monaco merges + // adjacent tokens of the same type, so `()` collapses into one token + // and legitimately loses the lexer's boundary between them. + const expected = new Set(testCase.boundaries!); + const spurious = monarchBoundaries(testCase.source, "cel").filter( + (offset) => !expected.has(offset), + ); + + expect( + spurious, + `tokenizer split ${JSON.stringify(testCase.source)} at offsets the CEL lexer does not`, + ).toEqual([]); + }); + } + }); + + it("would actually catch a tokenizer that splits a literal", () => { + // A gate that cannot fail is worse than no gate. Feed the comparison a + // boundary set missing the ones inside a triple-quoted string -- the shape + // the tokenizer produced before the grammar translation was fixed to order + // alternatives longest-first -- and confirm it reports the difference. + const source = '"""a"""'; + const brokenLexerView = new Set([0]); + const asIfSplit = [0, 2, 3, 5]; + const spurious = asIfSplit.filter((offset) => !brokenLexerView.has(offset)); + expect(spurious).not.toEqual([]); + + // And the real tokenizer keeps it whole. + expect(monarchBoundaries(source, "cel")).toEqual([0]); + }); + + describe("every snippet tokenizes without an error token", () => { + for (const testCase of conformance) { + it(`${testCase.language}: ${testCase.source}`, () => { + const [tokens = []] = monaco.editor.tokenize(testCase.source, testCase.language); + expect(tokens.length).toBeGreaterThan(0); + // `invalid` is what a Monarch definition emits when nothing matched. + expect(tokens.map((t) => t.type).filter((t) => t.startsWith("invalid"))).toEqual([]); + }); + } + }); +}); diff --git a/web/packages/lang/test/hostSpec.test.ts b/web/packages/lang/test/hostSpec.test.ts new file mode 100644 index 000000000..4645894fc --- /dev/null +++ b/web/packages/lang/test/hostSpec.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; +import { attributesFor, definitions, mergeSpec, registerGomplateLanguages, spec } from "../src"; +import { celHoverProvider } from "../src/hover"; +import type { GomplateSpec, LanguageId } from "../src"; + +registerGomplateLanguages(monaco, { completions: false, hovers: false }); + +/** + * The catalogue a host serves from its own `GET /api/spec`. + * + * Shaped like duty's `catalog.query`: a namespaced global function with one + * typed overload. A host's spec response contains gomplate's own functions too, + * so the realistic input is gomplate's spec plus the host's. + */ +function hostSpec(): GomplateSpec { + return { + ...spec, + cel: { + ...spec.cel, + namespaces: [...spec.cel.namespaces, "catalog"], + functions: [ + ...spec.cel.functions, + { + name: "catalog.query", + namespace: "catalog", + doc: "Queries the config catalogue.", + overloads: [{ id: "catalog.query_string", args: ["string"], result: "dyn" }], + }, + ], + }, + }; +} + +function tokensOf(text: string, languageId: string) { + const lines = monaco.editor.tokenize(text, languageId); + return (lines[0] ?? []).map((token) => token.type.replace(/\.[a-z-]+$/, "")); +} + +describe("the generated attributes and the runtime derivation agree", () => { + // The word lists are derived twice: in Go, when the bundle is generated, and + // here, when a host's spec is merged in. This pins the two together — if + // genmonarch's derivation changes, this fails rather than the highlighting + // quietly going wrong for hosts only. + it.each([ + ["cel", ["keywords", "constants", "typeKeywords", "macros", "namespaces", "globalFunctions", "memberFunctions"]], + ["gomplate", ["keywords", "builtins", "namespaces", "functions"]], + ["yaml-gomplate", ["keywords", "builtins", "namespaces", "functions"]], + ] as const)("recomputes %s's word lists exactly", (languageId, names) => { + const generated = definitions[languageId as LanguageId]!.monarch as unknown as Record< + string, + string[] + >; + const derived = attributesFor(languageId, spec)!; + + for (const name of names) { + expect(derived[name], `${languageId}.${name}`).toEqual(generated[name]); + } + }); + + it("leaves the grammar's own lists alone", () => { + // `operators` comes from CEL.g4, not from the spec, so the derivation must + // not claim to produce it — spreading a partial set over the definition is + // what keeps it. + expect(attributesFor("cel", spec)).not.toHaveProperty("operators"); + expect(definitions.cel.monarch).toHaveProperty("operators"); + }); + + it("has nothing to derive for jsonpath", () => { + expect(attributesFor("jsonpath", spec)).toBeNull(); + }); +}); + +describe("merging a host's catalogue", () => { + it("keeps gomplate's functions and adds the host's", () => { + const merged = mergeSpec(spec, hostSpec()); + const names = merged.cel.functions.map((fn) => fn.name); + expect(names).toContain("catalog.query"); + expect(names).toContain("k8s.cpuAsMillicores"); + expect(merged.cel.namespaces).toContain("catalog"); + }); + + it("lets the host win a name it redefines", () => { + // The host's binary is what evaluates, so its declaration is the true one. + const overridden = mergeSpec(spec, { + ...spec, + cel: { + ...spec.cel, + functions: [{ name: "k8s.cpuAsMillicores", namespace: "k8s", doc: "host override" }], + }, + }); + const fn = overridden.cel.functions.find((f) => f.name === "k8s.cpuAsMillicores"); + expect(fn?.doc).toBe("host override"); + }); + + it("keeps macro overloads that share a name", () => { + // `map` is registered at both 2 and 3 arguments; keying by name alone + // silently drops one and the hover stops listing it. + const merged = mergeSpec(spec, spec); + const maps = merged.cel.macros.filter((macro) => macro.name === "map"); + expect(maps.length).toBe(spec.cel.macros.filter((m) => m.name === "map").length); + expect(maps.length).toBeGreaterThan(1); + }); + + it("returns the base untouched when there is nothing to merge", () => { + expect(mergeSpec(spec, undefined)).toBe(spec); + }); +}); + +describe("documenting a host's function", () => { + const hover = (text: string, column: number) => { + const model = monaco.editor.createModel(text, "cel"); + try { + return celHoverProvider(mergeSpec(spec, hostSpec())).provideHover(model, { + lineNumber: 1, + column, + }); + } finally { + model.dispose(); + } + }; + + it("hovers a host's function with its signature and docs", () => { + // Column 4 is inside `catalog`, so this also covers the dotted-word lookup + // widening past the namespace separator. + const contents = hover(`catalog.query("x")`, 4)?.contents; + const markdown = contents?.map((c) => c.value).join("\n") ?? ""; + expect(markdown).toContain("catalog.query"); + expect(markdown).toContain("Queries the config catalogue."); + expect(markdown).toContain("string"); + }); + + it("still hovers gomplate's own functions", () => { + const markdown = hover(`k8s.cpuAsMillicores("500m")`, 6) + ?.contents.map((c) => c.value) + .join("\n"); + expect(markdown).toContain("cpuAsMillicores"); + }); + + it("says nothing about a name in neither catalogue", () => { + expect(hover(`nonesuch(1)`, 4)).toBeNull(); + }); +}); + +describe("setSpec", () => { + it("tokenizes a host's namespaced function only after the spec arrives", () => { + const languages = registerGomplateLanguages(monaco, { languages: ["cel"] }); + try { + // Before: `catalog` is not a namespace this binary knows. + expect(tokensOf(`catalog.query("x")`, "cel")).not.toContain("namespace"); + + languages.setSpec(hostSpec()); + const after = tokensOf(`catalog.query("x")`, "cel"); + expect(after).toContain("namespace"); + expect(after).toContain("function"); + } finally { + languages.dispose(); + } + }); + + it("still tokenizes gomplate's own functions afterwards", () => { + const languages = registerGomplateLanguages(monaco, { languages: ["cel"] }); + try { + languages.setSpec(hostSpec()); + expect(tokensOf(`k8s.cpuAsMillicores("500m")`, "cel")).toContain("namespace"); + } finally { + languages.dispose(); + } + }); + + it("reverts to the baked catalogue when passed nothing", () => { + const languages = registerGomplateLanguages(monaco, { languages: ["cel"] }); + try { + languages.setSpec(hostSpec()); + languages.setSpec(undefined); + expect(tokensOf(`catalog.query("x")`, "cel")).not.toContain("namespace"); + } finally { + languages.dispose(); + } + }); + + it("does not throw when called before any model exists", () => { + const languages = registerGomplateLanguages(monaco, { languages: ["cel"] }); + expect(() => languages.setSpec(hostSpec())).not.toThrow(); + languages.dispose(); + }); +}); diff --git a/web/packages/lang/test/registration.test.ts b/web/packages/lang/test/registration.test.ts new file mode 100644 index 000000000..8be70480e --- /dev/null +++ b/web/packages/lang/test/registration.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { registerGomplateLanguages } from "../src"; +import type { Monaco } from "../src"; + +/** + * Counts what reaches Monaco. + * + * Real Monaco offers no way to enumerate the providers registered for a + * language, and the invariant under test is the bookkeeping around it rather + * than anything Monaco does — so this stands in for it. The tokenizer and + * completion behaviour itself is covered against real Monaco elsewhere. + */ +function stubMonaco() { + const state = { + registered: [] as string[], + tokenizers: [] as string[], + providers: 0, + disposed: 0, + }; + + const disposable = () => { + state.providers += 1; + return { + dispose() { + state.disposed += 1; + }, + }; + }; + + const monaco = { + languages: { + getLanguages: () => state.registered.map((id) => ({ id })), + register: ({ id }: { id: string }) => state.registered.push(id), + setLanguageConfiguration: () => ({ dispose() {} }), + setMonarchTokensProvider: (id: string) => { + state.tokenizers.push(id); + return { dispose() {} }; + }, + registerCompletionItemProvider: disposable, + registerHoverProvider: disposable, + CompletionItemKind: new Proxy({}, { get: () => 0 }), + }, + editor: { defineTheme: () => {} }, + } as unknown as Monaco; + + return { monaco, state }; +} + +describe("registering more than once", () => { + it("registers the language itself only the first time", () => { + const { monaco, state } = stubMonaco(); + registerGomplateLanguages(monaco, { languages: ["cel"] }); + registerGomplateLanguages(monaco, { languages: ["cel"] }); + expect(state.registered).toEqual(["cel"]); + }); + + it("replaces the previous providers rather than stacking them", () => { + // The bug this guards: `beforeMount` fires once per editor, so a two-editor + // page registered twice and every suggestion appeared twice. + const { monaco, state } = stubMonaco(); + registerGomplateLanguages(monaco, { languages: ["cel"] }); + const installed = state.providers; + + registerGomplateLanguages(monaco, { languages: ["cel"] }); + expect(state.providers).toBe(installed * 2); + expect(state.disposed).toBe(installed); + }); + + it("lets a later handle update the catalogue the earlier one registered", () => { + // setSpec has to act on every language the call selected, not only those it + // introduced, or the second editor's handle is inert. + const { monaco, state } = stubMonaco(); + registerGomplateLanguages(monaco, { languages: ["cel"] }); + const second = registerGomplateLanguages(monaco, { languages: ["cel"] }); + + const before = state.tokenizers.length; + second.setSpec(undefined); + expect(state.tokenizers.length).toBeGreaterThan(before); + }); + + it("disposes what it installed", () => { + const { monaco, state } = stubMonaco(); + const languages = registerGomplateLanguages(monaco, { languages: ["cel"] }); + const installed = state.providers; + languages.dispose(); + expect(state.disposed).toBe(installed); + }); + + it("rejects an unknown language instead of silently registering nothing", () => { + const { monaco } = stubMonaco(); + expect(() => + // @ts-expect-error -- deliberately outside the union + registerGomplateLanguages(monaco, { languages: ["klingon"] }), + ).toThrow(/unknown gomplate language/); + }); +}); diff --git a/web/packages/lang/test/setup.ts b/web/packages/lang/test/setup.ts new file mode 100644 index 000000000..5bc78e879 --- /dev/null +++ b/web/packages/lang/test/setup.ts @@ -0,0 +1,15 @@ +// Monaco's theme service probes `matchMedia` for the forced-colors setting as +// soon as the editor API module loads. jsdom does not implement it, so provide +// the minimum shape the probe needs. +if (typeof window !== "undefined" && typeof window.matchMedia !== "function") { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia; +} diff --git a/web/packages/lang/test/tokenize.test.ts b/web/packages/lang/test/tokenize.test.ts new file mode 100644 index 000000000..888395724 --- /dev/null +++ b/web/packages/lang/test/tokenize.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; +// The package root entry is the browser bundle, which needs a real DOM to even +// load. The editor API entry is the headless surface, and it is all the +// tokenizer needs. +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; +import { LANGUAGE_IDS, registerGomplateLanguages } from "../src"; + +registerGomplateLanguages(monaco, { completions: false, hovers: false }); + +/** + * Tokenizes with real Monaco and returns `text=token` pairs, dropping + * whitespace so the assertions stay about the interesting tokens. + * + * Monaco reports a token's start offset only, so each token's text runs to the + * start of the next one. + */ +function tokenize(text: string, languageId: string): string[] { + const lines = monaco.editor.tokenize(text, languageId); + const out: string[] = []; + + text.split(/\r\n|\r|\n/).forEach((line, index) => { + const tokens = lines[index] ?? []; + tokens.forEach((token, i) => { + const end = i + 1 < tokens.length ? tokens[i + 1]!.offset : line.length; + const value = line.slice(token.offset, end); + if (value.trim() === "") return; + // Monaco appends the language's tokenPostfix to every token type. + out.push(`${value}=${token.type.replace(/\.[a-z-]+$/, "")}`); + }); + }); + return out; +} + +describe("registration", () => { + it("registers every generated language", () => { + const registered = new Set(monaco.languages.getLanguages().map((l) => l.id)); + for (const id of LANGUAGE_IDS) expect(registered).toContain(id); + }); + + it("is idempotent, so several editors can register independently", () => { + expect(() => registerGomplateLanguages(monaco, { completions: false, hovers: false })).not.toThrow(); + }); + + it("rejects an unknown language instead of silently registering nothing", () => { + expect(() => + // @ts-expect-error -- deliberately outside the union + registerGomplateLanguages(monaco, { languages: ["klingon"] }), + ).toThrow(/unknown gomplate language/); + }); +}); + +describe("cel", () => { + it("colours a namespaced call, distinguishing namespace from function", () => { + expect(tokenize("k8s.isHealthy(pod)", "cel")).toEqual([ + "k8s=namespace", + ".=delimiter", + "isHealthy=function", + "(=delimiter.parenthesis", + "pod=identifier", + ")=delimiter.parenthesis", + ]); + }); + + it("does not treat an ordinary field access as a namespace", () => { + expect(tokenize("pod.metadata", "cel")).toEqual([ + "pod=identifier", + ".=delimiter", + "metadata=variable.field", + ]); + }); + + it("colours a member-only function but not the same word used as a variable", () => { + expect(tokenize("[1,2].sum()", "cel")).toContain("sum=function.member"); + expect(tokenize("sum + 1", "cel")).toEqual([ + "sum=identifier", + "+=operator", + "1=number", + ]); + }); + + it("highlights macros only in call position", () => { + expect(tokenize("has(a.b)", "cel")).toContain("has=keyword.macro"); + // Receiver-style macros are macros, not member functions. + expect(tokenize("[1].fold(e, acc, acc + e)", "cel")).toContain("fold=keyword.macro"); + expect(tokenize("items.fold(e, acc, acc + e)", "cel")).toContain("fold=keyword.macro"); + // The same word outside call position is an ordinary field. + expect(tokenize("a.fold", "cel")).toContain("fold=variable.field"); + }); + + it("keeps a triple-quoted string whole", () => { + expect(tokenize('"""a "b" c"""', "cel")).toEqual(['"""a "b" c"""=string']); + }); + + it("recognises raw and bytes string prefixes", () => { + expect(tokenize('r"a\\db"', "cel")).toEqual(['r"a\\db"=string']); + expect(tokenize('b"abc"', "cel")).toEqual(['b"abc"=string.bytes']); + }); + + it("recognises back-tick escaped identifiers", () => { + expect(tokenize("`a.b-c`", "cel")).toEqual(["`a.b-c`=identifier.escaped"]); + }); + + it("separates uint and float literals from plain ints", () => { + expect(tokenize("123u", "cel")).toEqual(["123u=number.uint"]); + expect(tokenize("1.5e-3", "cel")).toEqual(["1.5e-3=number.float"]); + expect(tokenize("0x1f", "cel")).toEqual(["0x1f=number"]); + }); + + it("distinguishes optional access from the ternary operator", () => { + // The name after `.?` is a field, exactly as it is after a plain `.`. + expect(tokenize("a.?b", "cel")).toEqual([ + "a=identifier", + ".?=operator.optional", + "b=variable.field", + ]); + expect(tokenize('a.?b.orValue("x")', "cel")).toEqual([ + "a=identifier", + ".?=operator.optional", + "b=variable.field", + ".=delimiter", + "orValue=function.member", + "(=delimiter.parenthesis", + '"x"=string', + ")=delimiter.parenthesis", + ]); + expect(tokenize("a ? b : c", "cel")).toEqual([ + "a=identifier", + "?=operator", + "b=identifier", + ":=operator", + "c=identifier", + ]); + }); + + it("colours comments and constants", () => { + expect(tokenize("// note\ntrue", "cel")).toEqual([ + "// note=comment", + "true=keyword.constant", + ]); + }); +}); + +describe("gomplate", () => { + it("separates literal text from an action", () => { + // Monaco merges adjacent tokens of the same type, so the literal run keeps + // its trailing space. + expect(tokenize("Hello {{ .name }}!", "gomplate")).toEqual([ + "Hello =source", + "{{=delimiter.template", + ".=delimiter", + "name=variable.field", + "}}=delimiter.template", + "!=source", + ]); + }); + + it("colours a pipeline into a namespaced function", () => { + expect(tokenize("{{ .name | strings.ToUpper }}", "gomplate")).toEqual([ + "{{=delimiter.template", + ".=delimiter", + "name=variable.field", + "|=operator.pipe", + "strings=namespace", + ".=delimiter", + "ToUpper=function", + "}}=delimiter.template", + ]); + }); + + it("handles trim markers", () => { + expect(tokenize("a{{- if .x -}}b{{- end -}}c", "gomplate")).toContain("{{-=delimiter.template"); + expect(tokenize("a{{- if .x -}}b", "gomplate")).toContain("if=keyword"); + }); + + it("treats a template comment as a comment, not an action", () => { + // The whole comment is one merged token; what matters is that `hidden` is + // not tokenized as a keyword or a function. + expect(tokenize("{{/* hidden */}}", "gomplate")).toEqual(["{{/* hidden */}}=comment"]); + expect(tokenize("{{/* if range */}}", "gomplate")).toEqual(["{{/* if range */}}=comment"]); + }); + + it("colours variables and assignment separately from fields", () => { + expect(tokenize("{{ $x := .y }}", "gomplate")).toEqual([ + "{{=delimiter.template", + "$x=variable", + ":==operator", + ".=delimiter", + "y=variable.field", + "}}=delimiter.template", + ]); + }); + + it("marks the delimiter directive header", () => { + expect(tokenize("# gotemplate: left-delim=$[[ right-delim=]]", "gomplate")).toEqual([ + "# gotemplate: left-delim=$[[ right-delim=]]=comment.directive", + ]); + }); + + it("colours builtins distinctly from gomplate functions", () => { + const tokens = tokenize("{{ printf \"%s\" (toJSON .x) }}", "gomplate"); + expect(tokens).toContain("printf=function.builtin"); + expect(tokens).toContain("toJSON=function"); + }); +}); + +describe("yaml-gomplate", () => { + it("colours YAML structure and the template inside a value", () => { + expect(tokenize("name: {{ .app }}", "yaml-gomplate")).toEqual([ + "name=type.yaml", + ":=delimiter", + "{{=delimiter.template", + ".=delimiter", + "app=variable.field", + "}}=delimiter.template", + ]); + }); + + it("keeps YAML comments and constants", () => { + expect(tokenize("# note\nenabled: true", "yaml-gomplate")).toEqual([ + "# note=comment", + "enabled=type.yaml", + ":=delimiter", + "true=keyword.constant", + ]); + }); + + it("colours a template inside a list item", () => { + const tokens = tokenize("items:\n - {{ .a }}", "yaml-gomplate"); + expect(tokens).toContain("{{=delimiter.template"); + expect(tokens).toContain("a=variable.field"); + }); +}); + +describe("json-gomplate", () => { + it("colours object keys and an embedded template", () => { + expect(tokenize('{"k": "{{ .v }}"}', "json-gomplate")).toEqual([ + "{=delimiter.curly", + '"k"=type.json', + ":=delimiter", + '"=string', + "{{=delimiter.template", + ".=delimiter", + "v=variable.field", + "}}=delimiter.template", + '"=string', + "}=delimiter.curly", + ]); + }); +}); + +describe("jsonpath", () => { + it("colours root, descent and filters", () => { + expect(tokenize("$..book[?(@.price < 10)]", "jsonpath")).toEqual([ + "$=variable.root", + "..=operator.descendant", + "book=variable.field", + "[=delimiter.square", + "?(=keyword.filter", + "@=variable.current", + ".=delimiter", + "price=variable.field", + "<=operator", + "10=number", + ")=delimiter.parenthesis", + "]=delimiter.square", + ]); + }); + + it("colours a wildcard and a slice", () => { + const tokens = tokenize("$.items[*][0:2]", "jsonpath"); + expect(tokens).toContain("*=operator.wildcard"); + expect(tokens).toContain(":=operator.slice"); + }); +}); diff --git a/web/packages/lang/tsconfig.build.json b/web/packages/lang/tsconfig.build.json new file mode 100644 index 000000000..3d3918b34 --- /dev/null +++ b/web/packages/lang/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "types": [] + }, + "include": ["src"] +} diff --git a/web/packages/lang/tsconfig.json b/web/packages/lang/tsconfig.json new file mode 100644 index 000000000..f0f98a373 --- /dev/null +++ b/web/packages/lang/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + // Matches clicky-ui's base config, which vendors this source into + // @flanksource/expressions. Checking it here rather than there is what + // keeps a vendoring from failing on a rule this package never applied. + "exactOptionalPropertyTypes": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vitest/globals"] + }, + "include": ["src", "test", "vite.config.ts"] +} diff --git a/web/packages/lang/vite.config.ts b/web/packages/lang/vite.config.ts new file mode 100644 index 000000000..4cc3b601b --- /dev/null +++ b/web/packages/lang/vite.config.ts @@ -0,0 +1,26 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + lib: { + entry: { + index: resolve(__dirname, "src/index.ts"), + spec: resolve(__dirname, "src/spec.ts"), + }, + formats: ["es", "cjs"], + fileName: (format, name) => `${name}.${format === "es" ? "js" : "cjs"}`, + }, + // monaco is passed in by the caller, never imported at runtime -- keeping it + // external is what lets a host app own its Monaco instance and version. + rollupOptions: { external: ["monaco-editor"] }, + sourcemap: true, + target: "es2022", + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./test/setup.ts"], + include: ["test/**/*.test.ts"], + }, +}); diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 000000000..bea2b3c62 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,27 @@ +packages: + - packages/* + - apps/* + +allowBuilds: + esbuild: true + +# Mirrors the versions clicky-ui pins in its own catalog. The playground links +# the sibling clicky-ui checkout when it is present, and a linked workspace +# package resolves its `catalog:` specifiers against *this* catalog, so the +# overlapping entries have to agree. +catalog: + "@monaco-editor/react": ^4.7.0 + "@tailwindcss/vite": ^4.1.13 + "@types/node": ^22.10.5 + "@types/react": ^19.0.7 + "@types/react-dom": ^19.0.3 + "@vitejs/plugin-react": ^4.3.4 + jsdom: ^26.0.0 + monaco-editor: 0.48.0 + react: ^19.0.0 + react-dom: ^19.0.0 + tailwindcss: ^4.1.13 + typescript: ^5.7.3 + vite: ^6.0.7 + vitest: ^3.2.6 + yaml: ^2.8.3 From 16cd42ebd6a09bcf05fa635752ed12c6304608b6 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 14 Aug 2026 14:15:05 +0300 Subject: [PATCH 4/7] feat(web): add playground tooling and generated language definitions Provide a browser playground that evaluates against the real gomplate engine and stays aligned with its language registries. Add generated Monaco definitions with drift detection, plus CI coverage for web typechecking, tests, and builds. --- .github/workflows/web.yml | 52 ++++++++++++ Makefile | 35 ++++++++ cel.go | 25 ++++++ cmd/genmonarch/main.go | 156 ++++++++++++++++++++++++++++++++++++ cmd/playground/main.go | 43 ++++++++++ go.mod | 2 +- hack/hostplayground/main.go | 72 +++++++++++++++++ 7 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/web.yml create mode 100644 cmd/genmonarch/main.go create mode 100644 cmd/playground/main.go create mode 100644 hack/hostplayground/main.go diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml new file mode 100644 index 000000000..0a175c8a2 --- /dev/null +++ b/.github/workflows/web.yml @@ -0,0 +1,52 @@ +name: web +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + # The Monaco language definitions are generated from cel-go's grammar and + # gomplate's live registries. If a registered function changes and the + # generated files are not refreshed, the editor silently disagrees with the + # evaluator -- so drift is a build failure, not a warning. + drift: + name: generated definitions are current + runs-on: ubuntu-latest + steps: + - name: Install Go + uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2 + with: + go-version: 1.22.x + - name: Checkout code + uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 + - name: Check for drift + run: make monarch-check + + web: + name: build and test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 + - name: Install pnpm + uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + version: 10 + - name: Install Node + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + - name: Install dependencies + run: pnpm -C web install --frozen-lockfile + - name: Typecheck + run: pnpm -C web typecheck + - name: Test + run: pnpm -C web test + - name: Build + run: pnpm -C web build diff --git a/Makefile b/Makefile index b3fb1abf9..5f3952ca0 100644 --- a/Makefile +++ b/Makefile @@ -216,3 +216,38 @@ gencel-gen: gencel .PHONY: cleancel cleancel: rm funcs/*_gen.go + +MONARCH_OUT := web/packages/lang/src/generated + +# Regenerates the Monaco language definitions from cel-go's grammar, the +# text/template lexer and gomplate's live registries. Run after changing any +# registered function. +.PHONY: monarch +monarch: + go run ./cmd/genmonarch -out $(MONARCH_OUT) + +# Fails when the checked-in definitions no longer match the code they describe. +.PHONY: monarch-check +monarch-check: + go run ./cmd/genmonarch -out $(MONARCH_OUT) -check + +.PHONY: web-install +web-install: + pnpm -C web install --frozen-lockfile + +.PHONY: web-build +web-build: web-install + pnpm -C web build + +.PHONY: web-test +web-test: web-install + pnpm -C web test + +# Serves the evaluation API the playground proxies to. +.PHONY: playground-server +playground-server: + go run ./cmd/playground + +.PHONY: playground +playground: web-install + pnpm -C web dev:playground diff --git a/cel.go b/cel.go index 017f028fc..728927d45 100644 --- a/cel.go +++ b/cel.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" "regexp" + "sort" "sync" "github.com/flanksource/commons/context" @@ -89,6 +90,19 @@ func GetCelEnv(environment map[string]any) []cel.EnvOption { return opts } +// CompileEnvOptions returns the options RunExpressionContext will compile the +// template against: the static set plus the per-call variables, registered +// native types, Functions and CelEnvs. +// +// For callers that want to compile an expression themselves before running it, +// to get a source position out of the issues -- which RunExpression's error +// does not carry. Building that environment from GetCelEnv alone reports a +// template's own Functions and CelEnvs as undeclared references. +func CompileEnvOptions(environment map[string]any, template Template) []cel.EnvOption { + opts := staticCelEnvOptions() + return append(opts, celEnvOptions(environment, template, currentNativeTypes())...) +} + // The following identifiers are reserved to allow easier embedding of CEL into a host language. // // Reference: https://github.com/google/cel-spec/blob/master/doc/langdef.md @@ -125,6 +139,17 @@ func IsCelKeyword(key string) bool { return ok } +// CELKeywords returns the reserved words, sorted. Editor tooling needs the set +// itself, not just membership tests. +func CELKeywords() []string { + out := make([]string, 0, len(celKeywords)) + for k := range celKeywords { + out = append(out, k) + } + sort.Strings(out) + return out +} + func IsValidCELIdentifier(s string) bool { if len(s) == 0 { return false diff --git a/cmd/genmonarch/main.go b/cmd/genmonarch/main.go new file mode 100644 index 000000000..3943bc613 --- /dev/null +++ b/cmd/genmonarch/main.go @@ -0,0 +1,156 @@ +// Command genmonarch generates the Monaco language definitions for the +// expression languages gomplate evaluates. +// +// Everything it writes is derived: the lexical rules come from cel-go's own +// ANTLR grammar and from text/template's lexer, and the function catalogue is +// read out of a live cel.Env and the FuncMap gomplate installs. Nothing here is +// a list maintained by hand alongside the code it describes. +// +// go run ./cmd/genmonarch -out web/packages/lang/src/generated +// go run ./cmd/genmonarch -out web/packages/lang/src/generated -check +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/flanksource/gomplate/v3/genmonarch" +) + +func main() { + out := flag.String("out", "web/packages/lang/src/generated", "directory to write the generated definitions into") + check := flag.Bool("check", false, "verify the checked-in files are up to date instead of writing them; exits non-zero on drift") + flag.Parse() + + if err := run(*out, *check); err != nil { + fmt.Fprintln(os.Stderr, "genmonarch:", err) + os.Exit(1) + } +} + +// referenceDocs are the Markdown references the conformance corpus draws its +// snippets from, relative to the repository root. +var referenceDocs = []string{"CEL.md", "GO_TEMPLATE.md", "README.md"} + +func run(dir string, check bool) error { + docs, err := readDocs(referenceDocs) + if err != nil { + return err + } + + bundle, err := genmonarch.Build(docs) + if err != nil { + return err + } + files, err := genmonarch.Render(bundle) + if err != nil { + return err + } + + if check { + return verify(dir, files) + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if err := removeStale(dir, files); err != nil { + return err + } + for _, name := range sortedNames(files) { + if err := os.WriteFile(filepath.Join(dir, name), files[name], 0o644); err != nil { + return fmt.Errorf("writing %s: %w", name, err) + } + } + fmt.Printf("genmonarch: wrote %d files to %s (%d CEL functions, %d go-template functions, %d conformance cases)\n", + len(files), dir, len(bundle.Spec.CEL.Functions), len(bundle.Spec.GoTemplate.Functions), len(bundle.Conformance)) + return nil +} + +// readDocs loads the reference Markdown. A missing file is fatal: silently +// generating a thinner corpus would weaken the gate without saying so. +func readDocs(names []string) (map[string]string, error) { + docs := map[string]string{} + for _, name := range names { + content, err := os.ReadFile(name) + if err != nil { + return nil, fmt.Errorf("reading %s (run from the repository root): %w", name, err) + } + docs[name] = string(content) + } + return docs, nil +} + +// verify reports the first file that differs, so CI fails loudly on drift +// rather than shipping a highlighter that disagrees with the evaluator. +func verify(dir string, files map[string][]byte) error { + for _, name := range sortedNames(files) { + existing, err := os.ReadFile(filepath.Join(dir, name)) + if os.IsNotExist(err) { + return fmt.Errorf("%s has not been generated; run `make monarch`", name) + } + if err != nil { + return err + } + if string(existing) != string(files[name]) { + return fmt.Errorf("%s is out of date; run `make monarch`", name) + } + } + + stale, err := staleFiles(dir, files) + if err != nil { + return err + } + if len(stale) > 0 { + return fmt.Errorf("%v are no longer generated; run `make monarch`", stale) + } + return nil +} + +// removeStale deletes previously generated files that are no longer produced, +// so a renamed language does not leave a stale definition behind. +func removeStale(dir string, files map[string][]byte) error { + stale, err := staleFiles(dir, files) + if err != nil { + return err + } + for _, name := range stale { + if err := os.Remove(filepath.Join(dir, name)); err != nil { + return err + } + } + return nil +} + +func staleFiles(dir string, files map[string][]byte) ([]string, error) { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var stale []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + if _, generated := files[entry.Name()]; !generated { + stale = append(stale, entry.Name()) + } + } + sort.Strings(stale) + return stale, nil +} + +func sortedNames(files map[string][]byte) []string { + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/cmd/playground/main.go b/cmd/playground/main.go new file mode 100644 index 000000000..143c15f54 --- /dev/null +++ b/cmd/playground/main.go @@ -0,0 +1,43 @@ +// Command playground serves the evaluation API behind the language playground. +// +// The Vite dev server proxies /api to it, so the playground evaluates against +// the real gomplate engine rather than a reimplementation in the browser. +// +// go run ./cmd/playground -addr :8321 +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + "time" + + "github.com/flanksource/gomplate/v3/playground" +) + +func main() { + addr := flag.String("addr", ":8321", "address to listen on") + timeout := flag.Duration("timeout", 5*time.Second, "ceiling on one evaluation") + flag.Parse() + + // No CelEnvs or Functions: this binary serves gomplate's own language. A + // host embeds the same package and supplies its own. + handler, err := playground.NewHandler(playground.Options{Timeout: *timeout}) + if err != nil { + fmt.Fprintln(os.Stderr, "playground:", err) + os.Exit(1) + } + + server := &http.Server{ + Addr: *addr, + Handler: handler.Mux(), + ReadHeaderTimeout: 5 * time.Second, + } + + fmt.Printf("playground: listening on %s\n", *addr) + if err := server.ListenAndServe(); err != nil { + fmt.Fprintln(os.Stderr, "playground:", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index ae7651d21..40225a027 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/Masterminds/goutils v1.1.1 github.com/Masterminds/semver/v3 v3.4.0 github.com/antchfx/xmlquery v1.5.1 + github.com/antlr4-go/antlr/v4 v4.13.1 github.com/flanksource/commons v1.53.1 github.com/flanksource/is-healthy v1.0.90 github.com/flanksource/kubectl-neat v1.0.4 @@ -44,7 +45,6 @@ require github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce require ( cel.dev/expr v0.25.1 // indirect github.com/antchfx/xpath v1.3.6 // indirect - github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bsm/gomega v1.27.10 // indirect github.com/cert-manager/cert-manager v1.19.4 // indirect diff --git a/hack/hostplayground/main.go b/hack/hostplayground/main.go new file mode 100644 index 000000000..8a61d992b --- /dev/null +++ b/hack/hostplayground/main.go @@ -0,0 +1,72 @@ +// Command hostplayground stands in for a host like mission-control: it serves +// the playground API with a function of its own registered through +// playground.Options, so the whole path -- catalogue, highlighting, completion, +// hover, evaluation -- can be exercised the way a host will exercise it. +// +// go run ./hack/hostplayground -addr :8321 +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "time" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + + "github.com/flanksource/gomplate/v3/playground" +) + +// catalogQuery imitates duty's `catalog.query`: namespaced, one typed overload, +// a binding that in the real thing would reach a database. +func catalogQuery() cel.EnvOption { + return cel.Function("catalog.query", + cel.Overload("catalog.query_string", + []*cel.Type{cel.StringType}, + cel.StringType, + cel.FunctionBinding(func(args ...ref.Val) ref.Val { + return types.String("matched:" + fmt.Sprint(args[0].Value())) + }), + ), + ) +} + +func main() { + addr := flag.String("addr", ":8321", "address to listen on") + flag.Parse() + + handler, err := playground.NewHandler(playground.Options{ + Timeout: 5 * time.Second, + CelEnvs: func(context.Context) []cel.EnvOption { + return []cel.EnvOption{catalogQuery()} + }, + Functions: func(context.Context) map[string]any { + return map[string]any{"hostName": func() any { return "mission-control" }} + }, + Examples: []playground.Example{{ + Name: "Catalogue query", + Language: playground.LanguageCEL, + Source: `catalog.query("health=unhealthy")`, + Input: "pod:\n status:\n phase: Running\n", + }}, + }) + if err != nil { + fmt.Fprintln(os.Stderr, "hostplayground:", err) + os.Exit(1) + } + + server := &http.Server{ + Addr: *addr, + Handler: handler.Mux(), + ReadHeaderTimeout: 5 * time.Second, + } + fmt.Printf("hostplayground: listening on %s\n", *addr) + if err := server.ListenAndServe(); err != nil { + fmt.Fprintln(os.Stderr, "hostplayground:", err) + os.Exit(1) + } +} From 8d337f5e8e8b2578652e4c3e794a8ff05d6677ba Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 11 Aug 2026 12:13:01 +0300 Subject: [PATCH 5/7] feat(cel): preserve native values and cap regex programs Add concurrency-safe native type registration so CEL preserves registered Go values and JSON field mappings. Upgrade cel-go to v0.31, support optimized execution decorators, and enforce a 10,000-instruction regex limit. Rework benchmark CI to interleave base/head samples and report metric-level CSV regressions with tests. Document the new CEL behavior and upcoming collection helpers. Claude-Session-Id: 019fef64-038b-7471-9962-6019a7d08109 --- .github/scripts/benchstat-summary.py | 212 +++++++++++--------- .github/scripts/benchstat-summary_test.py | 64 ++++++ .github/workflows/benchmark.yml | 55 ++++-- .gitignore | 5 +- CEL.md | 23 +++ README.md | 2 +- cel.go | 16 +- cel_expression.go | 138 +++++++++++++ cel_native.go | 154 +++++++++++++++ cel_native_test.go | 177 +++++++++++++++++ cel_tracker_test.go | 15 ++ cel_v031_bench_test.go | 117 +++++++++++ go.mod | 2 +- go.sum | 4 +- nilsafe/nilsafe.go | 33 +++- nilsafe/zeroval.go | 66 +++++-- run_expression_bench_test.go | 225 ++++++++++----------- serialize.go | 36 ++++ serialize_bench_test.go | 48 ++--- specs/REQUIREMENTS-coalesce.md | 214 ++++++++++++++++++++ specs/REQUIREMENTS-first-last.md | 230 ++++++++++++++++++++++ template.go | 118 +---------- template_test.go | 6 +- 23 files changed, 1539 insertions(+), 421 deletions(-) create mode 100644 .github/scripts/benchstat-summary_test.py create mode 100644 cel_expression.go create mode 100644 cel_native.go create mode 100644 cel_native_test.go create mode 100644 cel_v031_bench_test.go create mode 100644 specs/REQUIREMENTS-coalesce.md create mode 100644 specs/REQUIREMENTS-first-last.md diff --git a/.github/scripts/benchstat-summary.py b/.github/scripts/benchstat-summary.py index c1acb882d..6eb888c2c 100644 --- a/.github/scripts/benchstat-summary.py +++ b/.github/scripts/benchstat-summary.py @@ -1,136 +1,162 @@ #!/usr/bin/env python3 -""" -benchstat-summary.py — parse benchstat output and produce a markdown summary. - -Usage: benchstat-summary.py [--threshold PCT] benchstat.txt - -Extracts significant regressions/improvements from benchstat output. -Exit code 1 if any regression exceeds the threshold (default: 5%). -""" +"""Render significant benchstat CSV changes and enforce regression thresholds.""" import argparse +import csv import re import sys from dataclasses import dataclass +from typing import Iterable + +CHANGE_RE = re.compile(r"^([+-]\d+(?:\.\d+)?)%$") -@dataclass + +@dataclass(frozen=True) class Change: name: str + metric: str base: str head: str pct: float pval: str -# Matches lines like: -# BenchmarkName-4 1.030m ± 3% 1.304m ± 5% +26.57% (p=0.000 n=10) -# BenchmarkName-4 3.997 ± 1% 3.910 ± 0% -2.16% (p=0.000 n=10) -LINE_RE = re.compile( - r"^(\S+)" # benchmark name - r"\s+" - r"(\S+)" # base value - r"\s+±\s+\d+%" # base variance - r"\s+" - r"(\S+)" # head value - r"\s+±\s+\d+%" # head variance - r"\s+" - r"([+-]\d+\.\d+)%" # percentage change - r"\s+" - r"\(p=(\d+\.\d+)" # p-value -) +def parse_benchstat_rows(rows: Iterable[list[str]]) -> list[Change]: + changes: list[Change] = [] + columns: tuple[str, int, int, int, int] | None = None + found_table = False + for row in rows: + if not row: + continue + if row[0] == "" and "vs base" in row: + metric_indexes = [ + index + for index, value in enumerate(row) + if value and value not in {"CI", "vs base", "P"} + ] + if len(metric_indexes) < 2: + raise ValueError(f"invalid benchstat metric header: {row}") + columns = ( + row[metric_indexes[0]], + metric_indexes[0], + metric_indexes[1], + row.index("vs base"), + row.index("P"), + ) + found_table = True + continue + if columns is None or row[0] == "geomean": + continue + + metric, base_index, head_index, change_index, pval_index = columns + required_length = max(base_index, head_index, change_index, pval_index) + 1 + row.extend([""] * (required_length - len(row))) + if not row[base_index] or not row[head_index]: + continue + match = CHANGE_RE.match(row[change_index]) + if match is None: + continue + changes.append( + Change( + name=row[0], + metric=metric, + base=format_metric(row[base_index], metric), + head=format_metric(row[head_index], metric), + pct=float(match.group(1)), + pval=row[pval_index].removeprefix("p=").split()[0], + ) + ) + if not found_table: + raise ValueError("input contains no benchstat CSV metric tables") + return changes def parse_benchstat(path: str) -> list[Change]: - changes = [] - with open(path) as f: - for line in f: - stripped = line.strip() - if stripped.startswith("geomean"): - continue - m = LINE_RE.match(stripped) - if not m: - continue - changes.append( - Change( - name=m.group(1), - base=m.group(2), - head=m.group(3), - pct=float(m.group(4)), - pval=m.group(5), - ) - ) - return changes + with open(path, newline="", encoding="utf-8") as source: + return parse_benchstat_rows(csv.reader(source)) + + +def format_metric(raw: str, metric: str) -> str: + value = float(raw) + if metric == "sec/op": + for scale, suffix in ((1, "s/op"), (1e3, "ms/op"), (1e6, "us/op"), (1e9, "ns/op")): + converted = value * scale + if converted >= 1: + return f"{converted:.3g} {suffix}" + if metric in {"B/op", "allocs/op"}: + return f"{value:.3g} {metric}" + return f"{value:.3g} {metric}" + + +def regressions_above_threshold(changes: list[Change], threshold: float) -> list[Change]: + return sorted( + [change for change in changes if change.pct > threshold], + key=lambda change: -change.pct, + ) def render_markdown(changes: list[Change], threshold: float) -> str: - regressions = sorted([c for c in changes if c.pct > 0], key=lambda c: -c.pct) - improvements = sorted([c for c in changes if c.pct < 0], key=lambda c: c.pct) - + regressions = sorted([change for change in changes if change.pct > 0], key=lambda change: -change.pct) + improvements = sorted([change for change in changes if change.pct < 0], key=lambda change: change.pct) if not regressions and not improvements: return "### No significant performance changes detected\n" lines: list[str] = [] - if regressions: - above = [r for r in regressions if r.pct > threshold] + above = regressions_above_threshold(changes, threshold) + heading = f"### {len(regressions)} minor regression(s) (all within {threshold:g}% threshold)\n" if above: - lines.append( - f"### {len(regressions)} regression(s) detected (threshold: >{threshold:g}%)\n" - ) - else: - lines.append( - f"### {len(regressions)} minor regression(s) (all within {threshold:g}% threshold)\n" - ) - - lines.append("| Benchmark | Base | Head | Change | p-value |") - lines.append("|-----------|------|------|--------|---------|") - for r in regressions: - change = f"+{r.pct:.2f}%" - if r.pct > threshold: - change = f"**{change}**" - lines.append(f"| `{r.name}` | {r.base} | {r.head} | {change} | {r.pval} |") - lines.append("") + heading = f"### {len(regressions)} regression(s) detected (threshold: >{threshold:g}%)\n" + lines.append(heading) + lines.extend(render_table(regressions, threshold, emphasize_regressions=True)) if improvements: - lines.append(f"
") + lines.append("
") lines.append(f"{len(improvements)} improvement(s)\n") - lines.append("| Benchmark | Base | Head | Change | p-value |") - lines.append("|-----------|------|------|--------|---------|") - for imp in improvements: - lines.append( - f"| `{imp.name}` | {imp.base} | {imp.head} | {imp.pct:.2f}% | {imp.pval} |" - ) - lines.append("") + lines.extend(render_table(improvements, threshold, emphasize_regressions=False)) lines.append("
") lines.append("") - return "\n".join(lines) -def main(): +def render_table(changes: list[Change], threshold: float, emphasize_regressions: bool) -> list[str]: + lines = [ + "| Benchmark | Metric | Base | Head | Change | p-value |", + "|-----------|--------|------|------|--------|---------|", + ] + for change in changes: + percentage = f"{change.pct:+.2f}%" + if emphasize_regressions and change.pct > threshold: + percentage = f"**{percentage}**" + lines.append( + f"| `{change.name}` | {change.metric} | {change.base} | {change.head} | {percentage} | {change.pval} |" + ) + lines.append("") + return lines + + +def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("input", help="Path to benchstat output file") - parser.add_argument( - "--threshold", - type=float, - default=5, - help="Regression percentage threshold to flag as failure (default: 5)", - ) + parser.add_argument("input", help="Path to benchstat CSV output") + parser.add_argument("--threshold", type=float, default=5, help="Regression threshold percentage") + parser.add_argument("--no-fail", action="store_true", help="Render regressions without returning a failure") args = parser.parse_args() - changes = parse_benchstat(args.input) - summary = render_markdown(changes, args.threshold) - print(summary) + try: + changes = parse_benchstat(args.input) + except (OSError, ValueError) as error: + parser.error(str(error)) + print(render_markdown(changes, args.threshold)) - # Exit 1 if any regression exceeds the threshold - regressions_above_threshold = [c for c in changes if c.pct > args.threshold] - if regressions_above_threshold: - print(f"\nFailed: {len(regressions_above_threshold)} benchmark(s) regressed by more than {args.threshold:g}%:") - for c in sorted(regressions_above_threshold, key=lambda c: -c.pct): - print(f" {c.name}: {c.base} -> {c.head} (+{c.pct:.2f}%)") - sys.exit(1) + regressions = regressions_above_threshold(changes, args.threshold) + if regressions and not args.no_fail: + print(f"\nFailed: {len(regressions)} metric(s) regressed by more than {args.threshold:g}%:") + for change in regressions: + print(f" {change.name} {change.metric}: {change.base} -> {change.head} ({change.pct:+.2f}%)") + return 1 + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/.github/scripts/benchstat-summary_test.py b/.github/scripts/benchstat-summary_test.py new file mode 100644 index 000000000..3feaed282 --- /dev/null +++ b/.github/scripts/benchstat-summary_test.py @@ -0,0 +1,64 @@ +import csv +import io +import runpy +from pathlib import Path + +import pytest + + +SCRIPT = runpy.run_path(Path(__file__).with_name("benchstat-summary.py")) +parse_benchstat_rows = SCRIPT["parse_benchstat_rows"] +regressions_above_threshold = SCRIPT["regressions_above_threshold"] +render_markdown = SCRIPT["render_markdown"] + +BENCHSTAT_CSV = """goos: linux +goarch: amd64 +,.tmp/base.txt,,.tmp/head.txt,,, +,sec/op,CI,sec/op,CI,vs base,P +Thing-8,1e-06,± 1%,1.2e-06,± 1%,+20.00%,p=0.008 n=10 +Stable-8,2e-06,± 1%,2.01e-06,± 1%,~,p=0.310 n=10 +HeadOnly-8,,,3e-06,± 1% +geomean,1e-06,,1.2e-06,,+20.00%, + +,.tmp/base.txt,,.tmp/head.txt,,, +,B/op,CI,B/op,CI,vs base,P +Thing-8,200,± 0%,250,± 0%,+25.00%,p=0.008 n=10 + +,.tmp/base.txt,,.tmp/head.txt,,, +,allocs/op,CI,allocs/op,CI,vs base,P +Thing-8,4,± 0%,3,± 0%,-25.00%,p=0.008 n=10 +""" + + +def test_parse_benchstat_rows_preserves_metric_identity_and_formats_values(): + changes = parse_benchstat_rows(csv.reader(io.StringIO(BENCHSTAT_CSV))) + + assert [(change.name, change.metric, change.base, change.head, change.pct) for change in changes] == [ + ("Thing-8", "sec/op", "1 us/op", "1.2 us/op", 20.0), + ("Thing-8", "B/op", "200 B/op", "250 B/op", 25.0), + ("Thing-8", "allocs/op", "4 allocs/op", "3 allocs/op", -25.0), + ] + + +def test_render_markdown_emphasizes_only_regressions_above_threshold(): + changes = parse_benchstat_rows(csv.reader(io.StringIO(BENCHSTAT_CSV))) + + rendered = render_markdown(changes, threshold=20) + + assert "| Benchmark | Metric | Base | Head | Change | p-value |" in rendered + assert "| `Thing-8` | sec/op | 1 us/op | 1.2 us/op | +20.00% | 0.008 |" in rendered + assert "| `Thing-8` | B/op | 200 B/op | 250 B/op | **+25.00%** | 0.008 |" in rendered + assert "1 improvement(s)" in rendered + + +def test_regression_gate_checks_each_metric(): + changes = parse_benchstat_rows(csv.reader(io.StringIO(BENCHSTAT_CSV))) + + regressions = regressions_above_threshold(changes, threshold=20) + + assert [(change.metric, change.pct) for change in regressions] == [("B/op", 25.0)] + + +def test_parse_benchstat_rows_rejects_non_csv_output(): + with pytest.raises(ValueError, match="no benchstat CSV metric tables"): + parse_benchstat_rows(csv.reader(io.StringIO("BenchmarkThing 1 ns/op\n"))) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 2de8d0a1c..596532368 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -17,7 +17,7 @@ jobs: - name: Install Go uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2 with: - go-version: 1.25.x + go-version: 1.26.x cache: false - name: Checkout code uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -66,29 +66,40 @@ jobs: run: | GOBIN="$PWD/.bin" go install golang.org/x/perf/cmd/benchstat@82a0b07e230d76fa1b3036c383d7a98172f87334 echo "$PWD/.bin" >> "$GITHUB_PATH" + - name: Test benchmark summary + run: | + python3 -m pip install pytest==9.0.3 + python3 -m pytest .github/scripts/benchstat-summary_test.py - name: Prepare base worktree run: | - mkdir -p .bench - git worktree add .bench/base "${{ github.event.pull_request.base.sha }}" + mkdir -p .tmp/bench + git worktree add .tmp/bench/base "${{ github.event.pull_request.base.sha }}" # Override the base's bench files with HEAD's so both runs execute # the same benchmark suite — only the implementation under test differs. - cp serialize_bench_test.go .bench/base/serialize_bench_test.go - cp run_expression_bench_test.go .bench/base/run_expression_bench_test.go - - name: Benchmark base + cp serialize_bench_test.go .tmp/bench/base/serialize_bench_test.go + cp run_expression_bench_test.go .tmp/bench/base/run_expression_bench_test.go + - name: Compile benchmark binaries run: | - cd .bench/base - go test -run=^$ -bench='BenchmarkSerialize|BenchmarkRunExpressionContext' -count=6 -timeout 20m \ - github.com/flanksource/gomplate/v3 | tee "$GITHUB_WORKSPACE/bench-base.txt" - - name: Benchmark head + cd .tmp/bench/base + go test -c -o "$GITHUB_WORKSPACE/.tmp/bench/base.test" github.com/flanksource/gomplate/v3 + cd "$GITHUB_WORKSPACE" + go test -c -o .tmp/bench/head.test github.com/flanksource/gomplate/v3 + - name: Benchmark interleaved base and head run: | - go test -run=^$ -bench='BenchmarkSerialize|BenchmarkRunExpressionContext' -count=6 -timeout 20m \ - github.com/flanksource/gomplate/v3 | tee bench-head.txt + : > bench-base.txt + : > bench-head.txt + for sample in {1..10} + do + echo "Running base sample ${sample}/10" + .tmp/bench/base.test -test.run=^$ -test.bench='Benchmark(Serialize|RunExpressionContext|CELEnvExtend|CELProgramEvaluation|RunExpressionNativeInput)' -test.benchmem=true -test.benchtime=1s -test.count=1 -test.timeout=20m >> bench-base.txt + echo "Running head sample ${sample}/10" + .tmp/bench/head.test -test.run=^$ -test.bench='Benchmark(Serialize|RunExpressionContext|CELEnvExtend|CELProgramEvaluation|RunExpressionNativeInput)' -test.benchmem=true -test.benchtime=1s -test.count=1 -test.timeout=20m >> bench-head.txt + done - name: Compare run: | benchstat bench-base.txt bench-head.txt > benchstat.txt - - # Generate the summary (ignore exit code here; we check it in the next step) - python3 .github/scripts/benchstat-summary.py benchstat.txt > bench-summary.md || true + benchstat -format csv bench-base.txt bench-head.txt > benchstat.csv + python3 .github/scripts/benchstat-summary.py --no-fail benchstat.csv > bench-summary.md { echo "" @@ -97,13 +108,19 @@ jobs: echo "Base: \`${{ github.event.pull_request.base.sha }}\`" echo "Head: \`${{ github.event.pull_request.head.sha }}\`" echo "" - cat bench-summary.md + while IFS= read -r line + do + echo "$line" + done < bench-summary.md echo "" echo '
' echo 'Full benchstat output' echo "" echo '```text' - cat benchstat.txt + while IFS= read -r line + do + echo "$line" + done < benchstat.txt echo '```' echo "" echo '
' @@ -116,6 +133,8 @@ jobs: bench-base.txt bench-head.txt benchstat.txt + benchstat.csv + bench-summary.md bench-report.md retention-days: 14 - name: Post report to PR @@ -151,4 +170,4 @@ jobs: }); } - name: Check for regressions - run: python3 .github/scripts/benchstat-summary.py --threshold 5 benchstat.txt > /dev/null + run: python3 .github/scripts/benchstat-summary.py --threshold 5 benchstat.csv > /dev/null diff --git a/.gitignore b/.gitignore index 975a8c365..1f81b148e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .git .bin +.tmp +__pycache__/ +.pytest_cache/ bin report.xml ./gomplate @@ -8,4 +11,4 @@ report.xml *.out *.test -.vscode \ No newline at end of file +.vscode diff --git a/CEL.md b/CEL.md index 8ebf56669..8d7e9aa77 100644 --- a/CEL.md +++ b/CEL.md @@ -19,6 +19,27 @@ CEL expressions use the [Common Expression Language (CEL)](https://cel.dev/). | `null_type` | The value `null` | | `type` | Values representing the types above | +### Native Go struct types + +Register a Go struct type to expose it directly to CEL instead of converting top-level values of that type to maps: + +```go +type Person struct { + DisplayName string `json:"display_name"` +} + +if err := gomplate.RegisterType(Person{}); err != nil { + return err +} + +result, err := gomplate.RunExpression( + map[string]any{"person": Person{DisplayName: "Ada"}}, + gomplate.Template{Expression: "person.display_name"}, +) +``` + +Registration is process-wide, concurrency-safe, and applies to subsequent CEL compilations. JSON field names are honored; a JSON tag containing only options, such as `json:",omitempty"`, retains the Go field name. When an expression returns a registered top-level value directly, the result is the original Go value. Unregistered values and Go-template evaluation retain the existing serialization behavior. + --- ## Standard Operators @@ -1300,6 +1321,8 @@ Determines if a string matches a regular expression pattern. "12345".matches("^\\d+$") // true ``` +Built-in CEL regex operations, including `.matches()`, reject regex programs larger than 10,000 instructions. This limit applies to both literal and dynamically supplied patterns. It does not apply to gomplate's separate `regexp.*` functions. + ### .quote Makes a string safe to print by escaping special characters. diff --git a/README.md b/README.md index b07fd246e..a66b1766e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Flanksource Gomplate is a fork of [hairyhenderson/gomplate](https://github.com/h - **Go Text/Template** – Full [Go `text/template`](https://pkg.go.dev/text/template) support with an extended function library from gomplate (base64, collections, crypto, data formats, filepath, math, random, regexp, strings, time, and more) - **CEL (Common Expression Language)** – [CEL](https://cel.dev/) support with: - Standard CEL operators and built-ins - - [celext](https://github.com/google/cel-go/tree/master/ext) extensions (strings, encoders, lists, math, sets) + - [cel-go extensions](https://github.com/cel-expr/cel-go/tree/v0.31.0/ext) (strings, encoders, lists, math, sets) - Kubernetes-specific helpers (`k8s.*`) - AWS helpers (`aws.*`) - Many gomplate functions remapped into CEL (`base64`, `math`, `random`, `regexp`, `filepath`, `crypto`, `sets`, etc.) diff --git a/cel.go b/cel.go index 728927d45..c85e18db8 100644 --- a/cel.go +++ b/cel.go @@ -3,7 +3,6 @@ package gomplate import ( gocontext "context" "fmt" - "reflect" "regexp" "sort" "sync" @@ -22,11 +21,7 @@ import ( "github.com/flanksource/gomplate/v3/strings" ) -var typeAdapters = []cel.EnvOption{} - -func RegisterType(i any) { - typeAdapters = append(typeAdapters, ext.NativeTypes(reflect.TypeOf(i))) -} +const celRegexProgramSizeLimit = 10_000 // staticCelEnvOptions returns the environment-independent CEL options: the // generated functions, the kubernetes library, the cel-go extensions, the @@ -49,6 +44,7 @@ func staticCelEnvOptions() []cel.EnvOption { opts = append(opts, getGoTemplateCelFunction()) opts = append(opts, getDebugCelFunction()) opts = append(opts, getFoldCelLibrary()) + opts = append(opts, cel.RegexProgramSizeLimit(celRegexProgramSizeLimit)) return opts } @@ -63,8 +59,8 @@ func staticCelEnvOptions() []cel.EnvOption { // to validate its declarations up front so Extend reuses them and only validates // the small per-call delta. // -// Env.Extend deep-copies the environment and never mutates the receiver, so the -// cached base env is safe to share across goroutines. +// Env.Extend uses copy-on-write and never mutates the receiver, so the cached +// base env is safe to share across goroutines. var baseCelEnv = sync.OnceValues(func() (*cel.Env, error) { opts := staticCelEnvOptions() opts = append(opts, cel.EagerlyValidateDeclarations(true)) @@ -80,7 +76,9 @@ var baseCelEnv = sync.OnceValues(func() (*cel.Env, error) { // option set via staticCelEnvOptions. func GetCelEnv(environment map[string]any) []cel.EnvOption { opts := staticCelEnvOptions() - opts = append(opts, typeAdapters...) + if nativeTypes := currentNativeTypes(); nativeTypes.envOption != nil { + opts = append(opts, nativeTypes.envOption) + } // Load input as variables for k := range environment { diff --git a/cel_expression.go b/cel_expression.go new file mode 100644 index 000000000..f9b60bed9 --- /dev/null +++ b/cel_expression.go @@ -0,0 +1,138 @@ +package gomplate + +import ( + "fmt" + "strconv" + "strings" + "time" + + commonsContext "github.com/flanksource/commons/context" + "github.com/flanksource/commons/properties" + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/patrickmn/go-cache" + "github.com/samber/oops" +) + +var celExpressionCache = cache.New(time.Hour, time.Hour) + +func RunExpression(environment map[string]any, template Template) (any, error) { + return RunExpressionContext(newContext(), environment, template) +} + +func RunExpressionContext(ctx commonsContext.Context, environment map[string]any, template Template) (any, error) { + tracker := celTrackerFromContext(ctx) + if tracker != nil { + if err := tracker.begin(); err != nil { + return nil, err + } + defer tracker.abort() + } + + nativeTypes := currentNativeTypes() + data, err := serializeForCEL(environment, nativeTypes) + if err != nil { + return "", err + } + cacheKey := template.celCacheKey(environment, nativeTypes.generation) + + var program cel.Program + var ast *cel.Ast + if tracker == nil && template.IsCacheable() { + cached, found := celExpressionCache.Get(cacheKey) + if found { + if cachedProgram, ok := cached.(*cel.Program); ok { + program = *cachedProgram + } + } + } + + if program == nil { + program, ast, err = compileCELProgram(data, template, nativeTypes, tracker != nil) + if err != nil { + return "", err + } + if tracker == nil && template.IsCacheable() { + celExpressionCache.Set(cacheKey, &program, template.CacheTime) + } + } + + out, details, err := program.Eval(data) + if tracker != nil { + tracker.complete(ast, details, out) + } + if err != nil { + return nil, oops.With("template", template.Expression).Wrap(err) + } + if ctx.Logger != nil && out.Value() != template.Expression && properties.On(false, "gomplate.log") { + ctx.Logger.V(4).Infof("templated %s => %v", template.ShortString(), out) + } + return out.Value(), nil +} + +func compileCELProgram(data map[string]any, template Template, nativeTypes *nativeTypeSnapshot, trackState bool) (cel.Program, *cel.Ast, error) { + base, err := baseCelEnv() + if err != nil { + return nil, nil, err + } + + envOptions := celEnvOptions(data, template, nativeTypes) + env, err := base.Extend(envOptions...) + if err != nil { + return nil, nil, err + } + expression := strings.ReplaceAll(template.Expression, "\n", " ") + if trackState { + expression = template.Expression + } + ast, issues := env.Compile(expression) + if issues != nil && issues.Err() != nil { + return nil, nil, oops.With("template", template.Expression).Errorf("issues: %s", issues.String()) + } + + evalOptions := []cel.EvalOption{cel.OptOptimize} + if trackState { + evalOptions = append(evalOptions, cel.OptTrackState) + } + program, err := env.Program(ast, cel.EvalOptions(evalOptions...)) + if err != nil { + return nil, nil, err + } + return program, ast, nil +} + +func celEnvOptions(data map[string]any, template Template, nativeTypes *nativeTypeSnapshot) []cel.EnvOption { + envOptions := make([]cel.EnvOption, 0, len(data)+len(template.Functions)+len(template.CelEnvs)+1) + if nativeTypes.envOption != nil { + envOptions = append(envOptions, nativeTypes.envOption) + } + for key := range data { + envOptions = append(envOptions, cel.Variable(key, cel.AnyType)) + } + for name, function := range template.Functions { + functionName := name + registeredFunction := function + envOptions = append(envOptions, cel.Function(functionName, cel.Overload( + functionName, + nil, + cel.AnyType, + cel.FunctionBinding(func(_ ...ref.Val) ref.Val { + function, ok := registeredFunction.(func() any) + if !ok { + return types.WrapErr(fmt.Errorf("%s is expected to be of type func() any", functionName)) + } + return types.DefaultTypeAdapter.NativeToValue(function()) + }), + ))) + } + envOptions = append(envOptions, template.CelEnvs...) + return envOptions +} + +func (t Template) celCacheKey(environment map[string]any, nativeTypeGeneration uint64) string { + if nativeTypeGeneration == 0 { + return t.cacheKey(environment) + } + return strconv.FormatUint(nativeTypeGeneration, 10) + ":" + t.cacheKey(environment) +} diff --git a/cel_native.go b/cel_native.go new file mode 100644 index 000000000..a248644b0 --- /dev/null +++ b/cel_native.go @@ -0,0 +1,154 @@ +package gomplate + +import ( + "fmt" + "maps" + "reflect" + "strings" + "sync" + "sync/atomic" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/ext" + "google.golang.org/protobuf/proto" +) + +type nativeTypeSnapshot struct { + items []any + reflectTypes map[reflect.Type]struct{} + keys map[string]struct{} + generation uint64 + envOption cel.EnvOption +} + +var emptyNativeTypeSnapshot = &nativeTypeSnapshot{ + reflectTypes: map[reflect.Type]struct{}{}, + keys: map[string]struct{}{}, +} + +var nativeTypeRegistry struct { + sync.Mutex + snapshot atomic.Pointer[nativeTypeSnapshot] +} + +func currentNativeTypes() *nativeTypeSnapshot { + if snapshot := nativeTypeRegistry.snapshot.Load(); snapshot != nil { + return snapshot + } + return emptyNativeTypeSnapshot +} + +// RegisterType makes a Go or CEL type available to subsequent CEL evaluations. +func RegisterType(value any) error { + item, reflectType, key, err := nativeTypeRegistration(value) + if err != nil { + return err + } + + nativeTypeRegistry.Lock() + defer nativeTypeRegistry.Unlock() + + current := currentNativeTypes() + if _, found := current.keys[key]; found { + return nil + } + + items := append(append([]any(nil), current.items...), item) + args := make([]any, 0, len(items)+1) + args = append(args, ext.ParseStructField(jsonCELFieldName)) + args = append(args, items...) + envOption := ext.NativeTypes(args...) + if _, err := cel.NewEnv(envOption); err != nil { + return fmt.Errorf("register CEL type %s: %w", key, err) + } + + reflectTypes := maps.Clone(current.reflectTypes) + addRegisteredReflectType(reflectTypes, reflectType) + keys := maps.Clone(current.keys) + keys[key] = struct{}{} + nativeTypeRegistry.snapshot.Store(&nativeTypeSnapshot{ + items: items, + reflectTypes: reflectTypes, + keys: keys, + generation: current.generation + 1, + envOption: envOption, + }) + return nil +} + +func nativeTypeRegistration(value any) (item any, reflectType reflect.Type, key string, err error) { + if value == nil { + return nil, nil, "", fmt.Errorf("register CEL type: value is nil") + } + + switch typed := value.(type) { + case reflect.Type: + if typed == nil { + return nil, nil, "", fmt.Errorf("register CEL type: reflect.Type is nil") + } + return typed, typed, registeredReflectTypeKey(typed), nil + case reflect.Value: + if !typed.IsValid() { + return nil, nil, "", fmt.Errorf("register CEL type: reflect.Value is invalid") + } + return typed, typed.Type(), registeredReflectTypeKey(typed.Type()), nil + case proto.Message: + reflectType = reflect.TypeOf(typed) + if reflectType.Kind() == reflect.Pointer && reflect.ValueOf(typed).IsNil() { + return nil, nil, "", fmt.Errorf("register CEL type: protobuf message is nil") + } + return typed, reflectType, "proto:" + string(typed.ProtoReflect().Descriptor().FullName()), nil + case types.StructTypeDescriptor: + refType, ok := typed.(ref.Type) + if !ok { + return nil, nil, "", fmt.Errorf("register CEL type: descriptor %T must also implement ref.Type", typed) + } + return refType, typed.ReflectType(), "cel:" + refType.TypeName(), nil + case ref.Type: + return typed, nil, "cel:" + typed.TypeName(), nil + default: + reflectType = reflect.TypeOf(value) + return reflectType, reflectType, registeredReflectTypeKey(reflectType), nil + } +} + +func registeredReflectTypeKey(reflectType reflect.Type) string { + for reflectType.Kind() == reflect.Pointer { + reflectType = reflectType.Elem() + } + return "reflect:" + reflectType.PkgPath() + ":" + reflectType.String() +} + +func addRegisteredReflectType(registered map[reflect.Type]struct{}, reflectType reflect.Type) { + if reflectType == nil { + return + } + registered[reflectType] = struct{}{} + if reflectType.Kind() == reflect.Pointer { + registered[reflectType.Elem()] = struct{}{} + } else { + registered[reflect.PointerTo(reflectType)] = struct{}{} + } +} + +func (snapshot *nativeTypeSnapshot) preserves(value any) bool { + if snapshot == nil || value == nil { + return false + } + _, found := snapshot.reflectTypes[reflect.TypeOf(value)] + return found +} + +func jsonCELFieldName(field reflect.StructField) string { + tag, found := field.Tag.Lookup("json") + if !found { + return field.Name + } + name := strings.Split(tag, ",")[0] + if name == "" { + return field.Name + } + return name +} diff --git a/cel_native_test.go b/cel_native_test.go new file mode 100644 index 000000000..ac799929b --- /dev/null +++ b/cel_native_test.go @@ -0,0 +1,177 @@ +package gomplate + +import ( + "fmt" + "reflect" + "strings" + "sync" + + "github.com/google/cel-go/common/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type registeredCELPerson struct { + DisplayName string `json:"display_name"` + Nickname string `json:",omitempty"` + Ignored string `json:"-"` +} + +type cachedCELPerson struct { + Name string `json:"name"` +} + +type describedCELPerson struct { + Name string `json:"name"` +} + +type concurrentCELPerson struct { + DisplayName string `json:"display_name"` +} + +type reflectedTypeCELPerson struct { + Name string `json:"name"` +} + +type reflectedValueCELPerson struct { + Name string `json:"name"` +} + +var _ = Describe("CEL native types", Ordered, func() { + It("passes registered top-level values to CEL without serializing them", func() { + person := registeredCELPerson{ + DisplayName: "Ada Lovelace", + Nickname: "Ada", + Ignored: "private", + } + Expect(RegisterType(person)).To(Succeed()) + + result, err := RunExpression(map[string]any{"person": person}, Template{ + Expression: "person", + CacheKey: "cel-native-person", + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(person)) + + field, err := RunExpression(map[string]any{"person": &person}, Template{ + Expression: `person.display_name + ":" + person.Nickname`, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(field).To(Equal("Ada Lovelace:Ada")) + + _, err = RunExpression(map[string]any{"person": person}, Template{Expression: "person.Ignored"}) + Expect(err).To(MatchError(ContainSubstring("no such field: Ignored"))) + }) + + It("invalidates cached programs when a native type is registered", func() { + person := cachedCELPerson{Name: "Grace Hopper"} + template := Template{Expression: "person", CacheKey: "cel-native-generation"} + + before, err := RunExpression(map[string]any{"person": person}, template) + Expect(err).NotTo(HaveOccurred()) + Expect(before).To(Equal(map[string]any{"name": "Grace Hopper"})) + + Expect(RegisterType(person)).To(Succeed()) + + after, err := RunExpression(map[string]any{"person": person}, template) + Expect(err).NotTo(HaveOccurred()) + Expect(after).To(Equal(person)) + }) + + It("accepts self-describing native CEL types", func() { + descriptor, err := types.NewNativeType( + reflect.TypeOf(describedCELPerson{}), + types.ParseStructField(jsonCELFieldName), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(RegisterType(descriptor)).To(Succeed()) + + person := describedCELPerson{Name: "Katherine Johnson"} + result, err := RunExpression(map[string]any{"person": person}, Template{Expression: "person"}) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(person)) + }) + + It("accepts reflect.Type and reflect.Value registrations", func() { + Expect(RegisterType(reflect.TypeOf(reflectedTypeCELPerson{}))).To(Succeed()) + Expect(RegisterType(reflect.ValueOf(reflectedValueCELPerson{}))).To(Succeed()) + + result, err := RunExpression(map[string]any{ + "typed": reflectedTypeCELPerson{Name: "Mary Jackson"}, + "valued": reflectedValueCELPerson{Name: "Christine Darden"}, + }, Template{Expression: `typed.name + ":" + valued.name`}) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal("Mary Jackson:Christine Darden")) + }) + + It("rejects invalid types without publishing a new generation", func() { + generation := currentNativeTypes().generation + + err := RegisterType(42) + + Expect(err).To(MatchError(ContainSubstring("unsupported reflect.Type"))) + Expect(currentNativeTypes().generation).To(Equal(generation)) + }) + + It("treats repeated registrations as idempotent", func() { + person := registeredCELPerson{} + Expect(RegisterType(person)).To(Succeed()) + generation := currentNativeTypes().generation + + Expect(RegisterType(&person)).To(Succeed()) + + Expect(currentNativeTypes().generation).To(Equal(generation)) + }) + + It("supports concurrent registration snapshots and evaluation", func() { + person := concurrentCELPerson{DisplayName: "Dorothy Vaughan"} + errors := make(chan error, 16) + var waitGroup sync.WaitGroup + for range 8 { + waitGroup.Add(2) + go func() { + defer waitGroup.Done() + errors <- RegisterType(person) + }() + go func() { + defer waitGroup.Done() + _, err := RunExpression(map[string]any{"person": person}, Template{Expression: "person.display_name"}) + errors <- err + }() + } + waitGroup.Wait() + close(errors) + + for err := range errors { + Expect(err).NotTo(HaveOccurred()) + } + }) +}) + +var _ = Describe("CEL regex program limits", func() { + const limit = 10_000 + + It("rejects oversized literal regex programs during compilation", func() { + pattern := strings.Repeat("a?", limit+1) + expression := fmt.Sprintf(`"a".matches(%q)`, pattern) + + _, err := RunExpression(nil, Template{Expression: expression}) + + Expect(err).To(MatchError(ContainSubstring("regex program size"))) + Expect(err).To(MatchError(ContainSubstring("exceeds limit of 10000"))) + }) + + It("rejects oversized dynamic regex programs during evaluation", func() { + pattern := strings.Repeat("a?", limit+1) + + _, err := RunExpression(map[string]any{"pattern": pattern}, Template{ + Expression: `"a".matches(pattern)`, + }) + + Expect(err).To(MatchError(ContainSubstring("regex program size"))) + Expect(err).To(MatchError(ContainSubstring("exceeds limit of 10000"))) + }) +}) diff --git a/cel_tracker_test.go b/cel_tracker_test.go index 156bb42d0..32996d2e4 100644 --- a/cel_tracker_test.go +++ b/cel_tracker_test.go @@ -78,6 +78,21 @@ var _ = Describe("CELTracker", func() { Expect(valueLines).To(ContainElement(2)) }) + It("tracks optimized list, optional, and regex evaluation", func() { + tracker := NewCELTracker() + expression := `([1, 2] + [3, 4]).size() == 4 && optional.of(name).orValue("") == "Ada" && name.matches("^A.*")` + + result, err := RunExpressionContext(newTrackedContext(tracker), map[string]any{"name": "Ada"}, Template{Expression: expression}) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(true)) + Expect(tracker.Snapshot()).To(SatisfyAll( + WithTransform(func(snapshot CELTraceSnapshot) *cel.Ast { return snapshot.AST }, Not(BeNil())), + WithTransform(func(snapshot CELTraceSnapshot) *cel.EvalDetails { return snapshot.Details }, Not(BeNil())), + WithTransform(func(snapshot CELTraceSnapshot) any { return snapshot.Output.Value() }, Equal(true)), + )) + }) + It("rejects concurrent reuse and can be reused after evaluation", func() { tracker := NewCELTracker() started := make(chan struct{}) diff --git a/cel_v031_bench_test.go b/cel_v031_bench_test.go new file mode 100644 index 000000000..43d3981f7 --- /dev/null +++ b/cel_v031_bench_test.go @@ -0,0 +1,117 @@ +package gomplate + +import ( + "fmt" + "testing" + + "github.com/google/cel-go/cel" +) + +type benchmarkNativeInput struct { + DisplayName string `json:"display_name"` + Scores []int `json:"scores"` +} + +func BenchmarkCELEnvExtendCustomFunction(b *testing.B) { + base, err := baseCelEnv() + if err != nil { + b.Fatal(err) + } + options := benchmarkEnvOptions(10, 1) + b.ReportAllocs() + for b.Loop() { + if _, err := base.Extend(options...); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkCELProgramEvaluation(b *testing.B) { + cases := []struct { + name string + expression string + data map[string]any + }{ + {"scalar", `config_type == "Kubernetes::Pod"`, map[string]any{"config_type": "Kubernetes::Pod"}}, + {"list_optional_regex", `([1, 2] + [3, 4]).size() == 4 && optional.of(name).orValue("") == "Ada" && name.matches("^A.*")`, map[string]any{"name": "Ada"}}, + {"comprehension", `[1, 2, 3, 4, 5].filter(n, n % 2 == 0).map(n, n * n).exists(n, n == 16)`, nil}, + } + for _, benchmark := range cases { + for _, optimize := range []bool{false, true} { + name := fmt.Sprintf("expression=%s/optimized=%t", benchmark.name, optimize) + b.Run(name, func(b *testing.B) { + data, err := serializeForCEL(benchmark.data, currentNativeTypes()) + if err != nil { + b.Fatal(err) + } + program, err := compileBenchmarkCELProgram(data, benchmark.expression, optimize) + if err != nil { + b.Fatal(err) + } + if output, _, err := program.Eval(data); err != nil || output.Value() != true { + b.Fatalf("unexpected warm-up result %v: %v", output, err) + } + b.ReportAllocs() + for b.Loop() { + if _, _, err := program.Eval(data); err != nil { + b.Fatal(err) + } + } + }) + } + } +} + +func BenchmarkRunExpressionNativeInput(b *testing.B) { + previousTypes := currentNativeTypes() + b.Cleanup(func() { + nativeTypeRegistry.Lock() + defer nativeTypeRegistry.Unlock() + nativeTypeRegistry.snapshot.Store(previousTypes) + }) + if err := RegisterType(benchmarkNativeInput{}); err != nil { + b.Fatal(err) + } + cases := []struct { + name string + value any + }{ + {"map", map[string]any{"display_name": "Ada", "scores": []int{1, 2, 3}}}, + {"native_struct", benchmarkNativeInput{DisplayName: "Ada", Scores: []int{1, 2, 3}}}, + } + for _, benchmark := range cases { + b.Run("input="+benchmark.name, func(b *testing.B) { + env := map[string]any{"person": benchmark.value} + template := Template{ + Expression: `person.display_name == "Ada" && person.scores.size() == 3`, + CacheKey: "benchmark-native-input-" + benchmark.name, + } + assertBenchmarkExpression(b, env, template) + b.ReportAllocs() + for b.Loop() { + if _, err := RunExpression(env, template); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func compileBenchmarkCELProgram(data map[string]any, expression string, optimize bool) (cel.Program, error) { + base, err := baseCelEnv() + if err != nil { + return nil, err + } + env, err := base.Extend(celEnvOptions(data, Template{}, currentNativeTypes())...) + if err != nil { + return nil, err + } + ast, issues := env.Compile(expression) + if issues != nil && issues.Err() != nil { + return nil, issues.Err() + } + if optimize { + return env.Program(ast, cel.EvalOptions(cel.OptOptimize)) + } + return env.Program(ast) +} diff --git a/go.mod b/go.mod index 40225a027..8cb12cfd5 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/flanksource/commons v1.53.1 github.com/flanksource/is-healthy v1.0.90 github.com/flanksource/kubectl-neat v1.0.4 - github.com/google/cel-go v0.27.0 + github.com/google/cel-go v0.31.0 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/gosimple/slug v1.15.0 diff --git a/go.sum b/go.sum index 98d0f51bd..f0c21eecc 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= -github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/cel-go v0.31.0 h1:H0bhpFTqOvmHrBGrWKp7ZlhBm5Hh8PYUEXnwxT1LL7A= +github.com/google/cel-go v0.31.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/nilsafe/nilsafe.go b/nilsafe/nilsafe.go index 51b55006c..e5fc59c84 100644 --- a/nilsafe/nilsafe.go +++ b/nilsafe/nilsafe.go @@ -38,11 +38,11 @@ func (*library) LibraryName() string { return "cel.lib.ext.nilsafe" func (*library) CompileOptions() []cel.EnvOption { return nil } func (l *library) ProgramOptions() []cel.ProgramOption { - return []cel.ProgramOption{cel.CustomDecorator(l.makeDecorator())} + return []cel.ProgramOption{cel.CustomDecoratorV2(l.makeDecorator())} } -func (l *library) makeDecorator() interpreter.InterpretableDecorator { - return func(i interpreter.Interpretable) (interpreter.Interpretable, error) { +func (l *library) makeDecorator() interpreter.InterpretableDecoratorV2 { + return func(i interpreter.InterpretableV2) (interpreter.InterpretableV2, error) { if attr, ok := i.(interpreter.InterpretableAttribute); ok { if attr.ID() != attr.Attr().ID() { return i, nil @@ -77,7 +77,14 @@ type nilSafeAttr struct { } func (a *nilSafeAttr) Eval(ctx interpreter.Activation) ref.Val { - val := a.InterpretableAttribute.Eval(ctx) + return nilSafeResolution(a.InterpretableAttribute.Eval(ctx)) +} + +func (a *nilSafeAttr) Exec(frame *interpreter.ExecutionFrame) ref.Val { + return nilSafeResolution(a.InterpretableAttribute.Exec(frame)) +} + +func nilSafeResolution(val ref.Val) ref.Val { if types.IsError(val) && isResolutionError(val) { return types.NullValue } @@ -100,10 +107,24 @@ type nilSafeCall struct { } func (c *nilSafeCall) Eval(ctx interpreter.Activation) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Eval(ctx) }, + func() ref.Val { return c.InterpretableCall.Eval(ctx) }, + ) +} + +func (c *nilSafeCall) Exec(frame *interpreter.ExecutionFrame) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Exec(frame) }, + func() ref.Val { return c.InterpretableCall.Exec(frame) }, + ) +} + +func (c *nilSafeCall) eval(evalArg func(interpreter.InterpretableV2) ref.Val, evalCall func() ref.Val) ref.Val { for _, arg := range c.Args() { - if arg.Eval(ctx) == types.NullValue { + if evalArg(arg) == types.NullValue { return types.NullValue } } - return c.InterpretableCall.Eval(ctx) + return evalCall() } diff --git a/nilsafe/zeroval.go b/nilsafe/zeroval.go index 0b9ced621..5954e5f2d 100644 --- a/nilsafe/zeroval.go +++ b/nilsafe/zeroval.go @@ -47,17 +47,31 @@ type zeroValueCall struct { } func (c *zeroValueCall) Eval(ctx interpreter.Activation) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Eval(ctx) }, + func() ref.Val { return c.InterpretableCall.Eval(ctx) }, + ) +} + +func (c *zeroValueCall) Exec(frame *interpreter.ExecutionFrame) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Exec(frame) }, + func() ref.Val { return c.InterpretableCall.Exec(frame) }, + ) +} + +func (c *zeroValueCall) eval(evalArg func(interpreter.InterpretableV2) ref.Val, evalCall func() ref.Val) ref.Val { args := c.Args() vals := make([]ref.Val, len(args)) hasNull := false for i, arg := range args { - vals[i] = arg.Eval(ctx) + vals[i] = evalArg(arg) if vals[i] == types.NullValue { hasNull = true } } if !hasNull { - return c.InterpretableCall.Eval(ctx) + return evalCall() } fn := c.Function() @@ -78,13 +92,27 @@ type zeroValueEq struct { } func (c *zeroValueEq) Eval(ctx interpreter.Activation) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Eval(ctx) }, + func() ref.Val { return c.InterpretableCall.Eval(ctx) }, + ) +} + +func (c *zeroValueEq) Exec(frame *interpreter.ExecutionFrame) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Exec(frame) }, + func() ref.Val { return c.InterpretableCall.Exec(frame) }, + ) +} + +func (c *zeroValueEq) eval(evalArg func(interpreter.InterpretableV2) ref.Val, evalCall func() ref.Val) ref.Val { args := c.Args() - lhs, rhs := args[0].Eval(ctx), args[1].Eval(ctx) + lhs, rhs := evalArg(args[0]), evalArg(args[1]) lNull := lhs == types.NullValue rNull := rhs == types.NullValue if !lNull && !rNull { - return c.InterpretableCall.Eval(ctx) + return evalCall() } if lNull && rNull { return types.True @@ -98,24 +126,32 @@ func (c *zeroValueEq) Eval(ctx interpreter.Activation) ref.Val { return lhs.Equal(rhs) } -func (c *zeroValueEq) Function() string { return operators.Equals } -func (c *zeroValueEq) OverloadID() string { return "" } -func (c *zeroValueEq) Args() []interpreter.Interpretable { - return c.InterpretableCall.Args() -} - type zeroValueNe struct { interpreter.InterpretableCall } func (c *zeroValueNe) Eval(ctx interpreter.Activation) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Eval(ctx) }, + func() ref.Val { return c.InterpretableCall.Eval(ctx) }, + ) +} + +func (c *zeroValueNe) Exec(frame *interpreter.ExecutionFrame) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Exec(frame) }, + func() ref.Val { return c.InterpretableCall.Exec(frame) }, + ) +} + +func (c *zeroValueNe) eval(evalArg func(interpreter.InterpretableV2) ref.Val, evalCall func() ref.Val) ref.Val { args := c.Args() - lhs, rhs := args[0].Eval(ctx), args[1].Eval(ctx) + lhs, rhs := evalArg(args[0]), evalArg(args[1]) lNull := lhs == types.NullValue rNull := rhs == types.NullValue if !lNull && !rNull { - return c.InterpretableCall.Eval(ctx) + return evalCall() } if lNull && rNull { return types.False @@ -134,12 +170,6 @@ func (c *zeroValueNe) Eval(ctx interpreter.Activation) ref.Val { return types.True } -func (c *zeroValueNe) Function() string { return operators.NotEquals } -func (c *zeroValueNe) OverloadID() string { return "" } -func (c *zeroValueNe) Args() []interpreter.Interpretable { - return c.InterpretableCall.Args() -} - func isOperator(fn string) bool { switch fn { case operators.Add, operators.Subtract, operators.Multiply, diff --git a/run_expression_bench_test.go b/run_expression_bench_test.go index 8f3c1e00a..2c4fd85bb 100644 --- a/run_expression_bench_test.go +++ b/run_expression_bench_test.go @@ -1,27 +1,12 @@ package gomplate -// This benchmark exercises the CEL expression evaluation path (RunExpressionContext) -// the way callers use it at runtime: with a CacheKey so the compiled cel.Program is -// served from celExpressionCache on every iteration after the first. -// -// Motivation: a production heap profile showed RunExpressionContext -> GetCelEnv -// (notably kubernetes.Library()) + Serialize accounting for the single largest slice -// of lifetime allocation, because GetCelEnv was rebuilt on EVERY evaluation even -// though the compiled program is cached. After iteration 1 (the cache miss), all -// remaining iterations are cache hits; any allocation that remains is the per-call -// overhead that runs regardless of the program cache. -// -// Only the cache-hit steady state is benchmarked: that is the prod hot path and the -// regression guard for the "GetCelEnv must not run on cache hits" invariant. -// // Run: -// go test -run=^$ -bench=BenchmarkRunExpressionContext -benchmem +// go test -run=^$ -bench='BenchmarkRunExpressionContext|BenchmarkCELEnvExtend|BenchmarkCELProgramEvaluation' -benchmem // -// Capture a heap profile and inspect it the same way we inspect prod ones: -// go test -run=^$ -bench=BenchmarkRunExpressionContext/cacheHit -benchmem \ -// -memprofile /tmp/cel.mem.pprof -memprofilerate=1 -// go tool pprof -alloc_space -top -nodecount=25 /tmp/cel.mem.pprof -// go tool pprof -alloc_space -peek 'GetCelEnv$' /tmp/cel.mem.pprof +// Capture a heap profile in the project scratch directory: +// go test -run=^$ -bench=BenchmarkRunExpressionContext/cache=hit -benchmem \ +// -memprofile .tmp/cel.mem.pprof -memprofilerate=1 +// go tool pprof -alloc_space -top -nodecount=25 .tmp/cel.mem.pprof import ( "fmt" @@ -31,10 +16,6 @@ import ( "github.com/google/cel-go/common/types/ref" ) -// benchExprEnv returns an env map shaped like a Kubernetes Pod config item as the -// scraper passes it to template evaluation. GetCelEnv registers one cel.Variable per -// top-level key and Serialize walks the entire structure, so env size directly drives -// the per-call allocation under test. func benchExprEnv(withNestedConfig bool) map[string]any { env := map[string]any{ "id": "0192f0a4-1234-7000-8000-aaaaaaaaaaaa", @@ -47,130 +28,140 @@ func benchExprEnv(withNestedConfig bool) map[string]any { "namespace": "default", }, } + if !withNestedConfig { + return env + } - if withNestedConfig { - containers := make([]any, 0, 3) - for i := 0; i < 3; i++ { - containers = append(containers, map[string]any{ - "name": fmt.Sprintf("container-%d", i), - "image": fmt.Sprintf("registry.example.com/app:%d.2.3", i), - "ports": []any{map[string]any{"containerPort": 8080 + i, "protocol": "TCP"}}, - "env": []any{ - map[string]any{"name": "LOG_LEVEL", "value": "info"}, - map[string]any{"name": "REGION", "value": "us-east-1"}, - }, - "resources": map[string]any{ - "limits": map[string]any{"cpu": "500m", "memory": "512Mi"}, - "requests": map[string]any{"cpu": "100m", "memory": "128Mi"}, - }, - }) - } - - env["config"] = map[string]any{ - "apiVersion": "v1", - "kind": "Pod", - "metadata": map[string]any{ - "name": "nginx-7c5ddbdf54-abcde", - "namespace": "default", - "labels": map[string]any{ - "app": "nginx", "team": "platform", "env": "production", "version": "v1.2.3", - }, - "annotations": map[string]any{ - "prometheus.io/scrape": "true", - "prometheus.io/port": "8080", - }, - "ownerReferences": []any{ - map[string]any{"apiVersion": "apps/v1", "kind": "ReplicaSet", "name": "nginx-7c5ddbdf54"}, - }, + containers := make([]any, 0, 3) + for i := range 3 { + containers = append(containers, map[string]any{ + "name": fmt.Sprintf("container-%d", i), + "image": fmt.Sprintf("registry.example.com/app:%d.2.3", i), + "ports": []any{map[string]any{"containerPort": 8080 + i, "protocol": "TCP"}}, + "env": []any{ + map[string]any{"name": "LOG_LEVEL", "value": "info"}, + map[string]any{"name": "REGION", "value": "us-east-1"}, }, - "spec": map[string]any{"containers": containers, "nodeName": "ip-10-0-1-23"}, - "status": map[string]any{"phase": "Running", "podIP": "10.0.5.12", "hostIP": "10.0.1.23"}, - } + "resources": map[string]any{ + "limits": map[string]any{"cpu": "500m", "memory": "512Mi"}, + "requests": map[string]any{"cpu": "100m", "memory": "128Mi"}, + }, + }) + } + env["config"] = map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "nginx-7c5ddbdf54-abcde", + "namespace": "default", + "labels": map[string]any{ + "app": "nginx", "team": "platform", "env": "production", "version": "v1.2.3", + }, + "annotations": map[string]any{ + "prometheus.io/scrape": "true", + "prometheus.io/port": "8080", + }, + "ownerReferences": []any{ + map[string]any{"apiVersion": "apps/v1", "kind": "ReplicaSet", "name": "nginx-7c5ddbdf54"}, + }, + }, + "spec": map[string]any{"containers": containers, "nodeName": "ip-10-0-1-23"}, + "status": map[string]any{"phase": "Running", "podIP": "10.0.5.12", "hostIP": "10.0.1.23"}, } - return env } -// exprBenchSink prevents the compiler from optimizing away results. -var exprBenchSink any - -// BenchmarkRunExpressionContext measures the CEL evaluation path on the cache-hit -// steady state: a CacheKey is set so the compiled cel.Program is reused from -// celExpressionCache. After the warm-up run, every iteration is a cache hit, and the -// reported B/op / allocs/op is the per-call overhead that runs regardless of the program -// cache (Serialize + Eval, plus GetCelEnv if a regression reintroduces it before the -// cache lookup). func BenchmarkRunExpressionContext(b *testing.B) { const expression = `config_type == "Kubernetes::Pod"` - for _, withConfig := range []bool{false, true} { - name := "cacheHit/smallEnv" + name := "small" if withConfig { - name = "cacheHit/largeEnv" + name = "large" } - b.Run(name, func(b *testing.B) { + b.Run("cache=hit/environment="+name, func(b *testing.B) { + celExpressionCache.Flush() env := benchExprEnv(withConfig) - tmpl := Template{ - Expression: expression, - CacheKey: "bench.RunExpressionContext:config_type==Kubernetes::Pod", - } - // Warm the cache once so we measure steady state, not the one-time compile. - if _, err := RunExpression(env, tmpl); err != nil { - b.Fatal(err) - } - + template := Template{Expression: expression, CacheKey: "benchmark-cache-hit-" + name} + assertBenchmarkExpression(b, env, template) b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - out, err := RunExpression(env, tmpl) - if err != nil { + for b.Loop() { + if _, err := RunExpression(env, template); err != nil { b.Fatal(err) } - exprBenchSink = out } }) } } -// BenchmarkRunExpressionContextCompile measures the CEL compile path -// (RunExpressionContext cache MISS). Production expressions that reference a -// context-capturing function such as catalog.query attach a CelEnv, which makes -// the compiled program non-cacheable (IsCacheable() is false when len(CelEnvs) -// != 0), so they pay the full env-build + compile cost on EVERY call. This is the -// path that previously rebuilt kubernetes.Library() and revalidated all of its -// declarations every time; it is now served by extending the cached base env. func BenchmarkRunExpressionContextCompile(b *testing.B) { const expression = `config_type == "Kubernetes::Pod"` - - // A trivial CelEnv: its only purpose is to make the template non-cacheable so - // every iteration goes through the compile path (mirrors catalog.query & co.). - noopFn := cel.Function("bench_noop", - cel.Overload("bench_noop_string", - []*cel.Type{cel.StringType}, cel.StringType, - cel.UnaryBinding(func(v ref.Val) ref.Val { return v }), - ), - ) - for _, withConfig := range []bool{false, true} { - name := "compile/smallEnv" + name := "small" if withConfig { - name = "compile/largeEnv" + name = "large" } - b.Run(name, func(b *testing.B) { + b.Run("cache=miss/environment="+name, func(b *testing.B) { env := benchExprEnv(withConfig) + template := Template{Expression: expression, CelEnvs: []cel.EnvOption{benchmarkNoopFunction()}} + assertBenchmarkExpression(b, env, template) + b.ReportAllocs() + for b.Loop() { + if _, err := RunExpression(env, template); err != nil { + b.Fatal(err) + } + } + }) + } +} +func BenchmarkCELEnvExtend(b *testing.B) { + base, err := baseCelEnv() + if err != nil { + b.Fatal(err) + } + cases := []struct { + variables int + functions int + }{{1, 0}, {10, 0}, {100, 0}} + for _, benchmark := range cases { + name := fmt.Sprintf("variables=%d/functions=%d", benchmark.variables, benchmark.functions) + b.Run(name, func(b *testing.B) { + options := benchmarkEnvOptions(benchmark.variables, benchmark.functions) b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - out, err := RunExpression(env, Template{ - Expression: expression, - CelEnvs: []cel.EnvOption{noopFn}, - }) - if err != nil { + for b.Loop() { + if _, err := base.Extend(options...); err != nil { b.Fatal(err) } - exprBenchSink = out } }) } } + +func benchmarkNoopFunction() cel.EnvOption { + return cel.Function("bench_noop", cel.Overload( + "bench_noop_string", []*cel.Type{cel.StringType}, cel.StringType, + cel.UnaryBinding(func(value ref.Val) ref.Val { return value }), + )) +} + +func benchmarkEnvOptions(variables, functions int) []cel.EnvOption { + options := make([]cel.EnvOption, 0, variables+functions) + for i := range variables { + options = append(options, cel.Variable(fmt.Sprintf("value_%d", i), cel.AnyType)) + } + if functions == 1 { + options = append(options, benchmarkNoopFunction()) + } + return options +} + +func assertBenchmarkExpression(b *testing.B, environment map[string]any, template Template) { + b.Helper() + output, err := RunExpression(environment, template) + if err != nil { + b.Fatal(err) + } + if output != true { + b.Fatalf("unexpected warm-up result: %v", output) + } +} diff --git a/serialize.go b/serialize.go index ab15438ec..3541a0a6e 100644 --- a/serialize.go +++ b/serialize.go @@ -81,3 +81,39 @@ func Serialize(in map[string]any) (out map[string]any, err error) { } return out, nil } + +func serializeForCEL(in map[string]any, nativeTypes *nativeTypeSnapshot) (map[string]any, error) { + if in == nil || nativeTypes == nil || len(nativeTypes.reflectTypes) == 0 { + return Serialize(in) + } + + preserved := 0 + for _, value := range in { + if nativeTypes.preserves(value) { + preserved++ + } + } + if preserved == 0 { + return Serialize(in) + } + if preserved == len(in) { + return in, nil + } + + serializedInput := make(map[string]any, len(in)-preserved) + for key, value := range in { + if !nativeTypes.preserves(value) { + serializedInput[key] = value + } + } + out, err := Serialize(serializedInput) + if err != nil { + return nil, err + } + for key, value := range in { + if nativeTypes.preserves(value) { + out[key] = value + } + } + return out, nil +} diff --git a/serialize_bench_test.go b/serialize_bench_test.go index 9a907f051..1c3ef1561 100644 --- a/serialize_bench_test.go +++ b/serialize_bench_test.go @@ -25,16 +25,11 @@ type benchPerson struct { } func BenchmarkSerialize(b *testing.B) { - sizes := []int{10, 100, 1000, 10000} - - for _, size := range sizes { - b.Run(fmt.Sprintf("Size-%d", size), func(b *testing.B) { + for _, size := range []int{10, 100, 1000, 10000} { + b.Run(fmt.Sprintf("items=%d/native_values=true", size), func(b *testing.B) { input := newSerializeBenchmarkInput(size) - b.ReportAllocs() - b.ResetTimer() - - for i := 0; i < b.N; i++ { + for b.Loop() { if _, err := Serialize(input); err != nil { b.Fatal(err) } @@ -43,20 +38,12 @@ func BenchmarkSerialize(b *testing.B) { } } -// BenchmarkSerialize_NoNativeTypes measures the path where Walk finds no -// uuid/duration/AsMapper values — isolates the Alter cost from the SetOne -// fixup loop optimized in the last commit. func BenchmarkSerialize_NoNativeTypes(b *testing.B) { - sizes := []int{100, 1000, 10000} - - for _, size := range sizes { - b.Run(fmt.Sprintf("Size-%d", size), func(b *testing.B) { + for _, size := range []int{100, 1000, 10000} { + b.Run(fmt.Sprintf("items=%d/native_values=false", size), func(b *testing.B) { input := newPlainBenchmarkInput(size) - b.ReportAllocs() - b.ResetTimer() - - for i := 0; i < b.N; i++ { + for b.Loop() { if _, err := Serialize(input); err != nil { b.Fatal(err) } @@ -67,20 +54,16 @@ func BenchmarkSerialize_NoNativeTypes(b *testing.B) { func newSerializeBenchmarkInput(size int) map[string]any { items := make([]any, size) + identifier := uuid.MustParse("0192f0a4-1234-7000-8000-aaaaaaaaaaaa") for i := range items { items[i] = benchPerson{ Name: fmt.Sprintf("person-%d", i), Age: i % 100, - ID: uuid.New(), + ID: identifier, Duration: time.Duration(i) * time.Millisecond, - Address: &benchAddress{ - City: "Kathmandu", - Country: "Nepal", - }, + Address: &benchAddress{City: "Kathmandu", Country: "Nepal"}, MetaData: map[string]any{ - "index": i, - "enabled": i%2 == 0, - "uuid": uuid.New(), + "index": i, "enabled": i%2 == 0, "uuid": identifier, "duration": time.Duration(i) * time.Second, }, Codes: []string{"GO", "JS", "CEL"}, @@ -91,10 +74,9 @@ func newSerializeBenchmarkInput(size int) map[string]any { }, } } - return map[string]any{ - "id": uuid.New(), - "started": time.Now(), + "id": identifier, + "started": time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), "duration": 5 * time.Minute, "items": items, "nested": map[string]any{ @@ -113,13 +95,9 @@ func newPlainBenchmarkInput(size int) map[string]any { "age": i % 100, "enabled": i%2 == 0, "codes": []string{"GO", "JS", "CEL"}, - "address": map[string]any{ - "city": "Kathmandu", - "country": "Nepal", - }, + "address": map[string]any{"city": "Kathmandu", "country": "Nepal"}, } } - return map[string]any{ "started": "2026-01-01T00:00:00Z", "items": items, diff --git a/specs/REQUIREMENTS-coalesce.md b/specs/REQUIREMENTS-coalesce.md new file mode 100644 index 000000000..cba65ed57 --- /dev/null +++ b/specs/REQUIREMENTS-coalesce.md @@ -0,0 +1,214 @@ +# Feature: coalesce — CEL and gomplate function with Optional support + +## Overview + +Add a `coalesce` function available in both CEL expressions and gomplate templates. +It returns the first argument that is neither null/nil nor empty (empty string `""`, +empty list `[]`, empty map `{}`). When every argument is null/empty it returns `null`. + +**Problem**: Users frequently need a fallback chain such as +`coalesce(config.override, config.default, "fallback")` where any value in the +chain may be a CEL `optional`, a plain value, or outright null. No such +primitive exists in the codebase today. + +**Target users**: Engineers writing CEL expressions and Go-template strings in +Mission Control config/playbooks. + +--- + +## Functional Requirements + +### FR-1: Variadic coalesce returning first non-null, non-empty value + +**Description**: `coalesce` accepts any number of arguments (≥ 1) and returns +the first one that is neither null nor empty. Empty is defined as: +- `null` / `nil` +- empty string `""` +- empty list `[]` +- empty map `{}` + +Numeric zero (`0`, `0.0`) and boolean `false` are **not** considered empty. + +**User Story**: As a CEL/template user, I want `coalesce(a, b, c)` so that I +get the first meaningful value without writing nested `?? ` / `if` chains. + +**Acceptance Criteria**: +- [ ] Returns the first non-null, non-empty argument +- [ ] Numeric `0` and `false` are treated as valid (non-empty) values +- [ ] Returns `null` when all arguments are null/empty +- [ ] Accepts one or more arguments (variadic) +- [ ] Works with mixed types in a single call + +--- + +### FR-2: CEL `optional` unwrapping + +**Description**: When an argument is a CEL `optional` value, `coalesce` +unwraps it: +- `optional.of(v)` → use `v` (subject to the emptiness check above) +- `optional.none()` → skip (treated like null) + +Plain (non-optional) values are accepted alongside optionals in the same call. + +**User Story**: As a CEL user, I want `coalesce(optional.none(), optional.of("x"), "y")` +to return `"x"` so that I can write safe fallback chains over fields that may be +absent. + +**Acceptance Criteria**: +- [ ] `optional.none()` is skipped +- [ ] `optional.of(v)` is unwrapped and the inner `v` is evaluated for emptiness +- [ ] A mix of `optional` and plain values is accepted in the same call +- [ ] Result is always an unwrapped plain value (never wrapped in optional) + +--- + +### FR-3: gomplate template function + +**Description**: `coalesce` is registered as a top-level gomplate template +function (not namespaced) so it can be called as `{{ coalesce .a .b "default" }}`. + +**User Story**: As a template author, I want `{{ coalesce .a .b "default" }}` so +that I can express fallback logic inline without extra conditionals. + +**Acceptance Criteria**: +- [ ] Function is registered in `CreateCollFuncs` (or equivalent top-level map) +- [ ] Identical emptiness semantics to the CEL version +- [ ] `nil` interface values, empty strings, and empty slices are skipped +- [ ] Returns `nil` when all arguments are empty + +--- + +## Technical Considerations + +### Implementation location + +| Artifact | Location | +|----------|----------| +| Pure Go logic | `coll/coalesce.go` (new file, mirrors existing `coll/*.go` pattern) | +| gomplate wrapper | `funcs/coll.go` — add `Coalesce` method to `CollFuncs` and register it | +| CEL binding | `funcs/coll.go` — hand-written `cel.Function` (like `celLabelsMatch`), **not** generated by gencel because it needs variadic + optional handling | +| CEL registration | `funcs/cel_exports.go` — add the new `cel.EnvOption` var | +| Tests | `coll/coalesce_test.go` and `tests/cel_test.go` | + +### CEL variadic constraint + +CEL-go does not support variadic functions natively +([upstream issue](https://github.com/google/cel-go/issues/476)). +The standard pattern in this codebase is to declare overloads for arities 1–N. +Define overloads for 1, 2, 3, 4, 5 arguments with `cel.DynType` params, plus a +catch-all receiver-style overload if needed. + +### Optional unwrapping + +CEL `optional` values arrive as `types.Optional` from `cel.OptionalTypes()`. +Use a helper: + +```go +func unwrapOptional(v ref.Val) (ref.Val, bool) { + if opt, ok := v.(*types.Optional); ok { + if opt == types.OptionalNone { + return nil, false + } + return opt.GetValue(), true + } + return v, true +} +``` + +### Emptiness check + +```go +func isEmpty(v ref.Val) bool { + if v == types.NullValue || v == nil { + return true + } + if s, ok := v.(types.String); ok { + return string(s) == "" + } + if l, ok := v.(traits.Lister); ok { + return l.Size() == types.IntZero + } + if m, ok := v.(traits.Mapper); ok { + return m.Size() == types.IntZero + } + return false +} +``` + +### gomplate emptiness + +For the Go/template side use `reflect` to detect nil, empty string, empty slice, +empty map (same categories, no numeric zero treatment). + +--- + +## Success Criteria + +- [ ] `coalesce` is callable in CEL expressions with 1–5+ arguments +- [ ] `coalesce` is callable in gomplate templates +- [ ] `optional.none()` and plain `null` are both skipped +- [ ] `optional.of("")` is skipped (empty string inside optional) +- [ ] `0`, `false` are returned as valid values +- [ ] Returns `null`/`nil` when all args are empty +- [ ] All unit and integration tests pass (`go test ./...`) +- [ ] `make lint` passes + +--- + +## Testing Requirements + +### Unit tests — `coll/coalesce_test.go` + +| Case | Input | Expected | +|------|-------|----------| +| first non-empty string | `coalesce("", "b")` | `"b"` | +| nil skip | `coalesce(nil, "x")` | `"x"` | +| all empty | `coalesce("", nil)` | `nil` | +| zero is valid | `coalesce(nil, 0)` | `0` | +| false is valid | `coalesce(nil, false)` | `false` | +| empty slice skip | `coalesce([]any{}, "ok")` | `"ok"` | +| empty map skip | `coalesce(map[string]any{}, "ok")` | `"ok"` | +| single non-empty | `coalesce("a")` | `"a"` | + +### CEL integration tests — `tests/cel_test.go` + +| Expression | Env | Expected | +|------------|-----|----------| +| `coalesce(null, "b")` | — | `"b"` | +| `coalesce("", "b")` | — | `"b"` | +| `coalesce(null, null)` | — | `null` | +| `coalesce(0, "x")` | — | `0` | +| `coalesce(optional.none(), "y")` | — | `"y"` | +| `coalesce(optional.of(""), "y")` | — | `"y"` | +| `coalesce(optional.of("z"), "y")` | — | `"z"` | +| `coalesce(a, "default")` | `a=null` | `"default"` | +| `coalesce(a, b, "last")` | `a=""`, `b=null` | `"last"` | + +--- + +## Implementation Checklist + +### Phase 1: Pure logic + +- [ ] Create `coll/coalesce.go` with `Coalesce(args ...any) any` +- [ ] Write unit tests in `coll/coalesce_test.go` +- [ ] Verify tests pass: `go test ./coll/...` + +### Phase 2: gomplate template function + +- [ ] Add `Coalesce` method to `CollFuncs` in `funcs/coll.go` +- [ ] Register `"coalesce"` key in `CreateCollFuncs` +- [ ] Add template-level tests + +### Phase 3: CEL binding + +- [ ] Write `celCoalesce` (`cel.Function`) in `funcs/coll.go` with overloads for + arity 1–5 and a list-based fallback overload +- [ ] Add `celCoalesce` to `CelEnvOption` in `funcs/cel_exports.go` +- [ ] Add CEL integration tests in `tests/cel_test.go` + +### Phase 4: Verify & lint + +- [ ] `go test ./...` passes +- [ ] `make lint` passes +- [ ] Review emptiness edge-cases (zero, false, non-nil empty struct) diff --git a/specs/REQUIREMENTS-first-last.md b/specs/REQUIREMENTS-first-last.md new file mode 100644 index 000000000..1037b7b6a --- /dev/null +++ b/specs/REQUIREMENTS-first-last.md @@ -0,0 +1,230 @@ +# Feature: nil-safe `first` and `last` — CEL and gomplate functions + +## Overview + +Add `first` and `last` functions available as CEL global functions, CEL member +methods, and gomplate template functions. They return the first/last element of +a list, character of a string, or value (by sorted-key order) of a map, with +nil-safe behaviour: null/empty/out-of-range inputs return the zero value of the +element type rather than panicking or producing an error. + +**Problem**: CEL expressions frequently need to pick the head or tail of a +collection produced by a nilsafe field access (which may be null or empty). No +such primitive exists in the codebase today, so users resort to verbose +conditionals. + +**Target users**: Engineers writing CEL expressions and Go-template strings in +Mission Control config/playbooks. + +--- + +## Functional Requirements + +### FR-1: `first` — return first element of a list + +**Description**: Returns the element at index 0 of a list/slice. Returns the +zero value of the element type when the list is null or empty. + +**User Story**: As a CEL user, I want `first(items)` or `items.first()` so that +I can safely extract the leading element without guarding against empty lists. + +**Acceptance Criteria**: +- [ ] `first([1, 2, 3])` → `1` +- [ ] `first([])` → `0` (int zero) / `""` (string zero) / `null` when type unknown +- [ ] `first(null)` → `null` +- [ ] Available as `first(list)` (global) and `list.first()` (member) in CEL +- [ ] Available as `{{ first .list }}` in gomplate templates + +--- + +### FR-2: `last` — return last element of a list + +**Description**: Returns the element at index `len-1` of a list/slice. Returns +the zero value of the element type when the list is null or empty. + +**User Story**: As a CEL user, I want `last(items)` or `items.last()` so that I +can safely extract the trailing element. + +**Acceptance Criteria**: +- [ ] `last([1, 2, 3])` → `3` +- [ ] `last([])` → zero value / `null` +- [ ] `last(null)` → `null` +- [ ] Available as `last(list)` (global) and `list.last()` (member) in CEL +- [ ] Available as `{{ last .list }}` in gomplate + +--- + +### FR-3: String support — first/last character + +**Description**: When the input is a string, `first` returns the first character +(as a single-character string) and `last` returns the last character. + +**Acceptance Criteria**: +- [ ] `first("hello")` → `"h"` +- [ ] `last("hello")` → `"o"` +- [ ] `first("")` → `""` (empty string zero value) +- [ ] `last("")` → `""` +- [ ] `first(null)` (string-typed null) → `""` + +--- + +### FR-4: Map support — first/last value by sorted key + +**Description**: When the input is a map, `first` returns the value at the +lexicographically smallest key, and `last` returns the value at the largest key. + +**Acceptance Criteria**: +- [ ] `first({"b": 2, "a": 1})` → `1` (key "a" sorts first) +- [ ] `last({"b": 2, "a": 1})` → `2` (key "b" sorts last) +- [ ] `first({})` → `null` +- [ ] `last({})` → `null` +- [ ] `first(null)` (map-typed null) → `null` + +--- + +### FR-5: Nil-safe — no errors on null/empty input + +**Description**: Both functions must never return a CEL error or a Go panic for +null, empty, or out-of-range inputs. They should be exempt from the `nilsafe` +library's short-circuit decorator (same pattern as `coalesce`). + +**Acceptance Criteria**: +- [ ] Exempt from `nilSafeCall` decorator in `nilsafe/nilsafe.go` +- [ ] Null list argument → returns `null` (not an error) +- [ ] Empty list argument → returns type-matched zero value or `null` +- [ ] Integrates cleanly with nilsafe variable resolution (`x.missing.first()` → `""` / `null`) + +--- + +## Technical Considerations + +### Implementation location + +| Artifact | Location | +|----------|----------| +| Pure Go logic | `coll/firstlast.go` (new file) | +| gomplate wrapper | `funcs/coll.go` — add `First` / `Last` methods to `CollFuncs`, register in `CreateCollFuncs` | +| CEL global bindings | `funcs/coll.go` — hand-written `cel.Function` vars `celFirst`, `celLast` | +| CEL member bindings | `funcs/coll.go` — `cel.MemberOverload` variants for list, string, map | +| CEL registration | `funcs/cel_exports.go` — append `celFirst`, `celLast` | +| nilsafe exemption | `nilsafe/nilsafe.go` — add `"first"` and `"last"` to the bypass check (same as `"coalesce"`) | +| Tests (unit) | `coll/firstlast_test.go` | +| Tests (CEL integration) | `tests/cel_test.go` — `TestCelFirstLast` | + +### CEL overload strategy + +CEL-go does not support true variadic functions but does support member +overloads. Register both global and member forms: + +```go +// Global: first(list) +cel.Overload("first_list", []*cel.Type{cel.ListType(cel.DynType)}, cel.DynType, ...) +cel.Overload("first_string", []*cel.Type{cel.StringType}, cel.StringType, ...) +cel.Overload("first_map", []*cel.Type{cel.MapType(cel.StringType, cel.DynType)}, cel.DynType, ...) + +// Member: list.first() +cel.MemberOverload("list_first", []*cel.Type{cel.ListType(cel.DynType)}, cel.DynType, ...) +cel.MemberOverload("string_first", []*cel.Type{cel.StringType}, cel.StringType, ...) +cel.MemberOverload("map_first", []*cel.Type{cel.MapType(cel.StringType, cel.DynType)}, cel.DynType, ...) +``` + +### Zero value strategy + +Return type-matched zero values following the same pattern as `nilsafe/zeroval.go`: + +| Input type | Empty/null result | +|------------|------------------| +| `list` | `""` | +| `list` | `0` | +| `list` | `false` | +| `list` / unknown | `null` | +| `string` | `""` | +| `map` (empty/null) | `null` | + +In practice, since CEL lists are typed `dyn` at runtime, use `types.NullValue` +for empty lists unless the first element's type can be inferred. + +### Go / gomplate side + +```go +func First(in any) any { + // reflect-based: handles []T, string, map[string]any +} +func Last(in any) any { ... } +``` + +Nil/empty input returns `nil` (template renders as `""`). + +--- + +## Success Criteria + +- [ ] `first` and `last` are callable in CEL as global functions +- [ ] `first` and `last` are callable in CEL as member methods on list, string, map +- [ ] `first` and `last` are callable in gomplate templates +- [ ] Null/empty inputs never produce CEL errors or Go panics +- [ ] Nil-safe integration: `x.missing.first()` works when `x.missing` is null +- [ ] All unit and integration tests pass (`go test ./...`) +- [ ] `make lint` introduces no new issues + +--- + +## Testing Requirements + +### Unit tests — `coll/firstlast_test.go` + +| Case | Input | Fn | Expected | +|------|-------|----|----------| +| list first element | `[]any{1, 2, 3}` | First | `1` | +| list last element | `[]any{1, 2, 3}` | Last | `3` | +| single-element list | `[]any{"x"}` | First/Last | `"x"` | +| empty list | `[]any{}` | First/Last | `nil` | +| nil input | `nil` | First/Last | `nil` | +| string first char | `"hello"` | First | `"h"` | +| string last char | `"hello"` | Last | `"o"` | +| empty string | `""` | First/Last | `""` | +| map first by key | `map[string]any{"b":2,"a":1}` | First | `1` | +| map last by key | `map[string]any{"b":2,"a":1}` | Last | `2` | +| empty map | `map[string]any{}` | First/Last | `nil` | + +### CEL integration tests — `tests/cel_test.go` (`TestCelFirstLast`) + +| Expression | Env | Expected | +|------------|-----|----------| +| `first([1,2,3])` | — | `"1"` | +| `last([1,2,3])` | — | `"3"` | +| `[1,2,3].first()` | — | `"1"` | +| `[1,2,3].last()` | — | `"3"` | +| `first([])` | — | `""` | +| `last([])` | — | `""` | +| `first(null)` | — | `""` | +| `first("hello")` | — | `"h"` | +| `last("hello")` | — | `"o"` | +| `"hello".first()` | — | `"h"` | +| `first({"b":2,"a":1})` | — | `"1"` | +| `last({"b":2,"a":1})` | — | `"2"` | +| `first(a)` | `a=null` | `""` | +| nil-safe: `a.first()` | `a=null (list)` | `""` | + +--- + +## Implementation Checklist + +### Phase 1: Pure Go logic +- [ ] Create `coll/firstlast.go` with `First(in any) any` and `Last(in any) any` +- [ ] Write `coll/firstlast_test.go` covering all input types and edge cases +- [ ] Verify: `go test ./coll/...` + +### Phase 2: gomplate template functions +- [ ] Add `First` / `Last` methods to `CollFuncs` in `funcs/coll.go` +- [ ] Register `"first"` and `"last"` keys in `CreateCollFuncs` + +### Phase 3: CEL bindings +- [ ] Write `celFirst` / `celLast` `cel.Function` vars with global + member overloads for list, string, map +- [ ] Add `"first"` and `"last"` to the nilsafe bypass in `nilsafe/nilsafe.go` +- [ ] Append `celFirst`, `celLast` to `CelEnvOption` in `funcs/cel_exports.go` + +### Phase 4: Integration tests +- [ ] Add `TestCelFirstLast` to `tests/cel_test.go` +- [ ] Run full suite: `go test ./...` +- [ ] Confirm no new lint issues: `make lint` diff --git a/template.go b/template.go index ae3a2db38..c6edeeae4 100644 --- a/template.go +++ b/template.go @@ -17,8 +17,6 @@ import ( "github.com/flanksource/commons/properties" _ "github.com/flanksource/gomplate/v3/js" "github.com/google/cel-go/cel" - "github.com/google/cel-go/common/types" - "github.com/google/cel-go/common/types/ref" "github.com/patrickmn/go-cache" "github.com/robertkrimen/otto" "github.com/robertkrimen/otto/registry" @@ -31,8 +29,7 @@ var funcMap gotemplate.FuncMap var ( // keep the cache period low as lots of anonymous functions can pile up the cache. - goTemplateCache = cache.New(time.Hour, time.Hour) - celExpressionCache = cache.New(time.Hour, time.Hour) + goTemplateCache = cache.New(time.Hour, time.Hour) ) func init() { @@ -177,119 +174,6 @@ func (t Template) IsEmpty() bool { return t.Template == "" && t.JSONPath == "" && t.Expression == "" && t.Javascript == "" } -func RunExpression(_environment map[string]any, template Template) (any, error) { - return RunExpressionContext(newContext(), _environment, template) -} - -func RunExpressionContext(ctx commonsContext.Context, _environment map[string]any, template Template) (any, error) { - tracker := celTrackerFromContext(ctx) - if tracker != nil { - if err := tracker.begin(); err != nil { - return nil, err - } - defer tracker.abort() - } - - data, err := Serialize(_environment) - if err != nil { - return "", err - } - - // Look up the compiled-program cache BEFORE constructing the CEL env options. - // GetCelEnv (notably kubernetes.Library()) is the dominant allocation on the CEL - // path. On the overwhelmingly common cache hit it would be built and then - // immediately discarded, since cel.NewEnv is only needed to compile a new - // program. Build env options only when we actually need to compile. - var prg cel.Program - var ast *cel.Ast - if tracker == nil && template.IsCacheable() { - cached, ok := celExpressionCache.Get(template.cacheKey(_environment)) - if ok { - if cachedPrg, ok := cached.(*cel.Program); ok { - prg = *cachedPrg - } - } - } - - if prg == nil { - base, err := baseCelEnv() - if err != nil { - return "", err - } - - // Only the per-call options are layered on top of the cached base env: the - // heavy, environment-independent libraries already live in base. This keeps - // the dominant CEL setup cost (kubernetes.Library and declaration - // validation) off the compile path. - envOptions := make([]cel.EnvOption, 0, len(typeAdapters)+len(data)+len(template.Functions)+len(template.CelEnvs)) - envOptions = append(envOptions, typeAdapters...) - for k := range data { - envOptions = append(envOptions, cel.Variable(k, cel.AnyType)) - } - for name, fn := range template.Functions { - _name := name - _fn := fn - envOptions = append(envOptions, cel.Function(_name, cel.Overload( - _name, - nil, - cel.AnyType, - cel.FunctionBinding(func(values ...ref.Val) ref.Val { - ogFunc, ok := _fn.(func() any) - if !ok { - return types.WrapErr(fmt.Errorf("%s is expected to be of type func() any", _name)) - } - - out := ogFunc() - return types.DefaultTypeAdapter.NativeToValue(out) - }), - ))) - } - - envOptions = append(envOptions, template.CelEnvs...) - - env, err := base.Extend(envOptions...) - if err != nil { - return "", err - } - - expression := strings.ReplaceAll(template.Expression, "\n", " ") - if tracker != nil { - expression = template.Expression - } - var issues *cel.Issues - ast, issues = env.Compile(expression) - if issues != nil && issues.Err() != nil { - return "", oops.With("template", template.Expression).Errorf("issues: %s", issues.String()) - } - - var programOptions []cel.ProgramOption - if tracker != nil { - programOptions = append(programOptions, cel.EvalOptions(cel.OptTrackState)) - } - prg, err = env.Program(ast, programOptions...) - if err != nil { - return "", err - } - - if tracker == nil && template.IsCacheable() { - celExpressionCache.Set(template.cacheKey(_environment), &prg, template.CacheTime) - } - } - - out, details, err := prg.Eval(data) - if tracker != nil { - tracker.complete(ast, details, out) - } - if err != nil { - return nil, oops.With("template", template.Expression).Wrap(err) - } - if ctx.Logger != nil && out.Value() != template.Expression && properties.On(false, "gomplate.log") { - ctx.Logger.V(4).Infof("templated %s => %v", template.ShortString(), out) - } - return out.Value(), nil - -} - func newContext() commonsContext.Context { return commonsContext.NewContext(context.TODO(), commonsContext.WithLogger(logger.GetLogger("gomplate"))) diff --git a/template_test.go b/template_test.go index 04d4382eb..93838b5ee 100644 --- a/template_test.go +++ b/template_test.go @@ -102,7 +102,7 @@ func TestCacheTime(t *testing.T) { if _, err := RunExpression(nil, tpl); err != nil { t.Fatalf("eval: %v", err) } - _, exp, ok := celExpressionCache.GetWithExpiration(tpl.CacheKey) + _, exp, ok := celExpressionCache.GetWithExpiration(tpl.celCacheKey(nil, currentNativeTypes().generation)) if !ok { t.Fatalf("entry not cached") } @@ -122,7 +122,7 @@ func TestCacheTime(t *testing.T) { if _, err := RunExpression(nil, tpl); err != nil { t.Fatalf("eval: %v", err) } - _, exp, ok := celExpressionCache.GetWithExpiration(tpl.CacheKey) + _, exp, ok := celExpressionCache.GetWithExpiration(tpl.celCacheKey(nil, currentNativeTypes().generation)) if !ok { t.Fatalf("entry not cached") } @@ -142,7 +142,7 @@ func TestCacheTime(t *testing.T) { if _, err := RunExpression(nil, tpl); err != nil { t.Fatalf("eval: %v", err) } - _, exp, ok := celExpressionCache.GetWithExpiration(tpl.CacheKey) + _, exp, ok := celExpressionCache.GetWithExpiration(tpl.celCacheKey(nil, currentNativeTypes().generation)) if !ok { t.Fatalf("entry not cached") } From aaf049badf35f41355ddf2e9ed6e23ad074c9750 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 16 Aug 2026 16:59:27 +0300 Subject: [PATCH 6/7] refactor(genmonarch): Consolidate generated language definitions into one JSON artifact Keep tokenizer and configuration data together in one stable generated artifact while preserving the definitions export. BREAKING CHANGE: Remove per-language *.monarch.json and *.config.json artifacts; consumers must use languages.json or the generated definitions export Claude-Session-Id: 36467ebc-f368-4040-bc75-e28248497b4c --- cel_expression.go | 6 +++++ genmonarch/render.go | 63 ++++++++++++++++++-------------------------- 2 files changed, 32 insertions(+), 37 deletions(-) diff --git a/cel_expression.go b/cel_expression.go index f9b60bed9..4f719e055 100644 --- a/cel_expression.go +++ b/cel_expression.go @@ -91,6 +91,12 @@ func compileCELProgram(data map[string]any, template Template, nativeTypes *nati return nil, nil, oops.With("template", template.Expression).Errorf("issues: %s", issues.String()) } + // OptOptimize folds constants and precompiles regexes while the program is + // built. That makes this function -- the cache=miss path -- allocate a few + // percent more, which is the trade being bought: a program is compiled once + // and then evaluated from celExpressionCache for an hour, so the work moves + // off the hot path. Do not "fix" a compile-path allocation regression here by + // dropping it. evalOptions := []cel.EvalOption{cel.OptOptimize} if trackState { evalOptions = append(evalOptions, cel.OptTrackState) diff --git a/genmonarch/render.go b/genmonarch/render.go index b69182e9c..942b06f93 100644 --- a/genmonarch/render.go +++ b/genmonarch/render.go @@ -16,26 +16,38 @@ const generatedHeader = `// Code generated by cmd/genmonarch. DO NOT EDIT. // function catalogue is read from a live cel.Env and gomplate's FuncMap. ` +// languageDefinition is one language's whole editor contract -- tokenizer plus +// configuration -- in the shape the npm package's LanguageDefinition declares. +// Every language ships in a single generated file rather than two files each: +// the consumer always loads the whole set, and one artifact keeps the generated +// tree small enough to review. +type languageDefinition struct { + ID string `json:"id"` + Monarch Language `json:"monarch"` + Configuration Configuration `json:"configuration"` +} + // Render turns a bundle into the files the npm package ships, keyed by // filename. JSON is indented and newline-terminated so a regenerated tree // produces a reviewable diff. func Render(b *Bundle) (map[string][]byte, error) { files := map[string][]byte{} + // Keyed by language id: encoding/json sorts map keys, so the file is stable + // regardless of the order languages were built in. + definitions := make(map[string]languageDefinition, len(b.Order)) for _, id := range b.Order { - lang := b.Languages[id] - encoded, err := marshalIndent(lang) - if err != nil { - return nil, fmt.Errorf("%s tokenizer: %w", id, err) - } - files[id+".monarch.json"] = encoded - - encoded, err = marshalIndent(b.Configurations[id]) - if err != nil { - return nil, fmt.Errorf("%s configuration: %w", id, err) + definitions[id] = languageDefinition{ + ID: id, + Monarch: b.Languages[id], + Configuration: b.Configurations[id], } - files[id+".config.json"] = encoded } + languages, err := marshalIndent(definitions) + if err != nil { + return nil, fmt.Errorf("language definitions: %w", err) + } + files["languages.json"] = languages spec, err := marshalIndent(b.Spec) if err != nil { @@ -78,11 +90,7 @@ func renderIndex(b *Bundle) string { ids := append([]string(nil), b.Order...) sort.Strings(ids) - for _, id := range ids { - symbol := identifierFor(id) - fmt.Fprintf(&out, "import %sMonarch from \"./%s.monarch.json\";\n", symbol, id) - fmt.Fprintf(&out, "import %sConfig from \"./%s.config.json\";\n", symbol, id) - } + out.WriteString("import languagesJson from \"./languages.json\";\n") out.WriteString("import specJson from \"./spec.json\";\n") out.WriteString("import conformanceJson from \"./conformance.json\";\n\n") @@ -98,30 +106,11 @@ func renderIndex(b *Bundle) string { out.WriteString("] as const;\n\n") out.WriteString("export type LanguageId = (typeof LANGUAGE_IDS)[number];\n\n") - // The casts go through `unknown` because TypeScript widens a JSON import's + // The cast goes through `unknown` because TypeScript widens a JSON import's // fixed-length arrays to `string[]`, while Monaco's types want tuples // (`CharacterPair`, `blockComment`). The shapes are produced by this // generator and covered by the tokenizer tests, so widening here loses // nothing that is actually checked elsewhere. - out.WriteString("export const definitions: Record = {\n") - for _, id := range ids { - symbol := identifierFor(id) - fmt.Fprintf(&out, " %q: {\n id: %q,\n monarch: %sMonarch as unknown as LanguageDefinition[\"monarch\"],\n configuration: %sConfig as unknown as LanguageDefinition[\"configuration\"],\n },\n", - id, id, symbol, symbol) - } - out.WriteString("};\n") + out.WriteString("export const definitions = languagesJson as unknown as Record;\n") return out.String() } - -// identifierFor turns a language id into a JS identifier: `yaml-gomplate` -> -// `yamlGomplate`. -func identifierFor(id string) string { - parts := strings.Split(id, "-") - for i := 1; i < len(parts); i++ { - if parts[i] == "" { - continue - } - parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] - } - return strings.Join(parts, "") -} From 50c1799c741fecff4fe11d965d57f60be97c9bfe Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 17 Aug 2026 07:53:06 +0300 Subject: [PATCH 7/7] refactor(lang): consolidate generated language definitions Keep generated Monarch and editor configuration metadata in one catalog so the language package has a single import surface while preserving all existing definitions. --- .../lang/src/generated/cel.config.json | 68 - .../lang/src/generated/cel.monarch.json | 566 ---- .../lang/src/generated/gomplate.config.json | 55 - .../lang/src/generated/gomplate.monarch.json | 282 -- web/packages/lang/src/generated/index.ts | 46 +- .../src/generated/json-gomplate.config.json | 31 - .../src/generated/json-gomplate.monarch.json | 353 -- .../lang/src/generated/jsonpath.config.json | 31 - .../lang/src/generated/jsonpath.monarch.json | 103 - .../lang/src/generated/languages.json | 2299 +++++++++++++ .../src/generated/text-gomplate.config.json | 31 - .../src/generated/text-gomplate.monarch.json | 287 -- .../src/generated/yaml-gomplate.config.json | 38 - .../src/generated/yaml-gomplate.monarch.json | 434 --- web/pnpm-lock.yaml | 2999 +++++++++++++++++ 15 files changed, 5300 insertions(+), 2323 deletions(-) delete mode 100644 web/packages/lang/src/generated/cel.config.json delete mode 100644 web/packages/lang/src/generated/cel.monarch.json delete mode 100644 web/packages/lang/src/generated/gomplate.config.json delete mode 100644 web/packages/lang/src/generated/gomplate.monarch.json delete mode 100644 web/packages/lang/src/generated/json-gomplate.config.json delete mode 100644 web/packages/lang/src/generated/json-gomplate.monarch.json delete mode 100644 web/packages/lang/src/generated/jsonpath.config.json delete mode 100644 web/packages/lang/src/generated/jsonpath.monarch.json create mode 100644 web/packages/lang/src/generated/languages.json delete mode 100644 web/packages/lang/src/generated/text-gomplate.config.json delete mode 100644 web/packages/lang/src/generated/text-gomplate.monarch.json delete mode 100644 web/packages/lang/src/generated/yaml-gomplate.config.json delete mode 100644 web/packages/lang/src/generated/yaml-gomplate.monarch.json create mode 100644 web/pnpm-lock.yaml diff --git a/web/packages/lang/src/generated/cel.config.json b/web/packages/lang/src/generated/cel.config.json deleted file mode 100644 index a7a104b89..000000000 --- a/web/packages/lang/src/generated/cel.config.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "comments": { - "lineComment": "//", - "blockComment": [ - "", - "" - ] - }, - "brackets": [ - [ - "{", - "}" - ], - [ - "[", - "]" - ], - [ - "(", - ")" - ] - ], - "autoClosingPairs": [ - { - "open": "{", - "close": "}" - }, - { - "open": "[", - "close": "]" - }, - { - "open": "(", - "close": ")" - }, - { - "open": "\"", - "close": "\"" - }, - { - "open": "'", - "close": "'" - } - ], - "surroundingPairs": [ - { - "open": "{", - "close": "}" - }, - { - "open": "[", - "close": "]" - }, - { - "open": "(", - "close": ")" - }, - { - "open": "\"", - "close": "\"" - }, - { - "open": "'", - "close": "'" - } - ], - "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" -} diff --git a/web/packages/lang/src/generated/cel.monarch.json b/web/packages/lang/src/generated/cel.monarch.json deleted file mode 100644 index b3d9986fb..000000000 --- a/web/packages/lang/src/generated/cel.monarch.json +++ /dev/null @@ -1,566 +0,0 @@ -{ - "defaultToken": "", - "tokenPostfix": ".cel", - "brackets": [ - { - "open": "{", - "close": "}", - "token": "delimiter.curly" - }, - { - "open": "[", - "close": "]", - "token": "delimiter.square" - }, - { - "open": "(", - "close": ")", - "token": "delimiter.parenthesis" - } - ], - "constants": [ - "false", - "null", - "true" - ], - "globalFunctions": [ - "ASCII", - "Abs", - "Add", - "Age", - "Alpha", - "AlphaNum", - "Append", - "Assert", - "Base", - "CSV", - "CSVByColumn", - "CSVByRow", - "Ceil", - "Clean", - "Contains", - "ContainsCIDR", - "Dict", - "Dir", - "Div", - "Duration", - "Ext", - "Fail", - "Find", - "FindAll", - "Float", - "Floor", - "FromSlash", - "GetHealth", - "GetStatus", - "Has", - "HashUUID", - "Hour", - "HumanDuration", - "HumanSize", - "InTimeRange", - "IsAbs", - "IsFloat", - "IsHealthy", - "IsInt", - "IsKind", - "IsNum", - "IsReady", - "IsValid", - "IsValidIP", - "Item", - "Join", - "Kind", - "Match", - "Microsecond", - "Millisecond", - "Minute", - "Mul", - "Nanosecond", - "Nil", - "Now", - "Number", - "Parse", - "ParseDateTime", - "ParseDuration", - "ParseInLocation", - "ParseLocal", - "Pow", - "Prepend", - "QuoteMeta", - "Rel", - "Rem", - "Replace", - "ReplaceLiteral", - "Required", - "Round", - "SHA1", - "SHA1Bytes", - "SHA224", - "SHA224Bytes", - "SHA256", - "SHA256Bytes", - "SHA384", - "SHA384Bytes", - "SHA512", - "SHA512Bytes", - "SHA512_224", - "SHA512_224Bytes", - "SHA512_256", - "SHA512_256Bytes", - "Second", - "Semver", - "SemverCompare", - "Seq", - "Since", - "Sort", - "Split", - "SplitN", - "String", - "Sub", - "TOML", - "Ternary", - "ToSlash", - "Unix", - "Until", - "V1", - "V4", - "VolumeName", - "YAML", - "YAMLArray", - "ZoneName", - "ZoneOffset", - "abs", - "age", - "arnToMap", - "bitAnd", - "bitNot", - "bitOr", - "bitShiftLeft", - "bitShiftRight", - "bitXor", - "bool", - "bytes", - "ceil", - "coalesce", - "contains", - "containsFloat", - "cpuAsMillicores", - "debug", - "decode", - "double", - "duration", - "dyn", - "encode", - "equivalent", - "f", - "first", - "floor", - "fromAWSMap", - "getHealth", - "getResourcesLimit", - "getResourcesRequests", - "getStatus", - "in", - "in_business_hours", - "int", - "intersects", - "isFinite", - "isHealthy", - "isInf", - "isNaN", - "isReady", - "isURL", - "is_healthy", - "jmespath", - "jq", - "jsonpath", - "keyValToMap", - "labels", - "last", - "mapToKeyVal", - "matchLabel", - "matches", - "memoryAsBytes", - "merge", - "neat", - "nodeProperties", - "none", - "of", - "ofNonZeroValue", - "podProperties", - "quote", - "range", - "round", - "sign", - "size", - "sqrt", - "string", - "timestamp", - "toCSV", - "toTOML", - "toYAML", - "trunc", - "type", - "uint", - "url", - "urldecode", - "urlencode", - "xpath" - ], - "keywords": [ - "as", - "break", - "const", - "continue", - "else", - "for", - "function", - "if", - "import", - "in", - "let", - "loop", - "namespace", - "package", - "return", - "type", - "var", - "void", - "while" - ], - "macros": [ - "all", - "exists", - "exists_one", - "filter", - "fold", - "greatest", - "has", - "least", - "map", - "optFlatMap", - "optMap", - "sortBy" - ], - "memberFunctions": [ - "JSON", - "JSONArray", - "abbrev", - "camelCase", - "charAt", - "contains", - "distinct", - "endsWith", - "find", - "findAll", - "flatten", - "format", - "getDate", - "getDayOfMonth", - "getDayOfWeek", - "getDayOfYear", - "getEscapedPath", - "getFullYear", - "getHost", - "getHostname", - "getHours", - "getMilliseconds", - "getMinutes", - "getMonth", - "getPort", - "getQuery", - "getScheme", - "getSeconds", - "hasValue", - "indent", - "indexOf", - "isSorted", - "join", - "kebabCase", - "keys", - "lastIndexOf", - "lowerAscii", - "match", - "max", - "min", - "omit", - "or", - "orValue", - "pick", - "quote", - "repeat", - "replace", - "replaceAll", - "replaceAllRegex", - "reverse", - "runeCount", - "shellQuote", - "slice", - "slug", - "snakeCase", - "sort", - "sortBy", - "split", - "splitRegex", - "squote", - "startsWith", - "substring", - "sum", - "title", - "toJSON", - "toJSONPretty", - "toLower", - "toUpper", - "trim", - "trimPrefix", - "trimSpace", - "trimSuffix", - "trunc", - "uniq", - "upperAscii", - "value", - "values", - "wordWrap" - ], - "namespaces": [ - "aws", - "base64", - "crypto", - "data", - "filepath", - "json", - "k8s", - "lists", - "math", - "net", - "optional", - "random", - "regexp", - "sets", - "strings", - "test", - "time", - "uuid" - ], - "operators": [ - "!", - "!=", - "%", - "\u0026\u0026", - "(", - ")", - "*", - "+", - ",", - "-", - ".", - "/", - ":", - "\u003c", - "\u003c=", - "==", - "\u003e", - "\u003e=", - "?", - "[", - "]", - "{", - "||", - "}" - ], - "typeKeywords": [ - "bool", - "bytes", - "double", - "duration", - "dyn", - "int", - "list", - "map", - "null_type", - "string", - "timestamp", - "type", - "uint" - ], - "tokenizer": { - "root": [ - { - "include": "@whitespace" - }, - [ - "[bB](?:[rR]\"\"\"[\\s\\S]*?\"\"\"|[rR]'''[\\s\\S]*?'''|\"\"\"(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?\"\"\"|'''(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?'''|[rR]\"[^\"\\n\\r]*\"|[rR]'[^'\\n\\r]*'|\"(?:[^\\\\\"\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*\"|'(?:[^\\\\'\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*')", - "string.bytes" - ], - [ - "(?:[rR]\"\"\"[\\s\\S]*?\"\"\"|[rR]'''[\\s\\S]*?'''|\"\"\"(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?\"\"\"|'''(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?'''|[rR]\"[^\"\\n\\r]*\"|[rR]'[^'\\n\\r]*'|\"(?:[^\\\\\"\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*\"|'(?:[^\\\\'\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*')", - "string" - ], - [ - "`[A-Za-z0-9_.\\-/ ]+`", - "identifier.escaped" - ], - [ - "(?:\\.[0-9]+(?:[eE][+\\-]?[0-9]+)?|[0-9]+\\.[0-9]+(?:[eE][+\\-]?[0-9]+)?|[0-9]+[eE][+\\-]?[0-9]+)", - "number.float" - ], - [ - "(?:0x[0-9a-fA-F]+[uU]|[0-9]+[uU])", - "number.uint" - ], - [ - "(?:0x[0-9a-fA-F]+|[0-9]+)", - "number" - ], - [ - "(\\.\\?)([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", - { - "cases": { - "$2@macros": [ - "operator.optional", - "keyword.macro" - ], - "$2@memberFunctions": [ - "operator.optional", - "function.member" - ], - "$2@globalFunctions": [ - "operator.optional", - "function" - ], - "@default": [ - "operator.optional", - "variable.field" - ] - } - } - ], - [ - "(\\.\\?)([A-Za-z_][A-Za-z0-9_]*)", - [ - "operator.optional", - "variable.field" - ] - ], - [ - "\\.\\?", - "operator.optional" - ], - [ - "\\[\\?", - "operator.optional" - ], - [ - "\\?\\.", - "operator.optional" - ], - [ - "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", - { - "cases": { - "$1@namespaces": [ - "namespace", - "delimiter", - "function" - ], - "$3@macros": [ - "identifier", - "delimiter", - "keyword.macro" - ], - "$3@memberFunctions": [ - "identifier", - "delimiter", - "function.member" - ], - "$3@globalFunctions": [ - "identifier", - "delimiter", - "function" - ], - "@default": [ - "identifier", - "delimiter", - "identifier" - ] - } - } - ], - [ - "(\\.)([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", - { - "cases": { - "$2@macros": [ - "delimiter", - "keyword.macro" - ], - "$2@memberFunctions": [ - "delimiter", - "function.member" - ], - "$2@globalFunctions": [ - "delimiter", - "function" - ], - "@default": [ - "delimiter", - "variable.field" - ] - } - } - ], - [ - "(\\.)([A-Za-z_][A-Za-z0-9_]*)", - [ - "delimiter", - "variable.field" - ] - ], - [ - "([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", - { - "cases": { - "$1@macros": "keyword.macro", - "$1@globalFunctions": "function", - "$1@keywords": "keyword", - "@default": "identifier" - } - } - ], - [ - "[A-Za-z_][A-Za-z0-9_]*", - { - "cases": { - "@constants": "keyword.constant", - "@keywords": "keyword", - "@typeKeywords": "type", - "@default": "identifier" - } - } - ], - [ - "[{}()\\[\\]]", - "@brackets" - ], - [ - "(?:!=|\u0026\u0026|\u003c=|==|\u003e=|\\|\\||!|%|\\*|\\+|,|-|\\.|\\/|:|\u003c|\u003e|\\?)", - "operator" - ], - [ - "[,;]", - "delimiter" - ] - ], - "whitespace": [ - [ - "[\\t \\r\\n\\f]+", - "white" - ], - [ - "//[^\\n]*", - "comment" - ] - ] - } -} diff --git a/web/packages/lang/src/generated/gomplate.config.json b/web/packages/lang/src/generated/gomplate.config.json deleted file mode 100644 index decec0d59..000000000 --- a/web/packages/lang/src/generated/gomplate.config.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "comments": { - "blockComment": [ - "{{/*", - "*/}}" - ] - }, - "brackets": [ - [ - "{{", - "}}" - ], - [ - "(", - ")" - ], - [ - "[", - "]" - ] - ], - "autoClosingPairs": [ - { - "open": "{{", - "close": " }}" - }, - { - "open": "(", - "close": ")" - }, - { - "open": "\"", - "close": "\"" - }, - { - "open": "`", - "close": "`" - } - ], - "surroundingPairs": [ - { - "open": "(", - "close": ")" - }, - { - "open": "\"", - "close": "\"" - }, - { - "open": "`", - "close": "`" - } - ], - "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" -} diff --git a/web/packages/lang/src/generated/gomplate.monarch.json b/web/packages/lang/src/generated/gomplate.monarch.json deleted file mode 100644 index 6c33e4fb4..000000000 --- a/web/packages/lang/src/generated/gomplate.monarch.json +++ /dev/null @@ -1,282 +0,0 @@ -{ - "defaultToken": "source", - "tokenPostfix": ".gomplate", - "brackets": [ - { - "open": "(", - "close": ")", - "token": "delimiter.parenthesis" - }, - { - "open": "[", - "close": "]", - "token": "delimiter.square" - } - ], - "builtins": [ - "and", - "call", - "eq", - "ge", - "gt", - "html", - "index", - "js", - "le", - "len", - "lt", - "ne", - "not", - "or", - "print", - "printf", - "println", - "slice", - "urlquery" - ], - "functions": [ - "add", - "append", - "assert", - "bool", - "coalesce", - "contains", - "csv", - "csvByColumn", - "csvByRow", - "default", - "dict", - "div", - "endsWith", - "fail", - "first", - "flatten", - "getHealth", - "getStatus", - "has", - "hasPrefix", - "hasSuffix", - "humanDuration", - "humanSize", - "in_business_hours", - "indent", - "isHealthy", - "isKind", - "isReady", - "jmespath", - "join", - "jq", - "json", - "jsonArray", - "jsonpath", - "keyValToMap", - "keys", - "kind", - "last", - "mapToKeyVal", - "matchLabel", - "merge", - "mul", - "neat", - "parseDateTime", - "pow", - "prepend", - "quote", - "rem", - "replaceAll", - "required", - "reverse", - "semver", - "semverCompare", - "seq", - "shellQuote", - "slice", - "sort", - "split", - "splitN", - "squote", - "startsWith", - "sub", - "ternary", - "title", - "toCSV", - "toJSON", - "toJSONPretty", - "toLower", - "toTOML", - "toUpper", - "toYAML", - "toml", - "trim", - "uniq", - "urlParse", - "urldecode", - "urlencode", - "values", - "xpath", - "yaml", - "yamlArray" - ], - "keywords": [ - "block", - "break", - "continue", - "define", - "else", - "end", - "if", - "nil", - "range", - "template", - "with" - ], - "namespaces": [ - "base64", - "coll", - "conv", - "crypto", - "data", - "filepath", - "k8s", - "math", - "net", - "path", - "random", - "regexp", - "strings", - "test", - "time", - "uuid" - ], - "tokenizer": { - "root": [ - [ - "^#\\s*gotemplate:.*$", - "comment.directive" - ], - [ - "\\{\\{-?\\/\\*", - { - "next": "@tmplComment", - "token": "comment" - } - ], - [ - "\\{\\{-?", - { - "next": "@tmplAction", - "token": "delimiter.template" - } - ], - [ - "[\\s\\S]", - "source" - ] - ], - "tmplComment": [ - [ - "\\*\\/-?\\}\\}", - { - "next": "@pop", - "token": "comment" - } - ], - [ - "[\\s\\S]", - "comment" - ] - ], - "tmplAction": [ - [ - "-?\\}\\}", - { - "next": "@pop", - "token": "delimiter.template" - } - ], - [ - "[ \\t\\r\\n]+", - "white" - ], - [ - "\"(?:[^\"\\\\]|\\\\.)*\"", - "string" - ], - [ - "`[^`]*`", - "string" - ], - [ - "'(?:[^'\\\\]|\\\\.)*'", - "string" - ], - [ - "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", - "number" - ], - [ - "\\$[A-Za-z_][A-Za-z0-9_]*", - "variable" - ], - [ - "\\$", - "variable" - ], - [ - "(\\.)([A-Za-z_][A-Za-z0-9_]*)", - [ - "delimiter", - "variable.field" - ] - ], - [ - "\\.", - "variable.field" - ], - [ - "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", - { - "cases": { - "$1@namespaces": [ - "namespace", - "delimiter", - "function" - ], - "@default": [ - "identifier", - "delimiter", - "identifier" - ] - } - } - ], - [ - "[A-Za-z_][A-Za-z0-9_]*", - { - "cases": { - "@keywords": "keyword", - "@builtins": "function.builtin", - "@functions": "function", - "@default": "identifier" - } - } - ], - [ - "[()\\[\\]]", - "@brackets" - ], - [ - "\\|", - "operator.pipe" - ], - [ - ":=|=", - "operator" - ], - [ - ",", - "delimiter" - ] - ] - } -} diff --git a/web/packages/lang/src/generated/index.ts b/web/packages/lang/src/generated/index.ts index e3447267d..e0386518f 100644 --- a/web/packages/lang/src/generated/index.ts +++ b/web/packages/lang/src/generated/index.ts @@ -6,18 +6,7 @@ import type { ConformanceCase, LanguageDefinition, GomplateSpec } from "../types"; -import celMonarch from "./cel.monarch.json"; -import celConfig from "./cel.config.json"; -import gomplateMonarch from "./gomplate.monarch.json"; -import gomplateConfig from "./gomplate.config.json"; -import jsonGomplateMonarch from "./json-gomplate.monarch.json"; -import jsonGomplateConfig from "./json-gomplate.config.json"; -import jsonpathMonarch from "./jsonpath.monarch.json"; -import jsonpathConfig from "./jsonpath.config.json"; -import textGomplateMonarch from "./text-gomplate.monarch.json"; -import textGomplateConfig from "./text-gomplate.config.json"; -import yamlGomplateMonarch from "./yaml-gomplate.monarch.json"; -import yamlGomplateConfig from "./yaml-gomplate.config.json"; +import languagesJson from "./languages.json"; import specJson from "./spec.json"; import conformanceJson from "./conformance.json"; @@ -38,35 +27,4 @@ export const LANGUAGE_IDS = [ export type LanguageId = (typeof LANGUAGE_IDS)[number]; -export const definitions: Record = { - "cel": { - id: "cel", - monarch: celMonarch as unknown as LanguageDefinition["monarch"], - configuration: celConfig as unknown as LanguageDefinition["configuration"], - }, - "gomplate": { - id: "gomplate", - monarch: gomplateMonarch as unknown as LanguageDefinition["monarch"], - configuration: gomplateConfig as unknown as LanguageDefinition["configuration"], - }, - "json-gomplate": { - id: "json-gomplate", - monarch: jsonGomplateMonarch as unknown as LanguageDefinition["monarch"], - configuration: jsonGomplateConfig as unknown as LanguageDefinition["configuration"], - }, - "jsonpath": { - id: "jsonpath", - monarch: jsonpathMonarch as unknown as LanguageDefinition["monarch"], - configuration: jsonpathConfig as unknown as LanguageDefinition["configuration"], - }, - "text-gomplate": { - id: "text-gomplate", - monarch: textGomplateMonarch as unknown as LanguageDefinition["monarch"], - configuration: textGomplateConfig as unknown as LanguageDefinition["configuration"], - }, - "yaml-gomplate": { - id: "yaml-gomplate", - monarch: yamlGomplateMonarch as unknown as LanguageDefinition["monarch"], - configuration: yamlGomplateConfig as unknown as LanguageDefinition["configuration"], - }, -}; +export const definitions = languagesJson as unknown as Record; diff --git a/web/packages/lang/src/generated/json-gomplate.config.json b/web/packages/lang/src/generated/json-gomplate.config.json deleted file mode 100644 index c087049ba..000000000 --- a/web/packages/lang/src/generated/json-gomplate.config.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "brackets": [ - [ - "{", - "}" - ], - [ - "[", - "]" - ] - ], - "autoClosingPairs": [ - { - "open": "{", - "close": "}" - }, - { - "open": "[", - "close": "]" - }, - { - "open": "\"", - "close": "\"" - }, - { - "open": "'", - "close": "'" - } - ], - "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" -} diff --git a/web/packages/lang/src/generated/json-gomplate.monarch.json b/web/packages/lang/src/generated/json-gomplate.monarch.json deleted file mode 100644 index 63a506dce..000000000 --- a/web/packages/lang/src/generated/json-gomplate.monarch.json +++ /dev/null @@ -1,353 +0,0 @@ -{ - "defaultToken": "", - "tokenPostfix": ".json-gomplate", - "brackets": [ - { - "open": "{", - "close": "}", - "token": "delimiter.curly" - }, - { - "open": "[", - "close": "]", - "token": "delimiter.square" - }, - { - "open": "(", - "close": ")", - "token": "delimiter.parenthesis" - } - ], - "builtins": [ - "and", - "call", - "eq", - "ge", - "gt", - "html", - "index", - "js", - "le", - "len", - "lt", - "ne", - "not", - "or", - "print", - "printf", - "println", - "slice", - "urlquery" - ], - "functions": [ - "add", - "append", - "assert", - "bool", - "coalesce", - "contains", - "csv", - "csvByColumn", - "csvByRow", - "default", - "dict", - "div", - "endsWith", - "fail", - "first", - "flatten", - "getHealth", - "getStatus", - "has", - "hasPrefix", - "hasSuffix", - "humanDuration", - "humanSize", - "in_business_hours", - "indent", - "isHealthy", - "isKind", - "isReady", - "jmespath", - "join", - "jq", - "json", - "jsonArray", - "jsonpath", - "keyValToMap", - "keys", - "kind", - "last", - "mapToKeyVal", - "matchLabel", - "merge", - "mul", - "neat", - "parseDateTime", - "pow", - "prepend", - "quote", - "rem", - "replaceAll", - "required", - "reverse", - "semver", - "semverCompare", - "seq", - "shellQuote", - "slice", - "sort", - "split", - "splitN", - "squote", - "startsWith", - "sub", - "ternary", - "title", - "toCSV", - "toJSON", - "toJSONPretty", - "toLower", - "toTOML", - "toUpper", - "toYAML", - "toml", - "trim", - "uniq", - "urlParse", - "urldecode", - "urlencode", - "values", - "xpath", - "yaml", - "yamlArray" - ], - "keywords": [ - "block", - "break", - "continue", - "define", - "else", - "end", - "if", - "nil", - "range", - "template", - "with" - ], - "namespaces": [ - "base64", - "coll", - "conv", - "crypto", - "data", - "filepath", - "k8s", - "math", - "net", - "path", - "random", - "regexp", - "strings", - "test", - "time", - "uuid" - ], - "tokenizer": { - "root": [ - [ - "^#\\s*gotemplate:.*$", - "comment.directive" - ], - [ - "\\{\\{-?\\/\\*", - { - "next": "@tmplComment", - "token": "comment" - } - ], - [ - "\\{\\{-?", - { - "next": "@tmplAction", - "token": "delimiter.template" - } - ], - [ - "(\"(?:[^\"\\\\{]|\\\\.)*\")(\\s*)(:)", - [ - "type.json", - "white", - "delimiter" - ] - ], - [ - "\"", - { - "next": "@jsonString", - "token": "string" - } - ], - [ - "\\b(?:true|false|null)\\b", - "keyword.constant" - ], - [ - "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?", - "number" - ], - [ - "[{}\\[\\]]", - "@brackets" - ], - [ - "[,:]", - "delimiter" - ], - [ - "[\\s\\S]", - "" - ] - ], - "jsonString": [ - [ - "\\{\\{-?\\/\\*", - { - "next": "@tmplComment", - "token": "comment" - } - ], - [ - "\\{\\{-?", - { - "next": "@tmplAction", - "token": "delimiter.template" - } - ], - [ - "\"", - { - "next": "@pop", - "token": "string" - } - ], - [ - "\\\\.", - "string.escape" - ], - [ - "[^\"\\\\{]+", - "string" - ], - [ - "[\\s\\S]", - "string" - ] - ], - "tmplComment": [ - [ - "\\*\\/-?\\}\\}", - { - "next": "@pop", - "token": "comment" - } - ], - [ - "[\\s\\S]", - "comment" - ] - ], - "tmplAction": [ - [ - "-?\\}\\}", - { - "next": "@pop", - "token": "delimiter.template" - } - ], - [ - "[ \\t\\r\\n]+", - "white" - ], - [ - "\"(?:[^\"\\\\]|\\\\.)*\"", - "string" - ], - [ - "`[^`]*`", - "string" - ], - [ - "'(?:[^'\\\\]|\\\\.)*'", - "string" - ], - [ - "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", - "number" - ], - [ - "\\$[A-Za-z_][A-Za-z0-9_]*", - "variable" - ], - [ - "\\$", - "variable" - ], - [ - "(\\.)([A-Za-z_][A-Za-z0-9_]*)", - [ - "delimiter", - "variable.field" - ] - ], - [ - "\\.", - "variable.field" - ], - [ - "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", - { - "cases": { - "$1@namespaces": [ - "namespace", - "delimiter", - "function" - ], - "@default": [ - "identifier", - "delimiter", - "identifier" - ] - } - } - ], - [ - "[A-Za-z_][A-Za-z0-9_]*", - { - "cases": { - "@keywords": "keyword", - "@builtins": "function.builtin", - "@functions": "function", - "@default": "identifier" - } - } - ], - [ - "[()\\[\\]]", - "@brackets" - ], - [ - "\\|", - "operator.pipe" - ], - [ - ":=|=", - "operator" - ], - [ - ",", - "delimiter" - ] - ] - } -} diff --git a/web/packages/lang/src/generated/jsonpath.config.json b/web/packages/lang/src/generated/jsonpath.config.json deleted file mode 100644 index a5ec4c9db..000000000 --- a/web/packages/lang/src/generated/jsonpath.config.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "brackets": [ - [ - "[", - "]" - ], - [ - "(", - ")" - ] - ], - "autoClosingPairs": [ - { - "open": "[", - "close": "]" - }, - { - "open": "(", - "close": ")" - }, - { - "open": "\"", - "close": "\"" - }, - { - "open": "'", - "close": "'" - } - ], - "wordPattern": "[A-Za-z_][A-Za-z0-9_]*" -} diff --git a/web/packages/lang/src/generated/jsonpath.monarch.json b/web/packages/lang/src/generated/jsonpath.monarch.json deleted file mode 100644 index ed02d2e1b..000000000 --- a/web/packages/lang/src/generated/jsonpath.monarch.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "defaultToken": "", - "tokenPostfix": ".jsonpath", - "brackets": [ - { - "open": "[", - "close": "]", - "token": "delimiter.square" - }, - { - "open": "(", - "close": ")", - "token": "delimiter.parenthesis" - } - ], - "filterOperators": [ - "!", - "!=", - "\u0026\u0026", - "*", - "+", - "-", - "/", - "\u003c", - "\u003c=", - "==", - "=~", - "\u003e", - "\u003e=", - "||" - ], - "tokenizer": { - "root": [ - [ - "\\$", - "variable.root" - ], - [ - "@", - "variable.current" - ], - [ - "\\.\\.", - "operator.descendant" - ], - [ - "\\*", - "operator.wildcard" - ], - [ - "(\\.)([A-Za-z_][A-Za-z0-9_]*)", - [ - "delimiter", - "variable.field" - ] - ], - [ - "\\.", - "delimiter" - ], - [ - "\\?\\(", - "keyword.filter" - ], - [ - "[\\[\\]()]", - "@brackets" - ], - [ - "\"(?:[^\"\\\\]|\\\\.)*\"", - "string" - ], - [ - "'(?:[^'\\\\]|\\\\.)*'", - "string" - ], - [ - "\\b(?:true|false|null)\\b", - "keyword.constant" - ], - [ - "-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?", - "number" - ], - [ - "(?:==|!=|\u003c=|\u003e=|\u0026\u0026|\\|\\||=~|[\u003c\u003e!+\\-*/])", - "operator" - ], - [ - ":", - "operator.slice" - ], - [ - ",", - "delimiter" - ], - [ - "[A-Za-z_][A-Za-z0-9_]*", - "variable.field" - ] - ] - } -} diff --git a/web/packages/lang/src/generated/languages.json b/web/packages/lang/src/generated/languages.json new file mode 100644 index 000000000..164dc5a1a --- /dev/null +++ b/web/packages/lang/src/generated/languages.json @@ -0,0 +1,2299 @@ +{ + "cel": { + "id": "cel", + "monarch": { + "defaultToken": "", + "tokenPostfix": ".cel", + "brackets": [ + { + "open": "{", + "close": "}", + "token": "delimiter.curly" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "constants": [ + "false", + "null", + "true" + ], + "globalFunctions": [ + "ASCII", + "Abs", + "Add", + "Age", + "Alpha", + "AlphaNum", + "Append", + "Assert", + "Base", + "CSV", + "CSVByColumn", + "CSVByRow", + "Ceil", + "Clean", + "Contains", + "ContainsCIDR", + "Dict", + "Dir", + "Div", + "Duration", + "Ext", + "Fail", + "Find", + "FindAll", + "Float", + "Floor", + "FromSlash", + "GetHealth", + "GetStatus", + "Has", + "HashUUID", + "Hour", + "HumanDuration", + "HumanSize", + "InTimeRange", + "IsAbs", + "IsFloat", + "IsHealthy", + "IsInt", + "IsKind", + "IsNum", + "IsReady", + "IsValid", + "IsValidIP", + "Item", + "Join", + "Kind", + "Match", + "Microsecond", + "Millisecond", + "Minute", + "Mul", + "Nanosecond", + "Nil", + "Now", + "Number", + "Parse", + "ParseDateTime", + "ParseDuration", + "ParseInLocation", + "ParseLocal", + "Pow", + "Prepend", + "QuoteMeta", + "Rel", + "Rem", + "Replace", + "ReplaceLiteral", + "Required", + "Round", + "SHA1", + "SHA1Bytes", + "SHA224", + "SHA224Bytes", + "SHA256", + "SHA256Bytes", + "SHA384", + "SHA384Bytes", + "SHA512", + "SHA512Bytes", + "SHA512_224", + "SHA512_224Bytes", + "SHA512_256", + "SHA512_256Bytes", + "Second", + "Semver", + "SemverCompare", + "Seq", + "Since", + "Sort", + "Split", + "SplitN", + "String", + "Sub", + "TOML", + "Ternary", + "ToSlash", + "Unix", + "Until", + "V1", + "V4", + "VolumeName", + "YAML", + "YAMLArray", + "ZoneName", + "ZoneOffset", + "abs", + "age", + "arnToMap", + "bitAnd", + "bitNot", + "bitOr", + "bitShiftLeft", + "bitShiftRight", + "bitXor", + "bool", + "bytes", + "ceil", + "coalesce", + "contains", + "containsFloat", + "cpuAsMillicores", + "debug", + "decode", + "double", + "duration", + "dyn", + "encode", + "equivalent", + "f", + "first", + "floor", + "fromAWSMap", + "getHealth", + "getResourcesLimit", + "getResourcesRequests", + "getStatus", + "in", + "in_business_hours", + "int", + "intersects", + "isFinite", + "isHealthy", + "isInf", + "isNaN", + "isReady", + "isURL", + "is_healthy", + "jmespath", + "jq", + "jsonpath", + "keyValToMap", + "labels", + "last", + "mapToKeyVal", + "matchLabel", + "matches", + "memoryAsBytes", + "merge", + "neat", + "nodeProperties", + "none", + "of", + "ofNonZeroValue", + "podProperties", + "quote", + "range", + "round", + "sign", + "size", + "sqrt", + "string", + "timestamp", + "toCSV", + "toTOML", + "toYAML", + "trunc", + "type", + "uint", + "url", + "urldecode", + "urlencode", + "xpath" + ], + "keywords": [ + "as", + "break", + "const", + "continue", + "else", + "for", + "function", + "if", + "import", + "in", + "let", + "loop", + "namespace", + "package", + "return", + "type", + "var", + "void", + "while" + ], + "macros": [ + "all", + "exists", + "exists_one", + "filter", + "fold", + "greatest", + "has", + "least", + "map", + "optFlatMap", + "optMap", + "sortBy" + ], + "memberFunctions": [ + "JSON", + "JSONArray", + "abbrev", + "camelCase", + "charAt", + "contains", + "distinct", + "endsWith", + "find", + "findAll", + "flatten", + "format", + "getDate", + "getDayOfMonth", + "getDayOfWeek", + "getDayOfYear", + "getEscapedPath", + "getFullYear", + "getHost", + "getHostname", + "getHours", + "getMilliseconds", + "getMinutes", + "getMonth", + "getPort", + "getQuery", + "getScheme", + "getSeconds", + "hasValue", + "indent", + "indexOf", + "isSorted", + "join", + "kebabCase", + "keys", + "lastIndexOf", + "lowerAscii", + "match", + "max", + "min", + "omit", + "or", + "orValue", + "pick", + "quote", + "repeat", + "replace", + "replaceAll", + "replaceAllRegex", + "reverse", + "runeCount", + "shellQuote", + "slice", + "slug", + "snakeCase", + "sort", + "sortBy", + "split", + "splitRegex", + "squote", + "startsWith", + "substring", + "sum", + "title", + "toJSON", + "toJSONPretty", + "toLower", + "toUpper", + "trim", + "trimPrefix", + "trimSpace", + "trimSuffix", + "trunc", + "uniq", + "upperAscii", + "value", + "values", + "wordWrap" + ], + "namespaces": [ + "aws", + "base64", + "crypto", + "data", + "filepath", + "json", + "k8s", + "lists", + "math", + "net", + "optional", + "random", + "regexp", + "sets", + "strings", + "test", + "time", + "uuid" + ], + "operators": [ + "!", + "!=", + "%", + "\u0026\u0026", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + ":", + "\u003c", + "\u003c=", + "==", + "\u003e", + "\u003e=", + "?", + "[", + "]", + "{", + "||", + "}" + ], + "typeKeywords": [ + "bool", + "bytes", + "double", + "duration", + "dyn", + "int", + "list", + "map", + "null_type", + "string", + "timestamp", + "type", + "uint" + ], + "tokenizer": { + "root": [ + { + "include": "@whitespace" + }, + [ + "[bB](?:[rR]\"\"\"[\\s\\S]*?\"\"\"|[rR]'''[\\s\\S]*?'''|\"\"\"(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?\"\"\"|'''(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?'''|[rR]\"[^\"\\n\\r]*\"|[rR]'[^'\\n\\r]*'|\"(?:[^\\\\\"\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*\"|'(?:[^\\\\'\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*')", + "string.bytes" + ], + [ + "(?:[rR]\"\"\"[\\s\\S]*?\"\"\"|[rR]'''[\\s\\S]*?'''|\"\"\"(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?\"\"\"|'''(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?'''|[rR]\"[^\"\\n\\r]*\"|[rR]'[^'\\n\\r]*'|\"(?:[^\\\\\"\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*\"|'(?:[^\\\\'\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*')", + "string" + ], + [ + "`[A-Za-z0-9_.\\-/ ]+`", + "identifier.escaped" + ], + [ + "(?:\\.[0-9]+(?:[eE][+\\-]?[0-9]+)?|[0-9]+\\.[0-9]+(?:[eE][+\\-]?[0-9]+)?|[0-9]+[eE][+\\-]?[0-9]+)", + "number.float" + ], + [ + "(?:0x[0-9a-fA-F]+[uU]|[0-9]+[uU])", + "number.uint" + ], + [ + "(?:0x[0-9a-fA-F]+|[0-9]+)", + "number" + ], + [ + "(\\.\\?)([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", + { + "cases": { + "$2@macros": [ + "operator.optional", + "keyword.macro" + ], + "$2@memberFunctions": [ + "operator.optional", + "function.member" + ], + "$2@globalFunctions": [ + "operator.optional", + "function" + ], + "@default": [ + "operator.optional", + "variable.field" + ] + } + } + ], + [ + "(\\.\\?)([A-Za-z_][A-Za-z0-9_]*)", + [ + "operator.optional", + "variable.field" + ] + ], + [ + "\\.\\?", + "operator.optional" + ], + [ + "\\[\\?", + "operator.optional" + ], + [ + "\\?\\.", + "operator.optional" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "$3@macros": [ + "identifier", + "delimiter", + "keyword.macro" + ], + "$3@memberFunctions": [ + "identifier", + "delimiter", + "function.member" + ], + "$3@globalFunctions": [ + "identifier", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", + { + "cases": { + "$2@macros": [ + "delimiter", + "keyword.macro" + ], + "$2@memberFunctions": [ + "delimiter", + "function.member" + ], + "$2@globalFunctions": [ + "delimiter", + "function" + ], + "@default": [ + "delimiter", + "variable.field" + ] + } + } + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", + { + "cases": { + "$1@macros": "keyword.macro", + "$1@globalFunctions": "function", + "$1@keywords": "keyword", + "@default": "identifier" + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@constants": "keyword.constant", + "@keywords": "keyword", + "@typeKeywords": "type", + "@default": "identifier" + } + } + ], + [ + "[{}()\\[\\]]", + "@brackets" + ], + [ + "(?:!=|\u0026\u0026|\u003c=|==|\u003e=|\\|\\||!|%|\\*|\\+|,|-|\\.|\\/|:|\u003c|\u003e|\\?)", + "operator" + ], + [ + "[,;]", + "delimiter" + ] + ], + "whitespace": [ + [ + "[\\t \\r\\n\\f]+", + "white" + ], + [ + "//[^\\n]*", + "comment" + ] + ] + } + }, + "configuration": { + "comments": { + "lineComment": "//", + "blockComment": [ + "", + "" + ] + }, + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ], + [ + "(", + ")" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "surroundingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" + } + }, + "gomplate": { + "id": "gomplate", + "monarch": { + "defaultToken": "source", + "tokenPostfix": ".gomplate", + "brackets": [ + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + } + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "functions": [ + "add", + "append", + "assert", + "bool", + "coalesce", + "contains", + "csv", + "csvByColumn", + "csvByRow", + "default", + "dict", + "div", + "endsWith", + "fail", + "first", + "flatten", + "getHealth", + "getStatus", + "has", + "hasPrefix", + "hasSuffix", + "humanDuration", + "humanSize", + "in_business_hours", + "indent", + "isHealthy", + "isKind", + "isReady", + "jmespath", + "join", + "jq", + "json", + "jsonArray", + "jsonpath", + "keyValToMap", + "keys", + "kind", + "last", + "mapToKeyVal", + "matchLabel", + "merge", + "mul", + "neat", + "parseDateTime", + "pow", + "prepend", + "quote", + "rem", + "replaceAll", + "required", + "reverse", + "semver", + "semverCompare", + "seq", + "shellQuote", + "slice", + "sort", + "split", + "splitN", + "squote", + "startsWith", + "sub", + "ternary", + "title", + "toCSV", + "toJSON", + "toJSONPretty", + "toLower", + "toTOML", + "toUpper", + "toYAML", + "toml", + "trim", + "uniq", + "urlParse", + "urldecode", + "urlencode", + "values", + "xpath", + "yaml", + "yamlArray" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "tokenizer": { + "root": [ + [ + "^#\\s*gotemplate:.*$", + "comment.directive" + ], + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "[\\s\\S]", + "source" + ] + ], + "tmplComment": [ + [ + "\\*\\/-?\\}\\}", + { + "next": "@pop", + "token": "comment" + } + ], + [ + "[\\s\\S]", + "comment" + ] + ], + "tmplAction": [ + [ + "-?\\}\\}", + { + "next": "@pop", + "token": "delimiter.template" + } + ], + [ + "[ \\t\\r\\n]+", + "white" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "`[^`]*`", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", + "number" + ], + [ + "\\$[A-Za-z_][A-Za-z0-9_]*", + "variable" + ], + [ + "\\$", + "variable" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "variable.field" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@keywords": "keyword", + "@builtins": "function.builtin", + "@functions": "function", + "@default": "identifier" + } + } + ], + [ + "[()\\[\\]]", + "@brackets" + ], + [ + "\\|", + "operator.pipe" + ], + [ + ":=|=", + "operator" + ], + [ + ",", + "delimiter" + ] + ] + } + }, + "configuration": { + "comments": { + "blockComment": [ + "{{/*", + "*/}}" + ] + }, + "brackets": [ + [ + "{{", + "}}" + ], + [ + "(", + ")" + ], + [ + "[", + "]" + ] + ], + "autoClosingPairs": [ + { + "open": "{{", + "close": " }}" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "`", + "close": "`" + } + ], + "surroundingPairs": [ + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "`", + "close": "`" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" + } + }, + "json-gomplate": { + "id": "json-gomplate", + "monarch": { + "defaultToken": "", + "tokenPostfix": ".json-gomplate", + "brackets": [ + { + "open": "{", + "close": "}", + "token": "delimiter.curly" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "functions": [ + "add", + "append", + "assert", + "bool", + "coalesce", + "contains", + "csv", + "csvByColumn", + "csvByRow", + "default", + "dict", + "div", + "endsWith", + "fail", + "first", + "flatten", + "getHealth", + "getStatus", + "has", + "hasPrefix", + "hasSuffix", + "humanDuration", + "humanSize", + "in_business_hours", + "indent", + "isHealthy", + "isKind", + "isReady", + "jmespath", + "join", + "jq", + "json", + "jsonArray", + "jsonpath", + "keyValToMap", + "keys", + "kind", + "last", + "mapToKeyVal", + "matchLabel", + "merge", + "mul", + "neat", + "parseDateTime", + "pow", + "prepend", + "quote", + "rem", + "replaceAll", + "required", + "reverse", + "semver", + "semverCompare", + "seq", + "shellQuote", + "slice", + "sort", + "split", + "splitN", + "squote", + "startsWith", + "sub", + "ternary", + "title", + "toCSV", + "toJSON", + "toJSONPretty", + "toLower", + "toTOML", + "toUpper", + "toYAML", + "toml", + "trim", + "uniq", + "urlParse", + "urldecode", + "urlencode", + "values", + "xpath", + "yaml", + "yamlArray" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "tokenizer": { + "root": [ + [ + "^#\\s*gotemplate:.*$", + "comment.directive" + ], + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "(\"(?:[^\"\\\\{]|\\\\.)*\")(\\s*)(:)", + [ + "type.json", + "white", + "delimiter" + ] + ], + [ + "\"", + { + "next": "@jsonString", + "token": "string" + } + ], + [ + "\\b(?:true|false|null)\\b", + "keyword.constant" + ], + [ + "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?", + "number" + ], + [ + "[{}\\[\\]]", + "@brackets" + ], + [ + "[,:]", + "delimiter" + ], + [ + "[\\s\\S]", + "" + ] + ], + "jsonString": [ + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "\"", + { + "next": "@pop", + "token": "string" + } + ], + [ + "\\\\.", + "string.escape" + ], + [ + "[^\"\\\\{]+", + "string" + ], + [ + "[\\s\\S]", + "string" + ] + ], + "tmplComment": [ + [ + "\\*\\/-?\\}\\}", + { + "next": "@pop", + "token": "comment" + } + ], + [ + "[\\s\\S]", + "comment" + ] + ], + "tmplAction": [ + [ + "-?\\}\\}", + { + "next": "@pop", + "token": "delimiter.template" + } + ], + [ + "[ \\t\\r\\n]+", + "white" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "`[^`]*`", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", + "number" + ], + [ + "\\$[A-Za-z_][A-Za-z0-9_]*", + "variable" + ], + [ + "\\$", + "variable" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "variable.field" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@keywords": "keyword", + "@builtins": "function.builtin", + "@functions": "function", + "@default": "identifier" + } + } + ], + [ + "[()\\[\\]]", + "@brackets" + ], + [ + "\\|", + "operator.pipe" + ], + [ + ":=|=", + "operator" + ], + [ + ",", + "delimiter" + ] + ] + } + }, + "configuration": { + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" + } + }, + "jsonpath": { + "id": "jsonpath", + "monarch": { + "defaultToken": "", + "tokenPostfix": ".jsonpath", + "brackets": [ + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "filterOperators": [ + "!", + "!=", + "\u0026\u0026", + "*", + "+", + "-", + "/", + "\u003c", + "\u003c=", + "==", + "=~", + "\u003e", + "\u003e=", + "||" + ], + "tokenizer": { + "root": [ + [ + "\\$", + "variable.root" + ], + [ + "@", + "variable.current" + ], + [ + "\\.\\.", + "operator.descendant" + ], + [ + "\\*", + "operator.wildcard" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "delimiter" + ], + [ + "\\?\\(", + "keyword.filter" + ], + [ + "[\\[\\]()]", + "@brackets" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "\\b(?:true|false|null)\\b", + "keyword.constant" + ], + [ + "-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?", + "number" + ], + [ + "(?:==|!=|\u003c=|\u003e=|\u0026\u0026|\\|\\||=~|[\u003c\u003e!+\\-*/])", + "operator" + ], + [ + ":", + "operator.slice" + ], + [ + ",", + "delimiter" + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + "variable.field" + ] + ] + } + }, + "configuration": { + "brackets": [ + [ + "[", + "]" + ], + [ + "(", + ")" + ] + ], + "autoClosingPairs": [ + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*" + } + }, + "text-gomplate": { + "id": "text-gomplate", + "monarch": { + "defaultToken": "", + "tokenPostfix": ".text-gomplate", + "brackets": [ + { + "open": "{", + "close": "}", + "token": "delimiter.curly" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "functions": [ + "add", + "append", + "assert", + "bool", + "coalesce", + "contains", + "csv", + "csvByColumn", + "csvByRow", + "default", + "dict", + "div", + "endsWith", + "fail", + "first", + "flatten", + "getHealth", + "getStatus", + "has", + "hasPrefix", + "hasSuffix", + "humanDuration", + "humanSize", + "in_business_hours", + "indent", + "isHealthy", + "isKind", + "isReady", + "jmespath", + "join", + "jq", + "json", + "jsonArray", + "jsonpath", + "keyValToMap", + "keys", + "kind", + "last", + "mapToKeyVal", + "matchLabel", + "merge", + "mul", + "neat", + "parseDateTime", + "pow", + "prepend", + "quote", + "rem", + "replaceAll", + "required", + "reverse", + "semver", + "semverCompare", + "seq", + "shellQuote", + "slice", + "sort", + "split", + "splitN", + "squote", + "startsWith", + "sub", + "ternary", + "title", + "toCSV", + "toJSON", + "toJSONPretty", + "toLower", + "toTOML", + "toUpper", + "toYAML", + "toml", + "trim", + "uniq", + "urlParse", + "urldecode", + "urlencode", + "values", + "xpath", + "yaml", + "yamlArray" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "tokenizer": { + "root": [ + [ + "^#\\s*gotemplate:.*$", + "comment.directive" + ], + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "[\\s\\S]", + "source" + ] + ], + "tmplComment": [ + [ + "\\*\\/-?\\}\\}", + { + "next": "@pop", + "token": "comment" + } + ], + [ + "[\\s\\S]", + "comment" + ] + ], + "tmplAction": [ + [ + "-?\\}\\}", + { + "next": "@pop", + "token": "delimiter.template" + } + ], + [ + "[ \\t\\r\\n]+", + "white" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "`[^`]*`", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", + "number" + ], + [ + "\\$[A-Za-z_][A-Za-z0-9_]*", + "variable" + ], + [ + "\\$", + "variable" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "variable.field" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@keywords": "keyword", + "@builtins": "function.builtin", + "@functions": "function", + "@default": "identifier" + } + } + ], + [ + "[()\\[\\]]", + "@brackets" + ], + [ + "\\|", + "operator.pipe" + ], + [ + ":=|=", + "operator" + ], + [ + ",", + "delimiter" + ] + ] + } + }, + "configuration": { + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" + } + }, + "yaml-gomplate": { + "id": "yaml-gomplate", + "monarch": { + "defaultToken": "", + "tokenPostfix": ".yaml-gomplate", + "brackets": [ + { + "open": "{", + "close": "}", + "token": "delimiter.curly" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "functions": [ + "add", + "append", + "assert", + "bool", + "coalesce", + "contains", + "csv", + "csvByColumn", + "csvByRow", + "default", + "dict", + "div", + "endsWith", + "fail", + "first", + "flatten", + "getHealth", + "getStatus", + "has", + "hasPrefix", + "hasSuffix", + "humanDuration", + "humanSize", + "in_business_hours", + "indent", + "isHealthy", + "isKind", + "isReady", + "jmespath", + "join", + "jq", + "json", + "jsonArray", + "jsonpath", + "keyValToMap", + "keys", + "kind", + "last", + "mapToKeyVal", + "matchLabel", + "merge", + "mul", + "neat", + "parseDateTime", + "pow", + "prepend", + "quote", + "rem", + "replaceAll", + "required", + "reverse", + "semver", + "semverCompare", + "seq", + "shellQuote", + "slice", + "sort", + "split", + "splitN", + "squote", + "startsWith", + "sub", + "ternary", + "title", + "toCSV", + "toJSON", + "toJSONPretty", + "toLower", + "toTOML", + "toUpper", + "toYAML", + "toml", + "trim", + "uniq", + "urlParse", + "urldecode", + "urlencode", + "values", + "xpath", + "yaml", + "yamlArray" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "tokenizer": { + "root": [ + [ + "^#\\s*gotemplate:.*$", + "comment.directive" + ], + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "#.*$", + "comment" + ], + [ + "^---\\s*$", + "keyword.directive" + ], + [ + "^\\.\\.\\.\\s*$", + "keyword.directive" + ], + [ + "^(\\s*)(-\\s+)([^-\\s#\"'][^:#]*?)(\\s*)(:)(?=\\s|$)", + [ + "white", + "delimiter.list", + "type.yaml", + "white", + "delimiter" + ] + ], + [ + "^(\\s*)([^-\\s#\"'][^:#]*?)(\\s*)(:)(?=\\s|$)", + [ + "white", + "type.yaml", + "white", + "delimiter" + ] + ], + [ + "^\\s*-\\s", + "delimiter.list" + ], + [ + "[\u0026*][A-Za-z0-9_-]+", + "variable.anchor" + ], + [ + "!!?[A-Za-z0-9_/-]*", + "type" + ], + [ + "[|\u003e][-+]?", + "keyword.scalar" + ], + [ + "\"", + { + "next": "@yamlDouble", + "token": "string" + } + ], + [ + "'", + { + "next": "@yamlSingle", + "token": "string" + } + ], + [ + "\\b(?:true|false|null|~|yes|no|on|off)\\b", + "keyword.constant" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)\\b", + "number" + ], + [ + "[{}\\[\\]]", + "@brackets" + ], + [ + ",", + "delimiter" + ], + [ + "[\\s\\S]", + "" + ] + ], + "yamlDouble": [ + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "\"", + { + "next": "@pop", + "token": "string" + } + ], + [ + "\\\\.", + "string.escape" + ], + [ + "[^\"\\\\{]+", + "string" + ], + [ + "[\\s\\S]", + "string" + ] + ], + "yamlSingle": [ + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "'", + { + "next": "@pop", + "token": "string" + } + ], + [ + "\\\\.", + "string.escape" + ], + [ + "[^'\\\\{]+", + "string" + ], + [ + "[\\s\\S]", + "string" + ] + ], + "tmplComment": [ + [ + "\\*\\/-?\\}\\}", + { + "next": "@pop", + "token": "comment" + } + ], + [ + "[\\s\\S]", + "comment" + ] + ], + "tmplAction": [ + [ + "-?\\}\\}", + { + "next": "@pop", + "token": "delimiter.template" + } + ], + [ + "[ \\t\\r\\n]+", + "white" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "`[^`]*`", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", + "number" + ], + [ + "\\$[A-Za-z_][A-Za-z0-9_]*", + "variable" + ], + [ + "\\$", + "variable" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "variable.field" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@keywords": "keyword", + "@builtins": "function.builtin", + "@functions": "function", + "@default": "identifier" + } + } + ], + [ + "[()\\[\\]]", + "@brackets" + ], + [ + "\\|", + "operator.pipe" + ], + [ + ":=|=", + "operator" + ], + [ + ",", + "delimiter" + ] + ] + } + }, + "configuration": { + "comments": { + "lineComment": "#", + "blockComment": [ + "", + "" + ] + }, + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" + } + } +} diff --git a/web/packages/lang/src/generated/text-gomplate.config.json b/web/packages/lang/src/generated/text-gomplate.config.json deleted file mode 100644 index c087049ba..000000000 --- a/web/packages/lang/src/generated/text-gomplate.config.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "brackets": [ - [ - "{", - "}" - ], - [ - "[", - "]" - ] - ], - "autoClosingPairs": [ - { - "open": "{", - "close": "}" - }, - { - "open": "[", - "close": "]" - }, - { - "open": "\"", - "close": "\"" - }, - { - "open": "'", - "close": "'" - } - ], - "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" -} diff --git a/web/packages/lang/src/generated/text-gomplate.monarch.json b/web/packages/lang/src/generated/text-gomplate.monarch.json deleted file mode 100644 index 829720674..000000000 --- a/web/packages/lang/src/generated/text-gomplate.monarch.json +++ /dev/null @@ -1,287 +0,0 @@ -{ - "defaultToken": "", - "tokenPostfix": ".text-gomplate", - "brackets": [ - { - "open": "{", - "close": "}", - "token": "delimiter.curly" - }, - { - "open": "[", - "close": "]", - "token": "delimiter.square" - }, - { - "open": "(", - "close": ")", - "token": "delimiter.parenthesis" - } - ], - "builtins": [ - "and", - "call", - "eq", - "ge", - "gt", - "html", - "index", - "js", - "le", - "len", - "lt", - "ne", - "not", - "or", - "print", - "printf", - "println", - "slice", - "urlquery" - ], - "functions": [ - "add", - "append", - "assert", - "bool", - "coalesce", - "contains", - "csv", - "csvByColumn", - "csvByRow", - "default", - "dict", - "div", - "endsWith", - "fail", - "first", - "flatten", - "getHealth", - "getStatus", - "has", - "hasPrefix", - "hasSuffix", - "humanDuration", - "humanSize", - "in_business_hours", - "indent", - "isHealthy", - "isKind", - "isReady", - "jmespath", - "join", - "jq", - "json", - "jsonArray", - "jsonpath", - "keyValToMap", - "keys", - "kind", - "last", - "mapToKeyVal", - "matchLabel", - "merge", - "mul", - "neat", - "parseDateTime", - "pow", - "prepend", - "quote", - "rem", - "replaceAll", - "required", - "reverse", - "semver", - "semverCompare", - "seq", - "shellQuote", - "slice", - "sort", - "split", - "splitN", - "squote", - "startsWith", - "sub", - "ternary", - "title", - "toCSV", - "toJSON", - "toJSONPretty", - "toLower", - "toTOML", - "toUpper", - "toYAML", - "toml", - "trim", - "uniq", - "urlParse", - "urldecode", - "urlencode", - "values", - "xpath", - "yaml", - "yamlArray" - ], - "keywords": [ - "block", - "break", - "continue", - "define", - "else", - "end", - "if", - "nil", - "range", - "template", - "with" - ], - "namespaces": [ - "base64", - "coll", - "conv", - "crypto", - "data", - "filepath", - "k8s", - "math", - "net", - "path", - "random", - "regexp", - "strings", - "test", - "time", - "uuid" - ], - "tokenizer": { - "root": [ - [ - "^#\\s*gotemplate:.*$", - "comment.directive" - ], - [ - "\\{\\{-?\\/\\*", - { - "next": "@tmplComment", - "token": "comment" - } - ], - [ - "\\{\\{-?", - { - "next": "@tmplAction", - "token": "delimiter.template" - } - ], - [ - "[\\s\\S]", - "source" - ] - ], - "tmplComment": [ - [ - "\\*\\/-?\\}\\}", - { - "next": "@pop", - "token": "comment" - } - ], - [ - "[\\s\\S]", - "comment" - ] - ], - "tmplAction": [ - [ - "-?\\}\\}", - { - "next": "@pop", - "token": "delimiter.template" - } - ], - [ - "[ \\t\\r\\n]+", - "white" - ], - [ - "\"(?:[^\"\\\\]|\\\\.)*\"", - "string" - ], - [ - "`[^`]*`", - "string" - ], - [ - "'(?:[^'\\\\]|\\\\.)*'", - "string" - ], - [ - "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", - "number" - ], - [ - "\\$[A-Za-z_][A-Za-z0-9_]*", - "variable" - ], - [ - "\\$", - "variable" - ], - [ - "(\\.)([A-Za-z_][A-Za-z0-9_]*)", - [ - "delimiter", - "variable.field" - ] - ], - [ - "\\.", - "variable.field" - ], - [ - "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", - { - "cases": { - "$1@namespaces": [ - "namespace", - "delimiter", - "function" - ], - "@default": [ - "identifier", - "delimiter", - "identifier" - ] - } - } - ], - [ - "[A-Za-z_][A-Za-z0-9_]*", - { - "cases": { - "@keywords": "keyword", - "@builtins": "function.builtin", - "@functions": "function", - "@default": "identifier" - } - } - ], - [ - "[()\\[\\]]", - "@brackets" - ], - [ - "\\|", - "operator.pipe" - ], - [ - ":=|=", - "operator" - ], - [ - ",", - "delimiter" - ] - ] - } -} diff --git a/web/packages/lang/src/generated/yaml-gomplate.config.json b/web/packages/lang/src/generated/yaml-gomplate.config.json deleted file mode 100644 index 6f112c25c..000000000 --- a/web/packages/lang/src/generated/yaml-gomplate.config.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "comments": { - "lineComment": "#", - "blockComment": [ - "", - "" - ] - }, - "brackets": [ - [ - "{", - "}" - ], - [ - "[", - "]" - ] - ], - "autoClosingPairs": [ - { - "open": "{", - "close": "}" - }, - { - "open": "[", - "close": "]" - }, - { - "open": "\"", - "close": "\"" - }, - { - "open": "'", - "close": "'" - } - ], - "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" -} diff --git a/web/packages/lang/src/generated/yaml-gomplate.monarch.json b/web/packages/lang/src/generated/yaml-gomplate.monarch.json deleted file mode 100644 index 79a7c9908..000000000 --- a/web/packages/lang/src/generated/yaml-gomplate.monarch.json +++ /dev/null @@ -1,434 +0,0 @@ -{ - "defaultToken": "", - "tokenPostfix": ".yaml-gomplate", - "brackets": [ - { - "open": "{", - "close": "}", - "token": "delimiter.curly" - }, - { - "open": "[", - "close": "]", - "token": "delimiter.square" - }, - { - "open": "(", - "close": ")", - "token": "delimiter.parenthesis" - } - ], - "builtins": [ - "and", - "call", - "eq", - "ge", - "gt", - "html", - "index", - "js", - "le", - "len", - "lt", - "ne", - "not", - "or", - "print", - "printf", - "println", - "slice", - "urlquery" - ], - "functions": [ - "add", - "append", - "assert", - "bool", - "coalesce", - "contains", - "csv", - "csvByColumn", - "csvByRow", - "default", - "dict", - "div", - "endsWith", - "fail", - "first", - "flatten", - "getHealth", - "getStatus", - "has", - "hasPrefix", - "hasSuffix", - "humanDuration", - "humanSize", - "in_business_hours", - "indent", - "isHealthy", - "isKind", - "isReady", - "jmespath", - "join", - "jq", - "json", - "jsonArray", - "jsonpath", - "keyValToMap", - "keys", - "kind", - "last", - "mapToKeyVal", - "matchLabel", - "merge", - "mul", - "neat", - "parseDateTime", - "pow", - "prepend", - "quote", - "rem", - "replaceAll", - "required", - "reverse", - "semver", - "semverCompare", - "seq", - "shellQuote", - "slice", - "sort", - "split", - "splitN", - "squote", - "startsWith", - "sub", - "ternary", - "title", - "toCSV", - "toJSON", - "toJSONPretty", - "toLower", - "toTOML", - "toUpper", - "toYAML", - "toml", - "trim", - "uniq", - "urlParse", - "urldecode", - "urlencode", - "values", - "xpath", - "yaml", - "yamlArray" - ], - "keywords": [ - "block", - "break", - "continue", - "define", - "else", - "end", - "if", - "nil", - "range", - "template", - "with" - ], - "namespaces": [ - "base64", - "coll", - "conv", - "crypto", - "data", - "filepath", - "k8s", - "math", - "net", - "path", - "random", - "regexp", - "strings", - "test", - "time", - "uuid" - ], - "tokenizer": { - "root": [ - [ - "^#\\s*gotemplate:.*$", - "comment.directive" - ], - [ - "\\{\\{-?\\/\\*", - { - "next": "@tmplComment", - "token": "comment" - } - ], - [ - "\\{\\{-?", - { - "next": "@tmplAction", - "token": "delimiter.template" - } - ], - [ - "#.*$", - "comment" - ], - [ - "^---\\s*$", - "keyword.directive" - ], - [ - "^\\.\\.\\.\\s*$", - "keyword.directive" - ], - [ - "^(\\s*)(-\\s+)([^-\\s#\"'][^:#]*?)(\\s*)(:)(?=\\s|$)", - [ - "white", - "delimiter.list", - "type.yaml", - "white", - "delimiter" - ] - ], - [ - "^(\\s*)([^-\\s#\"'][^:#]*?)(\\s*)(:)(?=\\s|$)", - [ - "white", - "type.yaml", - "white", - "delimiter" - ] - ], - [ - "^\\s*-\\s", - "delimiter.list" - ], - [ - "[\u0026*][A-Za-z0-9_-]+", - "variable.anchor" - ], - [ - "!!?[A-Za-z0-9_/-]*", - "type" - ], - [ - "[|\u003e][-+]?", - "keyword.scalar" - ], - [ - "\"", - { - "next": "@yamlDouble", - "token": "string" - } - ], - [ - "'", - { - "next": "@yamlSingle", - "token": "string" - } - ], - [ - "\\b(?:true|false|null|~|yes|no|on|off)\\b", - "keyword.constant" - ], - [ - "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)\\b", - "number" - ], - [ - "[{}\\[\\]]", - "@brackets" - ], - [ - ",", - "delimiter" - ], - [ - "[\\s\\S]", - "" - ] - ], - "yamlDouble": [ - [ - "\\{\\{-?\\/\\*", - { - "next": "@tmplComment", - "token": "comment" - } - ], - [ - "\\{\\{-?", - { - "next": "@tmplAction", - "token": "delimiter.template" - } - ], - [ - "\"", - { - "next": "@pop", - "token": "string" - } - ], - [ - "\\\\.", - "string.escape" - ], - [ - "[^\"\\\\{]+", - "string" - ], - [ - "[\\s\\S]", - "string" - ] - ], - "yamlSingle": [ - [ - "\\{\\{-?\\/\\*", - { - "next": "@tmplComment", - "token": "comment" - } - ], - [ - "\\{\\{-?", - { - "next": "@tmplAction", - "token": "delimiter.template" - } - ], - [ - "'", - { - "next": "@pop", - "token": "string" - } - ], - [ - "\\\\.", - "string.escape" - ], - [ - "[^'\\\\{]+", - "string" - ], - [ - "[\\s\\S]", - "string" - ] - ], - "tmplComment": [ - [ - "\\*\\/-?\\}\\}", - { - "next": "@pop", - "token": "comment" - } - ], - [ - "[\\s\\S]", - "comment" - ] - ], - "tmplAction": [ - [ - "-?\\}\\}", - { - "next": "@pop", - "token": "delimiter.template" - } - ], - [ - "[ \\t\\r\\n]+", - "white" - ], - [ - "\"(?:[^\"\\\\]|\\\\.)*\"", - "string" - ], - [ - "`[^`]*`", - "string" - ], - [ - "'(?:[^'\\\\]|\\\\.)*'", - "string" - ], - [ - "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", - "number" - ], - [ - "\\$[A-Za-z_][A-Za-z0-9_]*", - "variable" - ], - [ - "\\$", - "variable" - ], - [ - "(\\.)([A-Za-z_][A-Za-z0-9_]*)", - [ - "delimiter", - "variable.field" - ] - ], - [ - "\\.", - "variable.field" - ], - [ - "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", - { - "cases": { - "$1@namespaces": [ - "namespace", - "delimiter", - "function" - ], - "@default": [ - "identifier", - "delimiter", - "identifier" - ] - } - } - ], - [ - "[A-Za-z_][A-Za-z0-9_]*", - { - "cases": { - "@keywords": "keyword", - "@builtins": "function.builtin", - "@functions": "function", - "@default": "identifier" - } - } - ], - [ - "[()\\[\\]]", - "@brackets" - ], - [ - "\\|", - "operator.pipe" - ], - [ - ":=|=", - "operator" - ], - [ - ",", - "delimiter" - ] - ] - } -} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 000000000..96f252c03 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,2999 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + '@tailwindcss/vite': + specifier: ^4.1.13 + version: 4.3.3 + '@types/node': + specifier: ^22.10.5 + version: 22.20.1 + '@types/react': + specifier: ^19.0.7 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.2.4 + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0 + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + monaco-editor: + specifier: 0.48.0 + version: 0.48.0 + react: + specifier: ^19.0.0 + version: 19.2.8 + react-dom: + specifier: ^19.0.0 + version: 19.2.8 + tailwindcss: + specifier: ^4.1.13 + version: 4.3.3 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.3 + vitest: + specifier: ^3.2.6 + version: 3.2.7 + yaml: + specifier: ^2.8.3 + version: 2.9.0 + +importers: + + .: {} + + apps/playground: + dependencies: + '@flanksource/clicky-ui': + specifier: ^0.3.19 + version: 0.3.20(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.18)(monaco-editor@0.48.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1))(tailwindcss@4.3.3) + '@flanksource/gomplate-lang': + specifier: workspace:* + version: link:../../packages/lang + monaco-editor: + specifier: 'catalog:' + version: 0.48.0 + react: + specifier: 'catalog:' + version: 19.2.8 + react-dom: + specifier: 'catalog:' + version: 19.2.8(react@19.2.8) + yaml: + specifier: 'catalog:' + version: 2.9.0 + devDependencies: + '@tailwindcss/vite': + specifier: 'catalog:' + version: 4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + '@types/node': + specifier: 'catalog:' + version: 22.20.1 + '@types/react': + specifier: 'catalog:' + version: 19.2.18 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 4.7.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + tailwindcss: + specifier: 'catalog:' + version: 4.3.3 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: 'catalog:' + version: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.9.0) + + packages/lang: + devDependencies: + jsdom: + specifier: 'catalog:' + version: 26.1.0 + monaco-editor: + specifier: 'catalog:' + version: 0.48.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: 'catalog:' + version: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.9.0) + +packages: + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.10.4': + resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + + '@codemirror/view@6.43.8': + resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@flanksource/clicky-ui@0.3.20': + resolution: {integrity: sha512-FByvZ67fymsUNVtO3HvbpUmF9XzNj1cC9rQtY2wmMmmOHaG2xLgyzmUE1zaOB2oVYUwhhbpj6XQKCeHZxZ0vsw==} + peerDependencies: + '@ai-sdk/react': ^3.0.0 + '@mdxeditor/editor': ^4.0.4 + '@shikijs/langs': ^1.24.0 + '@shikijs/themes': ^1.24.0 + '@shikijs/transformers': ^1.24.0 + ai: ^6.0.0 + marked: ^15.0.0 + monaco-editor: '>=0.48 <1' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-rnd: ^10.5.0 + recharts: ^3.0.0 + shiki: ^1.24.0 + streamdown: ^2.5.0 + tailwindcss: ^3.4.0 || ^4.0.0 + peerDependenciesMeta: + '@ai-sdk/react': + optional: true + '@mdxeditor/editor': + optional: true + '@shikijs/langs': + optional: true + '@shikijs/themes': + optional: true + '@shikijs/transformers': + optional: true + ai: + optional: true + marked: + optional: true + react-rnd: + optional: true + shiki: + optional: true + streamdown: + optional: true + + '@flanksource/icons@1.0.63': + resolution: {integrity: sha512-QJ3N49jltwT4xJVvajr/6V++LqTEYKGamjtLA+2NNECDG8I/guhgH3l984rIoE4+dkYTrUPwvoUh4SiKLic8FQ==} + peerDependencies: + react: '*' + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.20': + resolution: {integrity: sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@marijn/find-cluster-break@1.0.3': + resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} + + '@tanstack/react-query@5.101.4': + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} + peerDependencies: + react: ^18 || ^19 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + baseline-browser-mapping@2.11.13: + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + + electron-to-chromium@1.5.405: + resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==} + + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + immer@11.1.16: + resolution: {integrity: sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jotai@2.20.2: + resolution: {integrity: sha512-aHB4CNb9qRcyf0mwSB6EO5bCGAjx8cTwFgOFCE2leOnTzqACbnSWG8XoWB3LxCT1Qoj03I1OWAHszDmN4uHb/w==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + monaco-editor@0.48.0: + resolution: {integrity: sha512-goSDElNqFfw7iDHMg8WDATkfcyeLTNpBHQpO8incK6p5qZt5G/1j41X0xdGzpIkGojGXM+QiRQyLjnfDVvrpwA==} + + monaco-languageserver-types@0.3.4: + resolution: {integrity: sha512-d58sP5yNhjs8uG1ESXs0hFnuX2YfdMhiGeWhdgTUZyG9aaWgyI4dDwrK1khf1mPF2u9Sljv42sfYqPFZnqYMYg==} + + monaco-marker-data-provider@1.2.5: + resolution: {integrity: sha512-5ZdcYukhPwgYMCvlZ9H5uWs5jc23BQ8fFF5AhSIdrz5mvYLsqGZ58ZLxTv8rCX6+AxdJ8+vxg1HVSk+F2bLosg==} + + monaco-types@0.1.2: + resolution: {integrity: sha512-8LwfrlWXsedHwAL41xhXyqzPibS8IqPuIXr9NdORhonS495c2/wky+sI1PRLvMCuiI0nqC2NH1six9hdiRY4Xg==} + + monaco-worker-manager@2.0.1: + resolution: {integrity: sha512-kdPL0yvg5qjhKPNVjJoym331PY/5JC11aPJXtCZNwWRvBr6jhkIamvYAyiY5P1AWFmNOy0aRDRoMdZfa71h8kg==} + peerDependencies: + monaco-editor: '>=0.30.0' + + monaco-yaml@5.1.1: + resolution: {integrity: sha512-BuZ0/ZCGjrPNRzYMZ/MoxH8F/SdM+mATENXnpOhDYABi1Eh+QvxSszEct+ACSCarZiwLvy7m6yEF/pvW8XJkyQ==} + peerDependencies: + monaco-editor: '>=0.36' + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + recharts@3.10.1: + resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + + tailwind-merge@2.6.1: + resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-jsonrpc@9.0.1: + resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.18.2: + resolution: {integrity: sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.4': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/json': 1.0.3 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/state@6.7.1': + dependencies: + '@marijn/find-cluster-break': 1.0.3 + + '@codemirror/view@6.43.8': + dependencies: + '@codemirror/state': 6.7.1 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@flanksource/clicky-ui@0.3.20(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.18)(monaco-editor@0.48.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1))(tailwindcss@4.3.3)': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.10.4 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-sql': 6.10.0 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@flanksource/icons': 1.0.63(react@19.2.8) + '@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@monaco-editor/react': 4.7.0(monaco-editor@0.48.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@tanstack/react-query': 5.101.4(react@19.2.8) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + dompurify: 3.4.13 + jotai: 2.20.2(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) + monaco-editor: 0.48.0 + monaco-yaml: 5.1.1(monaco-editor@0.48.0) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + recharts: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1) + tailwind-merge: 2.6.1 + tailwindcss: 4.3.3 + yaml: 2.9.0 + transitivePeerDependencies: + - '@babel/core' + - '@babel/template' + - '@types/react' + + '@flanksource/icons@1.0.63(react@19.2.8)': + dependencies: + react: 19.2.8 + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@floating-ui/react@0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@floating-ui/utils': 0.2.12 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + tabbable: 6.5.0 + + '@floating-ui/utils@0.2.12': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@marijn/find-cluster-break@1.0.3': {} + + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.48.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.48.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.16 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 19.2.8 + react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + + '@tanstack/query-core@5.101.4': {} + + '@tanstack/react-query@5.101.4(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.101.4 + react: 19.2.8 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@types/use-sync-external-store@0.0.6': {} + + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + agent-base@7.1.4: {} + + assertion-error@2.0.1: {} + + baseline-browser-mapping@2.11.13: {} + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.13 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.405 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + cac@6.7.14: {} + + caniuse-lite@1.0.30001809: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + convert-source-map@2.0.0: {} + + crelt@1.0.7: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js-light@2.5.1: {} + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + detect-libc@2.1.2: {} + + dompurify@3.4.13: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + electron-to-chromium@1.5.405: {} + + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@6.0.1: {} + + es-module-lexer@1.7.0: {} + + es-toolkit@1.50.0: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + graceful-fs@4.2.11: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + immer@11.1.16: {} + + internmap@2.0.3: {} + + is-potential-custom-element-name@1.0.1: {} + + jiti@2.7.0: {} + + jotai@2.20.2(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8): + optionalDependencies: + '@babel/core': 7.29.7 + '@babel/template': 7.29.7 + '@types/react': 19.2.18 + react: 19.2.8 + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + monaco-editor@0.48.0: {} + + monaco-languageserver-types@0.3.4: + dependencies: + monaco-types: 0.1.2 + vscode-languageserver-protocol: 3.18.2 + vscode-uri: 3.1.0 + + monaco-marker-data-provider@1.2.5: + dependencies: + monaco-types: 0.1.2 + + monaco-types@0.1.2: {} + + monaco-worker-manager@2.0.1(monaco-editor@0.48.0): + dependencies: + monaco-editor: 0.48.0 + + monaco-yaml@5.1.1(monaco-editor@0.48.0): + dependencies: + '@types/json-schema': 7.0.15 + jsonc-parser: 3.3.1 + monaco-editor: 0.48.0 + monaco-languageserver-types: 0.3.4 + monaco-marker-data-provider: 1.2.5 + monaco-types: 0.1.2 + monaco-worker-manager: 2.0.1(monaco-editor@0.48.0) + path-browserify: 1.0.1 + prettier: 2.8.8 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-uri: 3.1.0 + yaml: 2.9.0 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-releases@2.0.53: {} + + nwsapi@2.2.24: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-browserify@1.0.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@2.8.8: {} + + punycode@2.3.1: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@19.2.8: {} + + react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + redux: 5.0.1 + + react-refresh@0.17.0: {} + + react@19.2.8: {} + + recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.50.0 + eventemitter3: 5.0.4 + immer: 11.1.16 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-is: 19.2.8 + react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + reselect: 5.2.0 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.8) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + reselect@5.2.0: {} + + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + state-local@1.0.7: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + style-mod@4.1.3: {} + + symbol-tree@3.2.4: {} + + tabbable@6.5.0: {} + + tailwind-merge@2.6.1: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tiny-invariant@1.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite-node@3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + yaml: 2.9.0 + + vitest@3.2.7(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vscode-jsonrpc@9.0.1: {} + + vscode-languageserver-protocol@3.18.2: + dependencies: + vscode-jsonrpc: 9.0.1 + vscode-languageserver-types: 3.18.0 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.18.0: {} + + vscode-uri@3.1.0: {} + + w3c-keyname@2.2.8: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} + + yaml@2.9.0: {}