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/.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/.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/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/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 017f028fc..c85e18db8 100644 --- a/cel.go +++ b/cel.go @@ -3,8 +3,8 @@ package gomplate import ( gocontext "context" "fmt" - "reflect" "regexp" + "sort" "sync" "github.com/flanksource/commons/context" @@ -21,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 @@ -48,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 } @@ -62,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)) @@ -79,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 { @@ -89,6 +88,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 +137,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/cel_cast_test.go b/cel_cast_test.go new file mode 100644 index 000000000..f04a290d0 --- /dev/null +++ b/cel_cast_test.go @@ -0,0 +1,93 @@ +package gomplate + +import ( + "math" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The conversions CEL's own are too strict for. `string(x)` has a fixed set of +// overloads and refuses a map or a null; `int("3")` parses but nothing picks +// int-versus-double from the value; and there is no date formatter at all. Each +// is declared globally and as a member, so `text(x)` and `x.text()` both read +// naturally depending on where in an expression they land. +var _ = Describe("cast helpers", func() { + evaluate := func(expression string, environment map[string]any) (any, error) { + return RunExpression(environment, Template{Expression: expression}) + } + + DescribeTable("text stringifies anything", + func(expression string, expected string) { + Expect(evaluate(expression, map[string]any{ + "value": map[string]any{"name": "folio", "count": 2}, + })).To(Equal(expected)) + }, + Entry("a string is itself", `"folio".text()`, "folio"), + Entry("an integer", `(2).text()`, "2"), + Entry("a double keeps its fraction", `(6.9).text()`, "6.9"), + Entry("a bool", `true.text()`, "true"), + Entry("the global form", `text("folio")`, "folio"), + // The case `string()` refuses outright, and the reason this exists: a + // register records what a scraper reported, whatever shape it arrived in. + Entry("a map", `value.name.text()`, "folio"), + ) + + DescribeTable("int parses and truncates", + func(expression string, expected int64) { + Expect(evaluate(expression, nil)).To(Equal(expected)) + }, + Entry("a numeric string", `"3".int()`, int64(3)), + Entry("a string with surrounding space", `" 17 ".int()`, int64(17)), + Entry("a double truncates", `(6.9).int()`, int64(6)), + Entry("an integer is itself", `(3).int()`, int64(3)), + Entry("the global form", `int("3")`, int64(3)), + ) + + DescribeTable("float parses", + func(expression string, expected float64) { + Expect(evaluate(expression, nil)).To(Equal(expected)) + }, + Entry("a fractional string", `"6.9".float()`, 6.9), + Entry("a whole string", `"7".float()`, float64(7)), + Entry("an integer widens", `(7).float()`, float64(7)), + ) + + DescribeTable("date formats a timestamp as YYYY-MM-DD", + func(expression string, expected string) { + Expect(evaluate(expression, map[string]any{ + "observed_at": "2026-03-24T18:45:00Z", + })).To(Equal(expected)) + }, + Entry("from a timestamp", `timestamp("2026-03-24T00:00:00Z").date()`, "2026-03-24"), + // The string receiver is what lets a projection read `source.first_observed.date()` + // instead of wrapping every field in `timestamp()` first. + Entry("from an RFC3339 string", `observed_at.date()`, "2026-03-24"), + Entry("the global form", `date("2026-03-24T18:45:00Z")`, "2026-03-24"), + // Not the local day: a register that says a finding was detected on the + // 24th must mean the same day to every reader of the document. + Entry("normalises to UTC", `timestamp("2026-03-24T23:30:00-05:00").date()`, "2026-03-25"), + ) + + DescribeTable("reports a value it cannot convert rather than returning a zero", + func(expression string, message string) { + _, err := evaluate(expression, nil) + Expect(err).To(MatchError(ContainSubstring(message))) + }, + Entry("a word is not an integer", `"several".int()`, `cannot parse "several"`), + Entry("a word is not a float", `"several".float()`, `cannot parse "several"`), + // Go parses "NaN", and JSON cannot write it. Both conversions refuse it + // rather than handing back something that cannot be serialised. + Entry("NaN is not an integer", `"NaN".int()`, "NaN"), + Entry("NaN is not a float", `"NaN".float()`, "NaN"), + // Beyond int64 the Go conversion is undefined and lands on the minimum + // int64, so a byte count past 2^63 would read as a large negative number. + Entry("beyond int64 is refused", `"18446744073709551616".int()`, "cannot represent"), + Entry("a phrase is not a date", `"last tuesday".date()`, "RFC3339"), + ) + + // The value .int() refuses is exactly the one .float() is for. + It("keeps a number too large for an int as a double", func() { + Expect(evaluate(`"18446744073709551616".float()`, nil)).To(Equal(math.Pow(2, 64))) + }) +}) diff --git a/cel_expression.go b/cel_expression.go new file mode 100644 index 000000000..3b9d1962b --- /dev/null +++ b/cel_expression.go @@ -0,0 +1,144 @@ +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 celResultValue(out), 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()) + } + + // 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) + } + 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_result.go b/cel_result.go new file mode 100644 index 000000000..9c29e22fe --- /dev/null +++ b/cel_result.go @@ -0,0 +1,103 @@ +package gomplate + +import ( + "math" + "reflect" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" + "google.golang.org/protobuf/types/known/structpb" +) + +// celResultValue is what an expression hands back to its caller. +// +// A map or list *constructed* inside CEL is held in cel-go's own representation, +// and its Value() is a map[ref.Val]ref.Val -- which marshals to {"Adapter":{}} +// and is useless to a caller that wants to write the result somewhere. A value +// merely *selected* out of the environment was never converted in the first +// place and comes back as whatever Go type it went in as, which is why the +// conversion is applied to aggregates rather than to everything: a registered +// native type must still round-trip as its own struct, not as a map. +func celResultValue(out ref.Val) any { + // A CEL null's Value() is structpb.NullValue, a protobuf enum whose value is + // 0. A caller checking for nil sees a number, and a caller writing the result + // records a zero where the expression said nothing at all. + if out == types.NullValue { + return nil + } + + switch out.(type) { + case traits.Mapper, traits.Lister: + default: + return out.Value() + } + // An aggregate is not automatically cel-go's own: a function returning + // []string, or a map read straight out of the environment, is wrapped in a + // Lister or Mapper while its Value() stays the Go value it always was. + // Converting those would widen `"open-source".split("-")` from []string to + // []any for no gain, so only the representations holding ref.Vals are + // rewritten. + if !holdsCELValues(out.Value()) { + return out.Value() + } + + native, err := out.ConvertToNative(types.JSONValueType) + if err != nil { + // Prior behaviour, deliberately. A value CEL cannot render as JSON is one + // this function has nothing better to say about, and returning what the + // caller used to get is strictly no worse than failing the evaluation. + return out.Value() + } + json, ok := native.(*structpb.Value) + if !ok { + return out.Value() + } + return celWholeNumbers(json.AsInterface()) +} + +var refValType = reflect.TypeOf((*ref.Val)(nil)).Elem() + +// holdsCELValues reports the aggregates cel-go built itself, which are the ones +// carrying ref.Vals rather than Go values. A value that is neither a Go map nor +// a Go slice is one of cel-go's own structs -- the map literal whose Value() is +// a mapAccessor, and which marshals to {"Adapter":{}}. +func holdsCELValues(value any) bool { + reflected := reflect.ValueOf(value) + switch reflected.Kind() { + case reflect.Slice, reflect.Array: + return reflected.Type().Elem().Implements(refValType) + case reflect.Map: + return reflected.Type().Key().Implements(refValType) || + reflected.Type().Elem().Implements(refValType) + default: + return true + } +} + +// celWholeNumbers narrows integral floats back to integers. structpb's only +// numeric type is a float64, so a count of two would otherwise come back as +// `2.0` and a schema declaring that field an integer would be describing +// something the value does not look like. A genuinely fractional value -- a +// score of 6.9 -- is left alone. +func celWholeNumbers(value any) any { + switch typed := value.(type) { + case float64: + if typed == math.Trunc(typed) && !math.IsInf(typed, 0) && math.Abs(typed) < 1<<53 { + return int64(typed) + } + return typed + case []any: + for index, item := range typed { + typed[index] = celWholeNumbers(item) + } + return typed + case map[string]any: + for key, item := range typed { + typed[key] = celWholeNumbers(item) + } + return typed + default: + return value + } +} diff --git a/cel_result_test.go b/cel_result_test.go new file mode 100644 index 000000000..e04fef282 --- /dev/null +++ b/cel_result_test.go @@ -0,0 +1,86 @@ +package gomplate + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// What an expression returns when it builds a value rather than selecting one. +// +// A map or list constructed inside CEL is held in cel-go's own representation, +// whose Value() is a map[ref.Val]ref.Val -- it marshals to {"Adapter":{}} and is +// unusable to a caller that wants to write the result somewhere. Every consumer +// that returns structured data hits this; the ones returning strings and bools +// never do, which is why it went unnoticed. +var _ = Describe("expression results", func() { + evaluate := func(expression string, environment map[string]any) any { + result, err := RunExpression(environment, Template{Expression: expression}) + Expect(err).NotTo(HaveOccurred()) + return result + } + + It("returns a constructed map as a plain Go map", func() { + Expect(evaluate(`{"kind": "finding", "severity": "high"}`, nil)). + To(Equal(map[string]any{"kind": "finding", "severity": "high"})) + }) + + It("returns a constructed list of maps as plain Go values", func() { + Expect(evaluate(`[{"kind": "finding"}, {"kind": "inventory"}]`, nil)). + To(Equal([]any{ + map[string]any{"kind": "finding"}, + map[string]any{"kind": "inventory"}, + })) + }) + + It("converts the whole way down, not just the outermost level", func() { + Expect(evaluate(`{"finding": {"advisory": {"id": "GHSA-p63j-vcc4-9vmv"}}}`, nil)). + To(Equal(map[string]any{ + "finding": map[string]any{"advisory": map[string]any{"id": "GHSA-p63j-vcc4-9vmv"}}, + })) + }) + + // A count written back as `2.0` would make a schema declaring the field an + // integer describe something the document does not look like. A genuinely + // fractional value is left alone. + It("keeps whole numbers whole and fractions fractional", func() { + Expect(evaluate(`{"count": 2, "score": 6.9}`, nil)). + To(Equal(map[string]any{"count": int64(2), "score": 6.9})) + }) + + It("reads a value out of the environment into a constructed map", func() { + Expect(evaluate(`{"name": source.name, "day": source.first_observed.date()}`, map[string]any{ + "source": map[string]any{"name": "folio", "first_observed": "2026-03-24T00:00:00Z"}, + })).To(Equal(map[string]any{"name": "folio", "day": "2026-03-24"})) + }) + + // out.Value() on a CEL null is structpb.NullValue -- a protobuf enum whose + // value is 0, so a caller checking for nil sees a number and a caller writing + // the result records a zero where the expression said nothing. + It("returns a null as a nil", func() { + Expect(evaluate(`null`, nil)).To(BeNil()) + }) + + It("returns a null inside a constructed map as a nil", func() { + Expect(evaluate(`{"owner": null, "name": "folio"}`, nil)). + To(Equal(map[string]any{"owner": nil, "name": "folio"})) + }) + + DescribeTable("leaves a scalar exactly as it was", + func(expression string, expected any) { + Expect(evaluate(expression, nil)).To(Equal(expected)) + }, + Entry("a string", `"folio"`, "folio"), + Entry("an integer", `2`, int64(2)), + Entry("a double", `6.9`, 6.9), + Entry("a bool", `true`, true), + ) + + // Selecting a map straight out of the environment never went through cel-go's + // own representation, so it must come back untouched -- including the integer + // widths a caller may be asserting on. + It("returns an environment map unchanged", func() { + Expect(evaluate(`tags`, map[string]any{ + "tags": map[string]any{"cluster": "production", "replicas": int64(3)}, + })).To(Equal(map[string]any{"cluster": "production", "replicas": int64(3)})) + }) +}) 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/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/funcs/cast.go b/funcs/cast.go new file mode 100644 index 000000000..6f4dc866f --- /dev/null +++ b/funcs/cast.go @@ -0,0 +1,172 @@ +package funcs + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +// The conversions CEL's own are too strict for. +// +// `string()` and `int()` are exact: each has a fixed overload set, so `string()` +// refuses anything it was not told about and neither reads a value whose type is +// only known at runtime. That is right for a language and wrong for templating +// over scraped data, where a property arrives as whatever the scraper made of it +// and the template's job is to say what it means. `date` has no equivalent at +// all -- CEL can parse a timestamp but not render one. +// +// Each is declared twice, globally and as a member, following celFirst in +// coll.go: `text(x)` reads better as an argument and `x.text()` reads better in +// a chain, and which one an author reaches for is not worth a rule. +// +// `int` is the exception: the global belongs to the standard library already, so +// only the member form is added and the two compose as one function. + +// castDateLayout is the calendar day, the unit a register or a report means when +// it says a date. Deliberately not RFC3339: a document stating a finding was +// detected on the 24th must mean the same day to whoever reads it, which is why +// every conversion below normalises to UTC first. +const castDateLayout = "2006-01-02" + +var celText = cel.Function("text", + cel.Overload("text_dyn", []*cel.Type{cel.DynType}, cel.StringType, + cel.UnaryBinding(celTextImpl)), + cel.MemberOverload("dyn_text", []*cel.Type{cel.DynType}, cel.StringType, + cel.UnaryBinding(celTextImpl)), +) + +// celInt has no global overload: the standard library's `int()` already converts +// every typed case, and a second global taking dyn would be ambiguous against +// each of them. The member form is what the standard library has no answer for. +var celInt = cel.Function("int", + cel.MemberOverload("dyn_int", []*cel.Type{cel.DynType}, cel.IntType, + cel.UnaryBinding(celIntImpl)), +) + +var celFloat = cel.Function("float", + cel.Overload("float_dyn", []*cel.Type{cel.DynType}, cel.DoubleType, + cel.UnaryBinding(celFloatImpl)), + cel.MemberOverload("dyn_float", []*cel.Type{cel.DynType}, cel.DoubleType, + cel.UnaryBinding(celFloatImpl)), +) + +var celDate = cel.Function("date", + cel.Overload("date_dyn", []*cel.Type{cel.DynType}, cel.StringType, + cel.UnaryBinding(celDateImpl)), + cel.MemberOverload("dyn_date", []*cel.Type{cel.DynType}, cel.StringType, + cel.UnaryBinding(celDateImpl)), +) + +func celTextImpl(value ref.Val) ref.Val { + if isNullVal(value) { + return types.NewErr("text() does not accept null") + } + return types.String(fmt.Sprint(value.Value())) +} + +// celIntImpl parses and truncates, because the two things a caller means by +// "as an integer" are a numeric string and a number that is not one yet. A +// value that is neither is an error rather than a zero: a silent zero reads as +// a real count of none. +func celIntImpl(value ref.Val) ref.Val { + if isNullVal(value) { + return types.NewErr("int() does not accept null") + } + switch typed := value.Value().(type) { + case int64: + return types.Int(typed) + case uint64: + if typed > math.MaxInt64 { + return types.NewErr("int() cannot represent %d", typed) + } + return types.Int(int64(typed)) + case float64: + return truncateToInt(typed) + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + if err != nil { + return types.NewErr("int() cannot parse %q", typed) + } + return truncateToInt(parsed) + default: + return types.NewErr("int() expects a string or numeric value, got %T", value.Value()) + } +} + +// truncateToInt rejects what int64 cannot hold instead of converting it. +// Converting a float64 outside int64's range is undefined in Go and yields the +// minimum int64 on amd64, so a byte count past 2^63 would otherwise arrive as a +// large negative number. The upper bound is strictly less than 2^63 because +// math.MaxInt64 is not representable as a float64 and rounds up to exactly that. +func truncateToInt(value float64) ref.Val { + if math.IsNaN(value) { + return types.NewErr("int() does not accept NaN") + } + truncated := math.Trunc(value) + if truncated < math.MinInt64 || truncated >= 1<<63 { + return types.NewErr("int() cannot represent %v", value) + } + return types.Int(int64(truncated)) +} + +func celFloatImpl(value ref.Val) ref.Val { + if isNullVal(value) { + return types.NewErr("float() does not accept null") + } + switch typed := value.Value().(type) { + case float64: + return types.Double(typed) + case int64: + return types.Double(float64(typed)) + case uint64: + return types.Double(float64(typed)) + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + if err != nil { + return types.NewErr("float() cannot parse %q", typed) + } + // Go parses "NaN" happily, and JSON has no way to write the result -- + // a value that cannot be serialised is not a number a caller can use. + if math.IsNaN(parsed) { + return types.NewErr("float() does not accept NaN") + } + return types.Double(parsed) + default: + return types.NewErr("float() expects a string or numeric value, got %T", value.Value()) + } +} + +// celDateImpl accepts a timestamp or the RFC3339 string one usually arrives as, +// so a caller does not have to wrap every scraped field in timestamp() before +// asking which day it was. +func celDateImpl(value ref.Val) ref.Val { + if isNullVal(value) { + return types.NewErr("date() does not accept null") + } + switch typed := value.Value().(type) { + case time.Time: + return types.String(typed.UTC().Format(castDateLayout)) + case string: + parsed, err := time.Parse(time.RFC3339, typed) + if err != nil { + return types.NewErr("date() expects an RFC3339 timestamp, got %q", typed) + } + return types.String(parsed.UTC().Format(castDateLayout)) + default: + return types.NewErr("date() expects a timestamp or RFC3339 string, got %T", value.Value()) + } +} + +// isNullVal covers both shapes a null reaches a binding as. Under the nilsafe +// library a null argument short-circuits the call and these branches are never +// reached, which is why they state the contract for every other environment +// rather than being the primary defence. +func isNullVal(value ref.Val) bool { + return value == nil || value == types.NullValue || value.Value() == nil +} diff --git a/funcs/cel_exports.go b/funcs/cel_exports.go index d41224465..37fe9f3f0 100644 --- a/funcs/cel_exports.go +++ b/funcs/cel_exports.go @@ -31,6 +31,16 @@ var CelEnvOption = []cel.EnvOption{ celCoalesce, celFirst, celLast, + + // The conversions cel-go's own are too strict for -- see cast.go. These are + // the exception to the note below: `string()` and `int()` do cover the typed + // cases, but neither reads a value whose type is only known at runtime, and + // neither renders a calendar day. + celText, + celInt, + celFloat, + celDate, + // NOTE: Conv Bool, int, Float, String are not needed // as cel-go has native support for it. // Slice, ToStrings are meaningless since 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..942b06f93 --- /dev/null +++ b/genmonarch/render.go @@ -0,0 +1,116 @@ +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. +` + +// 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 { + definitions[id] = languageDefinition{ + ID: id, + Monarch: b.Languages[id], + Configuration: b.Configurations[id], + } + } + 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 { + 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) + + 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") + + 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 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 = languagesJson as unknown as Record;\n") + return out.String() +} 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)) + }) +}) diff --git a/go.mod b/go.mod index ae7651d21..8cb12cfd5 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,11 @@ 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 - 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 @@ -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/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/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) + } +} 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/nilsafe_test.go b/nilsafe/nilsafe_test.go index 20e4fb24a..9d30c8bbc 100644 --- a/nilsafe/nilsafe_test.go +++ b/nilsafe/nilsafe_test.go @@ -214,6 +214,32 @@ func TestNilSafe_ZeroValues(t *testing.T) { {name: "null < 1 is true", expr: "a < 1", vars: map[string]any{"a": nil}, want: types.True}, {name: "null >= 0 is true", expr: "a >= 0", vars: map[string]any{"a": nil}, want: types.True}, {name: "null <= 0 is true", expr: "a <= 0", vars: map[string]any{"a": nil}, want: types.True}, + + // A number has a zero and a string has an empty, but an instant has + // neither: every moment is a real moment. Substituting one made a missing + // date read as 1970 -- the oldest date there is -- so `a < deadline` said + // "overdue" about a date nobody ever recorded. Null propagates instead. + { + name: "null before a timestamp is null, not the epoch", + expr: `a < timestamp("2026-03-24T00:00:00Z")`, + vars: map[string]any{"a": nil}, want: types.NullValue, + }, + { + name: "null after a timestamp is null", + expr: `a > timestamp("2026-03-24T00:00:00Z")`, + vars: map[string]any{"a": nil}, want: types.NullValue, + }, + { + name: "null under a duration is null", + expr: `a < duration("720h")`, + vars: map[string]any{"a": nil}, want: types.NullValue, + }, + // Equality still answers: a date nobody recorded is not that date. + { + name: "null does not equal a timestamp", + expr: `a == timestamp("2026-03-24T00:00:00Z")`, + vars: map[string]any{"a": nil}, want: types.False, + }, {name: "method on null still returns null", expr: "a.size()", vars: map[string]any{"a": nil}, want: types.NullValue}, {name: "present variable works normally", expr: "x + 1", vars: map[string]any{"x": int64(41)}, want: types.Int(42)}, {name: "both present comparison", expr: "x > 0", vars: map[string]any{"x": int64(5)}, want: types.True}, diff --git a/nilsafe/zeroval.go b/nilsafe/zeroval.go index 0b9ced621..989c7521a 100644 --- a/nilsafe/zeroval.go +++ b/nilsafe/zeroval.go @@ -1,8 +1,6 @@ package nilsafe import ( - "time" - "github.com/google/cel-go/common/operators" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" @@ -24,10 +22,12 @@ func zeroValueFor(t ref.Type) ref.Val { return types.String("") case types.BytesType: return types.Bytes{} - case types.DurationType: - return types.Duration{Duration: 0} - case types.TimestampType: - return types.Timestamp{Time: time.Unix(0, 0)} + // Deliberately no case for TimestampType or DurationType. A number has a + // zero and a string has an empty, but an instant has neither -- every moment + // is a real moment, and time.Unix(0, 0) is 1970 rather than "missing". A + // missing date substituted for the epoch is the oldest date there is, so + // `observed < deadline` answered "overdue" about a date nobody recorded: + // the most wrong answer available, and silently. Null propagates instead. default: return types.NullValue } @@ -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() @@ -68,6 +82,13 @@ func (c *zeroValueCall) Eval(ctx interpreter.Activation) ref.Val { for i, v := range vals { if v == types.NullValue { vals[i] = zeroValueFor(inferTypeFromPeers(vals, i)) + // No zero value stands in for this type, so there is nothing to + // compute with. Null propagates, which is the library's own contract + // for a missing thing, rather than an arbitrary sentinel being + // invented or the operator reporting itself as unsupported. + if vals[i] == types.NullValue { + return types.NullValue + } } } return dispatchOp(fn, vals) @@ -78,13 +99,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 +133,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 +177,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/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()) + } +} 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") } 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..3aaff9bc1 --- /dev/null +++ b/web/package.json @@ -0,0 +1,14 @@ +{ + "name": "@flanksource/gomplate-web", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm -r build", + "pretest": "pnpm --filter @flanksource/gomplate-lang build", + "test": "pnpm -r test", + "pretypecheck": "pnpm --filter @flanksource/gomplate-lang build", + "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/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/index.ts b/web/packages/lang/src/generated/index.ts new file mode 100644 index 000000000..e0386518f --- /dev/null +++ b/web/packages/lang/src/generated/index.ts @@ -0,0 +1,30 @@ +// 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 languagesJson from "./languages.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 = languagesJson as unknown as Record; diff --git a/web/packages/lang/src/generated/languages.json b/web/packages/lang/src/generated/languages.json new file mode 100644 index 000000000..45df51e67 --- /dev/null +++ b/web/packages/lang/src/generated/languages.json @@ -0,0 +1,2302 @@ +{ + "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", + "date", + "debug", + "decode", + "double", + "duration", + "dyn", + "encode", + "equivalent", + "f", + "first", + "float", + "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", + "text", + "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/spec.json b/web/packages/lang/src/generated/spec.json new file mode 100644 index 000000000..f521fa146 --- /dev/null +++ b/web/packages/lang/src/generated/spec.json @@ -0,0 +1,6257 @@ +{ + "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": "date", + "overloads": [ + { + "id": "date_dyn", + "args": [ + "dyn" + ], + "result": "string" + }, + { + "id": "dyn_date", + "args": [ + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "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": "float", + "overloads": [ + { + "id": "dyn_float", + "args": [ + "dyn" + ], + "result": "double", + "member": true + }, + { + "id": "float_dyn", + "args": [ + "dyn" + ], + "result": "double" + } + ] + }, + { + "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": "dyn_int", + "args": [ + "dyn" + ], + "result": "int", + "member": true + }, + { + "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": "text", + "overloads": [ + { + "id": "dyn_text", + "args": [ + "dyn" + ], + "result": "string", + "member": true + }, + { + "id": "text_dyn", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "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/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-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: {} 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