paseo-plugin-genui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/OVERVIEW.md +21 -0
- package/README.md +77 -2
- package/assets/fonts/Excalifont-OFL.txt +109 -0
- package/assets/fonts/Excalifont-Regular-latin.woff2 +0 -0
- package/assets/icon.png +0 -0
- package/assets/icon.svg +1 -0
- package/assets/screenshot-chat.png +0 -0
- package/assets/screenshot-gallery.png +0 -0
- package/assets/screenshot-playground.png +0 -0
- package/client/components/content.tsx +468 -0
- package/client/components/data.tsx +967 -0
- package/client/components/dev.tsx +998 -0
- package/client/components/diagram.tsx +513 -0
- package/client/components/inputs.tsx +994 -0
- package/client/components/layout.tsx +239 -0
- package/client/components/media.tsx +428 -0
- package/client/components/project.tsx +641 -0
- package/client/components/widgets.tsx +836 -0
- package/client/gallery.tsx +449 -0
- package/client/live-ui.tsx +525 -0
- package/client/loaders.ts +342 -0
- package/client/markdown.tsx +172 -0
- package/client/node-renderer.tsx +149 -0
- package/client/render.tsx +186 -0
- package/client/runtime.ts +103 -0
- package/client/settings.tsx +57 -0
- package/client/web.ts +186 -0
- package/gallery/Acceptance-baseline.ui +1 -0
- package/gallery/Acceptance-checklist.ui +14 -0
- package/gallery/Acceptance-evidence.ui +9 -0
- package/gallery/Acceptance.ui +1 -0
- package/gallery/Badge.ui +3 -0
- package/gallery/Benchmark-context.ui +8 -0
- package/gallery/Benchmark-hyperfine.ui +1 -0
- package/gallery/Benchmark-runs-list.ui +9 -0
- package/gallery/Benchmark-runs.ui +1 -0
- package/gallery/Benchmark-vitest.ui +1 -0
- package/gallery/Benchmark.ui +9 -0
- package/gallery/Board-bb-tasks.ui +1 -0
- package/gallery/Board-gh-issues.ui +1 -0
- package/gallery/Board-gh-prs.ui +1 -0
- package/gallery/Board.ui +12 -0
- package/gallery/Button-destructive.ui +4 -0
- package/gallery/Button.ui +5 -0
- package/gallery/Calendar.ui +15 -0
- package/gallery/CalendarHeatmap-short.ui +11 -0
- package/gallery/CalendarHeatmap.ui +4 -0
- package/gallery/Callout.ui +4 -0
- package/gallery/Card.ui +4 -0
- package/gallery/Carousel.ui +8 -0
- package/gallery/Changes-paths.ui +1 -0
- package/gallery/Changes-uncommitted.ui +1 -0
- package/gallery/Changes.ui +1 -0
- package/gallery/Chart-area.ui +3 -0
- package/gallery/Chart-funnel.ui +3 -0
- package/gallery/Chart-heatmap.ui +11 -0
- package/gallery/Chart-pie.ui +3 -0
- package/gallery/Chart-radar.ui +3 -0
- package/gallery/Chart-scatter.ui +4 -0
- package/gallery/Chart-stacked.ui +5 -0
- package/gallery/Chart.ui +3 -0
- package/gallery/Checklist.ui +6 -0
- package/gallery/Choice.ui +8 -0
- package/gallery/Code.ui +3 -0
- package/gallery/Commits-limit.ui +1 -0
- package/gallery/Commits.ui +1 -0
- package/gallery/Compare-conditions.ui +4 -0
- package/gallery/Compare-three.ui +7 -0
- package/gallery/Compare.ui +4 -0
- package/gallery/Coverage-go.ui +1 -0
- package/gallery/Coverage-lcov.ui +1 -0
- package/gallery/Coverage-python.ui +1 -0
- package/gallery/Coverage.ui +1 -0
- package/gallery/DateInput.ui +5 -0
- package/gallery/Decision-percent.ui +10 -0
- package/gallery/Decision.ui +12 -0
- package/gallery/Dependencies-audit.ui +1 -0
- package/gallery/Dependencies-both.ui +1 -0
- package/gallery/Dependencies-list.ui +7 -0
- package/gallery/Dependencies-pip.ui +1 -0
- package/gallery/Dependencies-pnpm.ui +1 -0
- package/gallery/Dependencies.ui +1 -0
- package/gallery/Details.ui +4 -0
- package/gallery/Diagram-flow.ui +16 -0
- package/gallery/Diagram-sequence.ui +15 -0
- package/gallery/Diagram.ui +16 -0
- package/gallery/Diff.ui +3 -0
- package/gallery/Divider.ui +5 -0
- package/gallery/EditableTable.ui +9 -0
- package/gallery/Entity-list.ui +6 -0
- package/gallery/Entity.ui +8 -0
- package/gallery/Environments.ui +1 -0
- package/gallery/Estimate-points.ui +6 -0
- package/gallery/Estimate.ui +8 -0
- package/gallery/File.ui +4 -0
- package/gallery/Findings-biome.ui +1 -0
- package/gallery/Findings-eslint.ui +1 -0
- package/gallery/Findings-oxlint.ui +1 -0
- package/gallery/Findings-review.ui +1 -0
- package/gallery/Findings-ruff.ui +1 -0
- package/gallery/Findings-sarif.ui +1 -0
- package/gallery/Findings-ship.ui +11 -0
- package/gallery/Findings-tsc.ui +1 -0
- package/gallery/Findings.ui +7 -0
- package/gallery/Flashcards.ui +3 -0
- package/gallery/FollowUps.ui +4 -0
- package/gallery/Form.ui +8 -0
- package/gallery/Gallery-links.ui +8 -0
- package/gallery/Gallery-strip.ui +14 -0
- package/gallery/Gallery.ui +10 -0
- package/gallery/Gauge.ui +7 -0
- package/gallery/Grid.ui +8 -0
- package/gallery/Hotspots-photo.ui +8 -0
- package/gallery/Hotspots.ui +11 -0
- package/gallery/Icon.ui +5 -0
- package/gallery/Image.ui +4 -0
- package/gallery/ImageSearch-aspects.ui +6 -0
- package/gallery/ImageSearch.ui +4 -0
- package/gallery/Input.ui +5 -0
- package/gallery/Json-api.ui +1 -0
- package/gallery/Json.ui +8 -0
- package/gallery/KeyValue.ui +3 -0
- package/gallery/List-dots.ui +8 -0
- package/gallery/List.ui +8 -0
- package/gallery/ListEditor.ui +5 -0
- package/gallery/Logs-jsonl.ui +1 -0
- package/gallery/Logs-lines.ui +11 -0
- package/gallery/Logs-python.ui +1 -0
- package/gallery/Logs.ui +1 -0
- package/gallery/Map-city.ui +8 -0
- package/gallery/Map.ui +8 -0
- package/gallery/Metric.ui +3 -0
- package/gallery/OptionCards-multi.ui +10 -0
- package/gallery/OptionCards.ui +10 -0
- package/gallery/Pipeline-checks.ui +1 -0
- package/gallery/Pipeline-passed.ui +1 -0
- package/gallery/Pipeline-running.ui +1 -0
- package/gallery/Pipeline-stages.ui +13 -0
- package/gallery/Pipeline.ui +1 -0
- package/gallery/Plan.ui +9 -0
- package/gallery/Progress.ui +4 -0
- package/gallery/Provenance-deploy.ui +6 -0
- package/gallery/Provenance-no-receipt.ui +1 -0
- package/gallery/Provenance.ui +1 -0
- package/gallery/PullRequest.ui +1 -0
- package/gallery/Quiz.ui +6 -0
- package/gallery/Radio.ui +5 -0
- package/gallery/Rating.ui +5 -0
- package/gallery/Roadmap-sprint.ui +8 -0
- package/gallery/Roadmap-year.ui +9 -0
- package/gallery/Roadmap.ui +12 -0
- package/gallery/Row.ui +3 -0
- package/gallery/SegmentedBar-status.ui +8 -0
- package/gallery/SegmentedBar.ui +8 -0
- package/gallery/Select.ui +5 -0
- package/gallery/Slider.ui +7 -0
- package/gallery/Snippet.ui +3 -0
- package/gallery/Sources-chips.ui +4 -0
- package/gallery/Sources.ui +8 -0
- package/gallery/Sparkline.ui +6 -0
- package/gallery/Stack.ui +7 -0
- package/gallery/StackTrace-aggregate.ui +1 -0
- package/gallery/StackTrace-browser.ui +1 -0
- package/gallery/StackTrace-go.ui +1 -0
- package/gallery/StackTrace-java.ui +1 -0
- package/gallery/StackTrace-python.ui +1 -0
- package/gallery/StackTrace-rust.ui +1 -0
- package/gallery/StackTrace.ui +1 -0
- package/gallery/Status.ui +9 -0
- package/gallery/Stepper.ui +5 -0
- package/gallery/Steps.ui +8 -0
- package/gallery/Swatches-long-names.ui +4 -0
- package/gallery/Swatches-picker.ui +5 -0
- package/gallery/Swatches.ui +10 -0
- package/gallery/Table.ui +7 -0
- package/gallery/Tabs.ui +8 -0
- package/gallery/Tests-junit.ui +1 -0
- package/gallery/Tests-vitest-junit.ui +1 -0
- package/gallery/Tests-vitest.ui +1 -0
- package/gallery/Tests.ui +9 -0
- package/gallery/Text.ui +4 -0
- package/gallery/TextArea.ui +5 -0
- package/gallery/Threads-ids.ui +1 -0
- package/gallery/Threads.ui +1 -0
- package/gallery/Timeline.ui +8 -0
- package/gallery/Timer.ui +4 -0
- package/gallery/Toggle.ui +5 -0
- package/gallery/Transcript-text.ui +1 -0
- package/gallery/Transcript-untimed.ui +1 -0
- package/gallery/Transcript-whisper.ui +1 -0
- package/gallery/Transcript.ui +1 -0
- package/gallery/Tree.ui +3 -0
- package/gallery/Treemap-du.ui +1 -0
- package/gallery/Treemap-esbuild.ui +1 -0
- package/gallery/Treemap-tree.ui +7 -0
- package/gallery/Treemap.ui +18 -0
- package/gallery/Walkthrough.ui +9 -0
- package/gallery/Waterfall-har.ui +1 -0
- package/gallery/Waterfall-trace.ui +1 -0
- package/gallery/Waterfall.ui +11 -0
- package/gallery/data/acceptance/coil/viewport-lifecycle.json +25 -0
- package/gallery/data/acceptance/coil-gate.json +153 -0
- package/gallery/data/acceptance/orbit-baseline.json +163 -0
- package/gallery/data/acceptance/shots/gate-cell-opened.png +0 -0
- package/gallery/data/acceptance/shots/gate-light.png +0 -0
- package/gallery/data/acceptance/shots/gate-phone-dark.png +0 -0
- package/gallery/data/activity.json +1 -0
- package/gallery/data/bb-tasks-orb.json +420 -0
- package/gallery/data/biome.json +229 -0
- package/gallery/data/compare/status-after.png +0 -0
- package/gallery/data/compare/status-before.png +0 -0
- package/gallery/data/compare/status-dark-2x.png +0 -0
- package/gallery/data/compare/status-rerender.png +0 -0
- package/gallery/data/coverage-py.json +1 -0
- package/gallery/data/coverage-summary.json +1 -0
- package/gallery/data/coverage.out +23 -0
- package/gallery/data/deploys.json +1 -0
- package/gallery/data/device-runs.json +285 -0
- package/gallery/data/du-node-modules.txt +359 -0
- package/gallery/data/esbuild-meta.json +292 -0
- package/gallery/data/eslint.json +266 -0
- package/gallery/data/eslint.sarif +343 -0
- package/gallery/data/findings-review.json +76 -0
- package/gallery/data/gh-issues-nextjs.json +293 -0
- package/gallery/data/gh-pr-checks.json +272 -0
- package/gallery/data/gh-prs-nextjs.json +419 -0
- package/gallery/data/gh-run-failed.json +1 -0
- package/gallery/data/gh-run-passed.json +1 -0
- package/gallery/data/gh-run-running.json +1 -0
- package/gallery/data/github-release.json +57 -0
- package/gallery/data/hyperfine-startup.json +187 -0
- package/gallery/data/lcov.info +234 -0
- package/gallery/data/logs/media-sync.log +127 -0
- package/gallery/data/logs/thumbnailer.jsonl +17 -0
- package/gallery/data/logs/vitest-run.log +89 -0
- package/gallery/data/npm-audit.json +672 -0
- package/gallery/data/npm-outdated.json +179 -0
- package/gallery/data/oxlint.json +156 -0
- package/gallery/data/pip-outdated.json +68 -0
- package/gallery/data/pnpm-outdated.json +58 -0
- package/gallery/data/provenance/devicectl-install.json +35 -0
- package/gallery/data/provenance/install-timeout.log +4 -0
- package/gallery/data/provenance/iphone-install.json +72 -0
- package/gallery/data/provenance/iphone-no-receipt.json +57 -0
- package/gallery/data/provenance/live-home.jpg +0 -0
- package/gallery/data/provenance/live-probe.txt +10 -0
- package/gallery/data/provenance/xcodebuild-2811.log +19 -0
- package/gallery/data/provenance/xcodebuild-4712.log +19 -0
- package/gallery/data/pytest-junit.xml +64 -0
- package/gallery/data/python-org-trace.json +35 -0
- package/gallery/data/python-org.har +31 -0
- package/gallery/data/retry.ts +13 -0
- package/gallery/data/ruff.json +261 -0
- package/gallery/data/src/durations.py +63 -0
- package/gallery/data/src/json-logic.ts +156 -0
- package/gallery/data/src/mapMath.ts +135 -0
- package/gallery/data/src/semver/bump.go +73 -0
- package/gallery/data/traces/browser-error.json +5 -0
- package/gallery/data/traces/go/main.go +27 -0
- package/gallery/data/traces/go-panic.txt +16 -0
- package/gallery/data/traces/java-caused-by.txt +15 -0
- package/gallery/data/traces/node-aggregate.txt +30 -0
- package/gallery/data/traces/node-cause.txt +21 -0
- package/gallery/data/traces/py/deploy.json +5 -0
- package/gallery/data/traces/py/deploy.py +17 -0
- package/gallery/data/traces/py/settings.py +19 -0
- package/gallery/data/traces/python-chain.txt +30 -0
- package/gallery/data/traces/rust/main.rs +11 -0
- package/gallery/data/traces/rust-panic.txt +16 -0
- package/gallery/data/traces/shop/cart.mjs +19 -0
- package/gallery/data/traces/shop/pricing.mjs +12 -0
- package/gallery/data/traces/shop/status.mjs +9 -0
- package/gallery/data/traces/web/app.js +11 -0
- package/gallery/data/traces/web/index.html +1 -0
- package/gallery/data/traces/web/search.js +10 -0
- package/gallery/data/transcript/call-noisy.wav +0 -0
- package/gallery/data/transcript/call.json +362 -0
- package/gallery/data/transcript/call.wav +0 -0
- package/gallery/data/transcript/mlx-whisper-tiny.en-noisy.json +1 -0
- package/gallery/data/transcript/whisper-cpp-base.en-noisy.json +658 -0
- package/gallery/data/transcript/whisper-cpp-base.en.json +658 -0
- package/gallery/data/transcript/whisper-cpp-base.en.srt +16 -0
- package/gallery/data/transcript/whisper-cpp-tiny.en-noisy.json +619 -0
- package/gallery/data/tsc.txt +4 -0
- package/gallery/data/vitest-bench.json +119 -0
- package/gallery/data/vitest-junit.xml +133 -0
- package/gallery/data/vitest-report.json +612 -0
- package/gallery/images/bell.svg +10 -0
- package/gallery/images/bike.svg +47 -0
- package/gallery/images/footer-planet.webp +0 -0
- package/gallery/images/front-light.svg +13 -0
- package/gallery/images/golden-hour.webp +0 -0
- package/gallery/images/option-flowers.webp +0 -0
- package/gallery/images/option-hilltop.webp +0 -0
- package/gallery/images/option-meadow.webp +0 -0
- package/gallery/images/option-sky.webp +0 -0
- package/gallery/images/u-lock.svg +10 -0
- package/index.client.tsx +164 -0
- package/index.server.ts +216 -0
- package/package.json +50 -4
- package/paseo-plugin.json +14 -0
- package/server/data-source.ts +407 -0
- package/server/project.ts +981 -0
- package/server/web/address.ts +57 -0
- package/server/web/cache.ts +85 -0
- package/server/web/commons.ts +126 -0
- package/server/web/geocode.ts +40 -0
- package/server/web/image.ts +35 -0
- package/server/web/lookups.ts +248 -0
- package/server/web/net.ts +210 -0
- package/shared/core/acceptance.ts +744 -0
- package/shared/core/ast.ts +76 -0
- package/shared/core/benchmark.ts +743 -0
- package/shared/core/catalog.ts +733 -0
- package/shared/core/compare.ts +225 -0
- package/shared/core/coverage.ts +456 -0
- package/shared/core/data.ts +334 -0
- package/shared/core/decisions.ts +527 -0
- package/shared/core/dependencies.ts +604 -0
- package/shared/core/diagnostics.ts +24 -0
- package/shared/core/environments.ts +212 -0
- package/shared/core/evaluate.ts +737 -0
- package/shared/core/fields.ts +66 -0
- package/shared/core/findings.ts +1172 -0
- package/shared/core/form.ts +69 -0
- package/shared/core/index.ts +44 -0
- package/shared/core/junit.ts +252 -0
- package/shared/core/lexer.ts +275 -0
- package/shared/core/logs.ts +496 -0
- package/shared/core/markdown.ts +2374 -0
- package/shared/core/parser.ts +355 -0
- package/shared/core/perf-units.ts +228 -0
- package/shared/core/pipeline.ts +679 -0
- package/shared/core/project.ts +472 -0
- package/shared/core/provenance.ts +725 -0
- package/shared/core/stack-trace.ts +718 -0
- package/shared/core/streaming.ts +136 -0
- package/shared/core/test-results.ts +562 -0
- package/shared/core/thread-activity.ts +203 -0
- package/shared/core/tracking.ts +887 -0
- package/shared/core/transcript.ts +1364 -0
- package/shared/core/treemap.ts +446 -0
- package/shared/core/types.ts +70 -0
- package/shared/core/validate.ts +574 -0
- package/shared/core/waterfall.ts +536 -0
- package/shared/diagram/flowchart.ts +205 -0
- package/shared/diagram/layout.ts +440 -0
- package/shared/diagram/markdown.ts +135 -0
- package/shared/diagram/sequence.ts +122 -0
- package/shared/diagram/zoom.ts +114 -0
- package/shared/fallback.ts +120 -0
- package/shared/genui.test.ts +122 -0
- package/shared/instructions.ts +144 -0
- package/shared/logic/calendarMath.ts +130 -0
- package/shared/logic/chartMath.ts +289 -0
- package/shared/logic/choose-logic.ts +196 -0
- package/shared/logic/compare-logic.ts +270 -0
- package/shared/logic/content-model.ts +227 -0
- package/shared/logic/environments-logic.ts +53 -0
- package/shared/logic/explain-logic.ts +315 -0
- package/shared/logic/fieldLogic.ts +138 -0
- package/shared/logic/gallery-search.ts +71 -0
- package/shared/logic/icons.ts +322 -0
- package/shared/logic/json-logic.ts +153 -0
- package/shared/logic/mapMath.ts +133 -0
- package/shared/logic/project-logic.ts +39 -0
- package/shared/logic/references-model.ts +201 -0
- package/shared/logic/sketch.ts +165 -0
- package/shared/logic/supersession.ts +77 -0
- package/shared/logic/values.ts +133 -0
- package/shared/logic/widget-logic.ts +163 -0
- package/shared/rpc.ts +252 -0
- package/shared/segment.ts +81 -0
- package/shared/settings.ts +12 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import type { ArrowExpr, CallExpr, Expr, Program } from "./ast";
|
|
2
|
+
import { FORBIDDEN_PROPERTIES, LIMITS, type ComponentName, type ComponentSpec, type ParamSpec } from "./catalog";
|
|
3
|
+
import { DiagnosticList, type Pos } from "./diagnostics";
|
|
4
|
+
import { dataKey, languageOf, snippetLines, snippetRangeError, type LoadedData } from "./data";
|
|
5
|
+
import type { Action, Binding, Diagnostic, UiNode, UiState, Value } from "./types";
|
|
6
|
+
import {
|
|
7
|
+
ACTION_SPECS,
|
|
8
|
+
COMPONENT_SPECS,
|
|
9
|
+
coerceState,
|
|
10
|
+
DESCRIPTIONS,
|
|
11
|
+
EXPECTED,
|
|
12
|
+
LAMBDA_METHODS,
|
|
13
|
+
stateMismatch,
|
|
14
|
+
type Kind,
|
|
15
|
+
} from "./validate";
|
|
16
|
+
|
|
17
|
+
/** Longest string any operation may produce; repeated doubling would otherwise exhaust memory. */
|
|
18
|
+
const MAX_STRING_LENGTH = 100_000;
|
|
19
|
+
/** Deepest expression nesting during evaluation (named values included), so the stack cannot overflow. */
|
|
20
|
+
const MAX_EVAL_DEPTH = 1_000;
|
|
21
|
+
/** Data for callers that load no files: every `Load` reports it is still loading, every `Snippet` shows its loading state. */
|
|
22
|
+
const NO_DATA: ReadonlyMap<string, LoadedData> = new Map();
|
|
23
|
+
|
|
24
|
+
abstract class EvalFailure extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
readonly pos: Pos,
|
|
27
|
+
message: string,
|
|
28
|
+
) {
|
|
29
|
+
super(message);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A problem local to one component: it becomes an Error node, siblings still render. */
|
|
34
|
+
class RuntimeError extends EvalFailure {}
|
|
35
|
+
|
|
36
|
+
/** A resource limit: evaluation stops. */
|
|
37
|
+
class LimitError extends EvalFailure {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A `Load` whose file is still being read. It travels like a RuntimeError, so
|
|
41
|
+
* whatever used the value waits too, but the component becomes a Loading
|
|
42
|
+
* placeholder instead of an Error, and it is no diagnostic.
|
|
43
|
+
*/
|
|
44
|
+
class LoadPending extends RuntimeError {
|
|
45
|
+
constructor(
|
|
46
|
+
pos: Pos,
|
|
47
|
+
readonly path: string,
|
|
48
|
+
) {
|
|
49
|
+
super(pos, `Loading ${path}…`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function kindOf(value: Value): Kind {
|
|
54
|
+
if (value === null) return "null";
|
|
55
|
+
if (typeof value === "boolean") return "boolean";
|
|
56
|
+
if (typeof value === "number") return "number";
|
|
57
|
+
if (typeof value === "string") return "string";
|
|
58
|
+
if (Array.isArray(value)) return "list";
|
|
59
|
+
// Object literals cannot carry these `kind` values (see the "object" case), so they mark interpreter output.
|
|
60
|
+
if ("kind" in value && (value.kind === "node" || value.kind === "action" || value.kind === "binding")) {
|
|
61
|
+
return value.kind;
|
|
62
|
+
}
|
|
63
|
+
return "object";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function describe(value: Value): string {
|
|
67
|
+
return DESCRIPTIONS[kindOf(value)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isList(value: Value): value is readonly Value[] {
|
|
71
|
+
return Array.isArray(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type PlainObject = { readonly [key: string]: Value };
|
|
75
|
+
|
|
76
|
+
/** A spec-language or loaded-data object, as opposed to interpreter-made nodes, actions and bindings. */
|
|
77
|
+
export function isPlainObject(value: Value): value is PlainObject {
|
|
78
|
+
return kindOf(value) === "object";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isNode(value: Value): value is UiNode {
|
|
82
|
+
return kindOf(value) === "node";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isAction(value: Value): value is Action {
|
|
86
|
+
return kindOf(value) === "action";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function truthy(value: Value): boolean {
|
|
90
|
+
return value !== null && value !== false && value !== 0 && value !== "";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Non-finite arithmetic results become null. */
|
|
94
|
+
function finite(value: number): number | null {
|
|
95
|
+
return Number.isFinite(value) ? value : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function checkString(text: string, pos: Pos): string {
|
|
99
|
+
if (text.length > MAX_STRING_LENGTH) {
|
|
100
|
+
throw new RuntimeError(pos, `text longer than ${MAX_STRING_LENGTH} characters`);
|
|
101
|
+
}
|
|
102
|
+
return text;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function checkLength<T extends readonly Value[]>(list: T, pos: Pos): T {
|
|
106
|
+
if (list.length > LIMITS.listLength) {
|
|
107
|
+
throw new RuntimeError(pos, `lists are limited to ${LIMITS.listLength} items (got ${list.length})`);
|
|
108
|
+
}
|
|
109
|
+
return list;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Text for `+`, templates and `join`: null is empty, other scalars use String(). */
|
|
113
|
+
function toText(value: Value, pos: Pos): string {
|
|
114
|
+
if (value === null) return "";
|
|
115
|
+
if (typeof value === "object") {
|
|
116
|
+
throw new RuntimeError(pos, `cannot turn ${describe(value)} into text${isList(value) ? '; use .join(", ")' : ""}`);
|
|
117
|
+
}
|
|
118
|
+
return String(value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function compare(op: string, left: number | string, right: number | string): boolean {
|
|
122
|
+
switch (op) {
|
|
123
|
+
case "<":
|
|
124
|
+
return left < right;
|
|
125
|
+
case "<=":
|
|
126
|
+
return left <= right;
|
|
127
|
+
case ">":
|
|
128
|
+
return left > right;
|
|
129
|
+
default:
|
|
130
|
+
return left >= right;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Rounds half away from zero, exact for decimal inputs like 1.005. */
|
|
135
|
+
function roundTo(value: number, digits: number): number {
|
|
136
|
+
const magnitude = Math.abs(value);
|
|
137
|
+
let rounded = Number(`${Math.round(Number(`${magnitude}e${digits}`))}e${-digits}`);
|
|
138
|
+
if (!Number.isFinite(rounded)) rounded = Math.round(magnitude * 10 ** digits) / 10 ** digits;
|
|
139
|
+
return value < 0 ? -rounded : rounded;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `1234.5` → "1,234.5"; fixed `digits`, or up to 2 with trailing zeros trimmed. */
|
|
143
|
+
function fmt(value: number, digits: number | undefined): string {
|
|
144
|
+
let fixed = roundTo(value, digits ?? 2).toFixed(digits ?? 2);
|
|
145
|
+
if (digits === undefined) fixed = fixed.replace(/\.0+$|(\.\d*?)0+$/, "$1");
|
|
146
|
+
const [whole, fraction] = fixed.split(".");
|
|
147
|
+
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
148
|
+
return fraction === undefined ? grouped : `${grouped}.${fraction}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The expression passed for `params[index]`, positionally or by name. */
|
|
152
|
+
function argument(call: CallExpr, params: readonly ParamSpec[], index: number): Expr | undefined {
|
|
153
|
+
return index < call.args.length ? call.args[index] : call.named.find((arg) => arg.name === params[index].name)?.value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface Scope {
|
|
157
|
+
readonly name: string;
|
|
158
|
+
readonly value: Value;
|
|
159
|
+
readonly parent: Scope | null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
class Interpreter {
|
|
163
|
+
readonly diagnostics = new DiagnosticList();
|
|
164
|
+
private steps = 0;
|
|
165
|
+
private nodes = 0;
|
|
166
|
+
private depth = 0;
|
|
167
|
+
/** `#index` per enclosing map iteration, appended to node keys. */
|
|
168
|
+
private suffix = "";
|
|
169
|
+
private readonly cache = new Map<string, { readonly value: Value } | { readonly error: RuntimeError }>();
|
|
170
|
+
|
|
171
|
+
constructor(
|
|
172
|
+
private readonly program: Program,
|
|
173
|
+
private readonly state: UiState,
|
|
174
|
+
private readonly data: ReadonlyMap<string, LoadedData>,
|
|
175
|
+
) {}
|
|
176
|
+
|
|
177
|
+
/** Reports a failure (a load still in flight is none) and restores the evaluation context saved before it; rethrows anything else. */
|
|
178
|
+
recover(error: unknown, depth: number, suffix: string): EvalFailure {
|
|
179
|
+
if (!(error instanceof EvalFailure)) throw error;
|
|
180
|
+
this.depth = depth;
|
|
181
|
+
this.suffix = suffix;
|
|
182
|
+
if (!(error instanceof LoadPending)) this.diagnostics.report(error.pos, error.message);
|
|
183
|
+
return error;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
expr(expr: Expr, scope: Scope | null): Value {
|
|
187
|
+
if (++this.steps > LIMITS.steps) {
|
|
188
|
+
throw new LimitError(expr.pos, `the UI needs more than ${LIMITS.steps} evaluation steps; simplify it`);
|
|
189
|
+
}
|
|
190
|
+
if (++this.depth > MAX_EVAL_DEPTH) throw new LimitError(expr.pos, "expressions are nested too deeply");
|
|
191
|
+
const value = this.compute(expr, scope);
|
|
192
|
+
this.depth--;
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private compute(expr: Expr, scope: Scope | null): Value {
|
|
197
|
+
switch (expr.type) {
|
|
198
|
+
case "literal":
|
|
199
|
+
return expr.value;
|
|
200
|
+
case "template": {
|
|
201
|
+
let text = expr.quasis[0];
|
|
202
|
+
expr.parts.forEach((part, index) => {
|
|
203
|
+
text += toText(this.expr(part, scope), part.pos) + expr.quasis[index + 1];
|
|
204
|
+
});
|
|
205
|
+
return checkString(text, expr.pos);
|
|
206
|
+
}
|
|
207
|
+
case "state":
|
|
208
|
+
return Object.hasOwn(this.state, expr.name) ? this.state[expr.name] : null;
|
|
209
|
+
case "ident":
|
|
210
|
+
for (let current = scope; current !== null; current = current.parent) {
|
|
211
|
+
if (current.name === expr.name) return current.value;
|
|
212
|
+
}
|
|
213
|
+
return this.named(expr.name, expr.pos);
|
|
214
|
+
case "list":
|
|
215
|
+
return checkLength(
|
|
216
|
+
expr.items.map((item) => this.expr(item, scope)),
|
|
217
|
+
expr.pos,
|
|
218
|
+
);
|
|
219
|
+
case "object": {
|
|
220
|
+
const object: Record<string, Value> = {};
|
|
221
|
+
for (const entry of expr.entries) {
|
|
222
|
+
const value = this.expr(entry.value, scope);
|
|
223
|
+
if (entry.key === "kind" && (value === "node" || value === "action" || value === "binding")) {
|
|
224
|
+
throw new RuntimeError(entry.pos, `\`kind: "${value}"\` is reserved; use another key or value`);
|
|
225
|
+
}
|
|
226
|
+
object[entry.key] = value;
|
|
227
|
+
}
|
|
228
|
+
return object;
|
|
229
|
+
}
|
|
230
|
+
case "unary": {
|
|
231
|
+
const operand = this.expr(expr.operand, scope);
|
|
232
|
+
return expr.op === "!" ? !truthy(operand) : finite(-this.number(operand, expr.pos, "`-`"));
|
|
233
|
+
}
|
|
234
|
+
case "binary":
|
|
235
|
+
return this.binary(expr, scope);
|
|
236
|
+
case "conditional":
|
|
237
|
+
return truthy(this.expr(expr.test, scope)) ? this.expr(expr.then, scope) : this.expr(expr.else, scope);
|
|
238
|
+
case "member":
|
|
239
|
+
return this.member(this.expr(expr.object, scope), expr.name, expr.pos);
|
|
240
|
+
case "index":
|
|
241
|
+
return this.index(this.expr(expr.object, scope), this.expr(expr.index, scope), expr.pos);
|
|
242
|
+
case "call": {
|
|
243
|
+
const component = COMPONENT_SPECS.get(expr.name);
|
|
244
|
+
if (component !== undefined) return this.component(expr, component, scope);
|
|
245
|
+
const action = ACTION_SPECS.get(expr.name);
|
|
246
|
+
if (action !== undefined) return this.action(expr, action, scope);
|
|
247
|
+
const own = this.program.named.get(expr.name)?.value;
|
|
248
|
+
if (own?.type === "arrow") return this.callOwn(expr, own, scope);
|
|
249
|
+
return this.callFunction(expr, scope);
|
|
250
|
+
}
|
|
251
|
+
case "method":
|
|
252
|
+
return this.method(expr, scope);
|
|
253
|
+
case "arrow":
|
|
254
|
+
throw new RuntimeError(
|
|
255
|
+
expr.pos,
|
|
256
|
+
"arrow functions can only be declared as a named function (`share = p => ...`) or passed to map, filter, find, some or every",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private named(name: string, pos: Pos): Value {
|
|
262
|
+
const cached = this.cache.get(name);
|
|
263
|
+
if (cached !== undefined) {
|
|
264
|
+
if ("error" in cached) throw cached.error;
|
|
265
|
+
return cached.value;
|
|
266
|
+
}
|
|
267
|
+
const decl = this.program.named.get(name);
|
|
268
|
+
if (decl === undefined) throw new RuntimeError(pos, `\`${name}\` is not defined`);
|
|
269
|
+
if (decl.value.type === "arrow") throw new RuntimeError(pos, `\`${name}\` is a function; call it, as in \`${name}(...)\``);
|
|
270
|
+
// Named values are top-level: their nodes never take the keys of the map that reads them.
|
|
271
|
+
const suffix = this.suffix;
|
|
272
|
+
this.suffix = "";
|
|
273
|
+
try {
|
|
274
|
+
const value = this.expr(decl.value, null);
|
|
275
|
+
this.cache.set(name, { value });
|
|
276
|
+
this.suffix = suffix;
|
|
277
|
+
return value;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error instanceof RuntimeError) this.cache.set(name, { error });
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private number(value: Value, pos: Pos, label: string): number {
|
|
285
|
+
if (typeof value !== "number") throw new RuntimeError(pos, `${label} needs a number, got ${describe(value)}`);
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private text(value: Value, pos: Pos, label: string): string {
|
|
290
|
+
if (typeof value !== "string") throw new RuntimeError(pos, `${label} needs text, got ${describe(value)}`);
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private digits(value: Value, pos: Pos, label: string): number {
|
|
295
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 20) {
|
|
296
|
+
throw new RuntimeError(pos, `${label} digits must be a whole number from 0 to 20`);
|
|
297
|
+
}
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private binary(expr: Extract<Expr, { type: "binary" }>, scope: Scope | null): Value {
|
|
302
|
+
const op = expr.op;
|
|
303
|
+
const left = this.expr(expr.left, scope);
|
|
304
|
+
if (op === "&&") return truthy(left) ? this.expr(expr.right, scope) : left;
|
|
305
|
+
if (op === "||") return truthy(left) ? left : this.expr(expr.right, scope);
|
|
306
|
+
if (op === "??") return left === null ? this.expr(expr.right, scope) : left;
|
|
307
|
+
const right = this.expr(expr.right, scope);
|
|
308
|
+
switch (op) {
|
|
309
|
+
case "==":
|
|
310
|
+
case "===":
|
|
311
|
+
return left === right;
|
|
312
|
+
case "!=":
|
|
313
|
+
case "!==":
|
|
314
|
+
return left !== right;
|
|
315
|
+
case "+":
|
|
316
|
+
if (typeof left === "string" || typeof right === "string") {
|
|
317
|
+
return checkString(toText(left, expr.left.pos) + toText(right, expr.right.pos), expr.pos);
|
|
318
|
+
}
|
|
319
|
+
if (typeof left === "number" && typeof right === "number") return finite(left + right);
|
|
320
|
+
throw new RuntimeError(expr.pos, `\`+\` needs numbers or text, got ${describe(left)} and ${describe(right)}`);
|
|
321
|
+
case "<":
|
|
322
|
+
case "<=":
|
|
323
|
+
case ">":
|
|
324
|
+
case ">=":
|
|
325
|
+
if (typeof left === "number" && typeof right === "number") return compare(op, left, right);
|
|
326
|
+
if (typeof left === "string" && typeof right === "string") return compare(op, left, right);
|
|
327
|
+
throw new RuntimeError(expr.pos, `\`${op}\` compares two numbers or two texts, got ${describe(left)} and ${describe(right)}`);
|
|
328
|
+
}
|
|
329
|
+
const a = this.number(left, expr.left.pos, `\`${op}\``);
|
|
330
|
+
const b = this.number(right, expr.right.pos, `\`${op}\``);
|
|
331
|
+
switch (op) {
|
|
332
|
+
case "-":
|
|
333
|
+
return finite(a - b);
|
|
334
|
+
case "*":
|
|
335
|
+
return finite(a * b);
|
|
336
|
+
case "/":
|
|
337
|
+
return finite(a / b);
|
|
338
|
+
case "%":
|
|
339
|
+
return finite(a % b);
|
|
340
|
+
case "**":
|
|
341
|
+
return finite(a ** b);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private member(object: Value, name: string, pos: Pos): Value {
|
|
346
|
+
if (object === null) return null;
|
|
347
|
+
if (isPlainObject(object)) {
|
|
348
|
+
// Narrowing keeps structurally compatible Action shapes; widen to read by key.
|
|
349
|
+
const record: PlainObject = object;
|
|
350
|
+
return Object.hasOwn(record, name) ? record[name] : null;
|
|
351
|
+
}
|
|
352
|
+
if (isList(object) || typeof object === "string") {
|
|
353
|
+
if (name === "length") return object.length;
|
|
354
|
+
throw new RuntimeError(pos, `${describe(object)} only has \`.length\`; \`.${name}\` does not exist`);
|
|
355
|
+
}
|
|
356
|
+
throw new RuntimeError(pos, `cannot read \`.${name}\` of ${describe(object)}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private index(object: Value, index: Value, pos: Pos): Value {
|
|
360
|
+
if (object === null) return null;
|
|
361
|
+
if (isList(object) || typeof object === "string") {
|
|
362
|
+
const at = this.number(index, pos, "an index");
|
|
363
|
+
return Number.isInteger(at) && at >= 0 && at < object.length ? object[at] : null;
|
|
364
|
+
}
|
|
365
|
+
if (isPlainObject(object)) {
|
|
366
|
+
const record: PlainObject = object;
|
|
367
|
+
const key = this.text(index, pos, "an object key");
|
|
368
|
+
if ((FORBIDDEN_PROPERTIES as readonly string[]).includes(key)) {
|
|
369
|
+
throw new RuntimeError(pos, `\`${key}\` is not accessible`);
|
|
370
|
+
}
|
|
371
|
+
return Object.hasOwn(record, key) ? record[key] : null;
|
|
372
|
+
}
|
|
373
|
+
throw new RuntimeError(pos, `cannot index ${describe(object)}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private node(type: UiNode["type"], key: string, props: Record<string, Value>, pos: Pos): UiNode {
|
|
377
|
+
if (++this.nodes > LIMITS.nodes) throw new LimitError(pos, `the UI has more than ${LIMITS.nodes} components`);
|
|
378
|
+
return { kind: "node", type, key, props };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private component(call: CallExpr, spec: ComponentSpec, scope: Scope | null): UiNode {
|
|
382
|
+
const key = `${call.pos.line}:${call.pos.column}${this.suffix}`;
|
|
383
|
+
// COMPONENT_SPECS only holds catalog names.
|
|
384
|
+
const type = call.name as ComponentName;
|
|
385
|
+
const depth = this.depth;
|
|
386
|
+
const suffix = this.suffix;
|
|
387
|
+
try {
|
|
388
|
+
const props: Record<string, Value> = {};
|
|
389
|
+
spec.params.forEach((param, index) => {
|
|
390
|
+
const arg = argument(call, spec.params, index);
|
|
391
|
+
if (arg === undefined) return;
|
|
392
|
+
const value = this.param(param, arg, scope, `${call.name} ${param.name}`, key);
|
|
393
|
+
if (value !== undefined) props[param.name] = value;
|
|
394
|
+
});
|
|
395
|
+
return this.node(type, key, type === "Snippet" ? this.snippet(props) : props, call.pos);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (!(error instanceof RuntimeError)) throw error;
|
|
398
|
+
const failure = this.recover(error, depth, suffix);
|
|
399
|
+
return failure instanceof LoadPending
|
|
400
|
+
? this.node("Loading", key, { path: failure.path, component: type }, call.pos)
|
|
401
|
+
: this.node("Error", key, { message: failure.message }, call.pos);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Snippet props: the requested range plus its lines from the raw file. A file
|
|
407
|
+
* still loading is `code: null` (the renderer shows a placeholder), and a
|
|
408
|
+
* file or range problem is `error`, never an Error node.
|
|
409
|
+
*/
|
|
410
|
+
private snippet(props: Readonly<Record<string, Value>>): Record<string, Value> {
|
|
411
|
+
// `path` is a required string param and the others optional numbers, so these are the evaluated values.
|
|
412
|
+
const path = typeof props.path === "string" ? props.path : "";
|
|
413
|
+
const from = typeof props.from === "number" ? props.from : null;
|
|
414
|
+
const to = typeof props.to === "number" ? props.to : null;
|
|
415
|
+
const loaded = this.data.get(dataKey({ path, raw: true }));
|
|
416
|
+
const rangeError = snippetRangeError(from, to);
|
|
417
|
+
let shown: { code: string | null; error: string | null };
|
|
418
|
+
if (rangeError !== null) shown = { code: null, error: rangeError };
|
|
419
|
+
else if (loaded === undefined) shown = { code: null, error: null };
|
|
420
|
+
else if (!loaded.ok) shown = { code: null, error: loaded.error };
|
|
421
|
+
else if (typeof loaded.value !== "string") shown = { code: null, error: "not a text file" };
|
|
422
|
+
else shown = snippetLines(loaded.value, from, to);
|
|
423
|
+
return { path, from, to, code: shown.code, error: shown.error, language: languageOf(path) };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Evaluates and normalizes one argument; `undefined` means an optional param passed as null. */
|
|
427
|
+
private param(param: ParamSpec, arg: Expr, scope: Scope | null, label: string, key: string): Value | undefined {
|
|
428
|
+
if (param.kind === "binding") {
|
|
429
|
+
if (arg.type !== "state") throw new RuntimeError(arg.pos, `${label} must be a state variable written as \`$name\``);
|
|
430
|
+
const value = Object.hasOwn(this.state, arg.name) ? this.state[arg.name] : null;
|
|
431
|
+
// Set() or a computed initial value can still give the state the wrong
|
|
432
|
+
// type; a lossless reading keeps the input working, anything else fails.
|
|
433
|
+
const mismatch = stateMismatch(label, param, arg.name, kindOf(value), "holds");
|
|
434
|
+
const coerced = mismatch === null ? value : coerceState(param.holds, value);
|
|
435
|
+
if (coerced === undefined) throw new RuntimeError(arg.pos, mismatch!);
|
|
436
|
+
const binding: Binding = { kind: "binding", name: arg.name, value: coerced };
|
|
437
|
+
return binding;
|
|
438
|
+
}
|
|
439
|
+
const value = this.expr(arg, scope);
|
|
440
|
+
if (param.kind === "nodes") return this.children(value, arg.pos, label, key);
|
|
441
|
+
if (param.kind === "actions") return this.actions(value, arg.pos, label);
|
|
442
|
+
if (value === null) {
|
|
443
|
+
if (param.optional) return undefined;
|
|
444
|
+
if (param.nullable || param.kind === "any") return null;
|
|
445
|
+
}
|
|
446
|
+
switch (param.kind) {
|
|
447
|
+
case "string":
|
|
448
|
+
case "text":
|
|
449
|
+
if (value !== null && typeof value !== "object") return String(value);
|
|
450
|
+
break;
|
|
451
|
+
case "number":
|
|
452
|
+
if (typeof value === "number") return value;
|
|
453
|
+
break;
|
|
454
|
+
case "boolean":
|
|
455
|
+
if (typeof value === "boolean") return value;
|
|
456
|
+
break;
|
|
457
|
+
case "enum": {
|
|
458
|
+
const values = param.values ?? [];
|
|
459
|
+
if (typeof value === "string" && values.includes(value)) return value;
|
|
460
|
+
const got = typeof value === "string" ? JSON.stringify(value) : describe(value);
|
|
461
|
+
throw new RuntimeError(arg.pos, `${label} must be one of ${values.join(", ")}; got ${got}`);
|
|
462
|
+
}
|
|
463
|
+
case "list":
|
|
464
|
+
if (isList(value)) return value;
|
|
465
|
+
break;
|
|
466
|
+
case "series": {
|
|
467
|
+
const isNumbers = (item: Value) => isList(item) && item.every((n) => typeof n === "number" || n === null);
|
|
468
|
+
if (isNumbers(value) || (isPlainObject(value) && Object.values(value).every(isNumbers))) return value;
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
case "any":
|
|
472
|
+
return value;
|
|
473
|
+
}
|
|
474
|
+
throw new RuntimeError(arg.pos, `${label} needs ${EXPECTED[param.kind]}, got ${describe(value)}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Flattens children: strings/numbers become Text nodes, null/false are dropped. */
|
|
478
|
+
children(value: Value, pos: Pos, label: string, key: string): UiNode[] {
|
|
479
|
+
const out: UiNode[] = [];
|
|
480
|
+
const visit = (item: Value): void => {
|
|
481
|
+
if (item === null || item === false) return;
|
|
482
|
+
if (typeof item === "string" || typeof item === "number") {
|
|
483
|
+
out.push(this.node("Text", `${key}.${out.length}`, { text: String(item) }, pos));
|
|
484
|
+
} else if (isList(item)) {
|
|
485
|
+
for (const child of item) visit(child);
|
|
486
|
+
} else if (isNode(item)) {
|
|
487
|
+
out.push(item);
|
|
488
|
+
} else {
|
|
489
|
+
throw new RuntimeError(pos, `${label} must be components or text, got ${describe(item)}`);
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
visit(value);
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private actions(value: Value, pos: Pos, label: string): Action[] {
|
|
497
|
+
const out: Action[] = [];
|
|
498
|
+
const visit = (item: Value): void => {
|
|
499
|
+
if (item === null || item === false) return;
|
|
500
|
+
if (isList(item)) {
|
|
501
|
+
for (const child of item) visit(child);
|
|
502
|
+
} else if (isAction(item)) {
|
|
503
|
+
out.push(item);
|
|
504
|
+
} else {
|
|
505
|
+
throw new RuntimeError(pos, `${label} takes actions such as Send("..."), got ${describe(item)}`);
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
visit(value);
|
|
509
|
+
return out;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private action(call: CallExpr, spec: ComponentSpec, scope: Scope | null): Action {
|
|
513
|
+
// Every action has a required first param; the validator guarantees it is present.
|
|
514
|
+
const first = argument(call, spec.params, 0)!;
|
|
515
|
+
if (call.name === "Set") {
|
|
516
|
+
if (first.type !== "state") throw new RuntimeError(first.pos, "Set needs a state variable written as `$name`");
|
|
517
|
+
const second = argument(call, spec.params, 1)!;
|
|
518
|
+
return { kind: "action", type: "set", name: first.name, value: this.expr(second, scope) };
|
|
519
|
+
}
|
|
520
|
+
const value = this.expr(first, scope);
|
|
521
|
+
if (value === null || typeof value === "object") {
|
|
522
|
+
throw new RuntimeError(first.pos, `${call.name} needs text, got ${describe(value)}`);
|
|
523
|
+
}
|
|
524
|
+
const text = String(value);
|
|
525
|
+
switch (call.name) {
|
|
526
|
+
case "Send":
|
|
527
|
+
return { kind: "action", type: "send", text };
|
|
528
|
+
case "Copy":
|
|
529
|
+
return { kind: "action", type: "copy", text };
|
|
530
|
+
case "OpenUrl":
|
|
531
|
+
if (!/^https?:\/\//i.test(text)) throw new RuntimeError(first.pos, "OpenUrl needs an http:// or https:// URL");
|
|
532
|
+
return { kind: "action", type: "openUrl", url: text };
|
|
533
|
+
}
|
|
534
|
+
throw new RuntimeError(call.pos, `unknown action \`${call.name}\``);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Numbers in a list; nulls are skipped. */
|
|
538
|
+
private numbers(value: Value, pos: Pos, label: string): number[] {
|
|
539
|
+
if (!isList(value)) throw new RuntimeError(pos, `${label} needs a list of numbers, got ${describe(value)}`);
|
|
540
|
+
const out: number[] = [];
|
|
541
|
+
for (const item of value) if (item !== null) out.push(this.number(item, pos, label));
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private callFunction(call: CallExpr, scope: Scope | null): Value {
|
|
546
|
+
const args = call.args.map((arg) => this.expr(arg, scope));
|
|
547
|
+
const name = call.name;
|
|
548
|
+
const numberAt = (index: number) => this.number(args[index], call.args[index].pos, `${name}()`);
|
|
549
|
+
const digitsAt = (index: number) =>
|
|
550
|
+
index < args.length ? this.digits(args[index], call.args[index].pos, `${name}()`) : undefined;
|
|
551
|
+
switch (name) {
|
|
552
|
+
case "round":
|
|
553
|
+
return finite(roundTo(numberAt(0), digitsAt(1) ?? 0));
|
|
554
|
+
case "fmt":
|
|
555
|
+
return fmt(numberAt(0), digitsAt(1));
|
|
556
|
+
case "abs":
|
|
557
|
+
return Math.abs(numberAt(0));
|
|
558
|
+
case "floor":
|
|
559
|
+
return Math.floor(numberAt(0));
|
|
560
|
+
case "ceil":
|
|
561
|
+
return Math.ceil(numberAt(0));
|
|
562
|
+
case "sqrt":
|
|
563
|
+
return finite(Math.sqrt(numberAt(0)));
|
|
564
|
+
case "pow":
|
|
565
|
+
return finite(numberAt(0) ** numberAt(1));
|
|
566
|
+
case "sum":
|
|
567
|
+
return finite(this.numbers(args[0], call.args[0].pos, "sum()").reduce((total, n) => total + n, 0));
|
|
568
|
+
case "min":
|
|
569
|
+
case "max": {
|
|
570
|
+
const values =
|
|
571
|
+
args.length === 1 && isList(args[0])
|
|
572
|
+
? this.numbers(args[0], call.args[0].pos, `${name}()`)
|
|
573
|
+
: args.map((_, index) => numberAt(index));
|
|
574
|
+
if (values.length === 0) return null;
|
|
575
|
+
return name === "min" ? Math.min(...values) : Math.max(...values);
|
|
576
|
+
}
|
|
577
|
+
case "keys":
|
|
578
|
+
case "values":
|
|
579
|
+
case "entries": {
|
|
580
|
+
const object = args[0];
|
|
581
|
+
if (!isPlainObject(object)) throw new RuntimeError(call.args[0].pos, `${name}() needs an object, got ${describe(object)}`);
|
|
582
|
+
const pairs = Object.entries(object);
|
|
583
|
+
return checkLength(
|
|
584
|
+
name === "keys" ? pairs.map(([key]) => key) : name === "values" ? pairs.map(([, value]) => value) : pairs,
|
|
585
|
+
call.pos,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
case "Load": {
|
|
589
|
+
const path = this.text(args[0], call.args[0].pos, "Load()");
|
|
590
|
+
const loaded = this.data.get(dataKey({ path, raw: false }));
|
|
591
|
+
if (loaded === undefined) throw new LoadPending(call.pos, path);
|
|
592
|
+
if (!loaded.ok) throw new RuntimeError(call.pos, `Couldn't load ${path}: ${loaded.error}`);
|
|
593
|
+
return loaded.value;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
throw new RuntimeError(call.pos, `\`${name}\` is not a function`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* A named function (`share = p => ...`). Its body sees its parameters and
|
|
601
|
+
* top-level names only; missing arguments are null. Nodes it makes are keyed
|
|
602
|
+
* by the call site too, so two calls never share component state.
|
|
603
|
+
*/
|
|
604
|
+
private callOwn(call: CallExpr, fn: ArrowExpr, scope: Scope | null): Value {
|
|
605
|
+
const args = call.args.map((arg) => this.expr(arg, scope));
|
|
606
|
+
let inner: Scope | null = null;
|
|
607
|
+
for (const [index, name] of fn.params.entries()) {
|
|
608
|
+
inner = { name, value: index < args.length ? args[index] : null, parent: inner };
|
|
609
|
+
}
|
|
610
|
+
const base = this.suffix;
|
|
611
|
+
this.suffix = `${base}@${call.pos.line}:${call.pos.column}`;
|
|
612
|
+
try {
|
|
613
|
+
return this.expr(fn.body, inner);
|
|
614
|
+
} finally {
|
|
615
|
+
this.suffix = base;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private method(call: Extract<Expr, { type: "method" }>, scope: Scope | null): Value {
|
|
620
|
+
const receiver = this.expr(call.object, scope);
|
|
621
|
+
if (isList(receiver) && LAMBDA_METHODS.has(call.name)) return this.lambdaMethod(receiver, call, scope);
|
|
622
|
+
const args = call.args.map((arg) => this.expr(arg, scope));
|
|
623
|
+
const label = `.${call.name}()`;
|
|
624
|
+
const textAt = (index: number) => this.text(args[index], call.args[index].pos, label);
|
|
625
|
+
const bounds = () => args.map((arg, index) => this.number(arg, call.args[index].pos, label));
|
|
626
|
+
if (isList(receiver)) {
|
|
627
|
+
switch (call.name) {
|
|
628
|
+
case "join":
|
|
629
|
+
return checkString(
|
|
630
|
+
receiver.map((item) => toText(item, call.pos)).join(args.length > 0 ? textAt(0) : ","),
|
|
631
|
+
call.pos,
|
|
632
|
+
);
|
|
633
|
+
case "slice":
|
|
634
|
+
return receiver.slice(...bounds());
|
|
635
|
+
case "includes":
|
|
636
|
+
return receiver.includes(args[0]);
|
|
637
|
+
case "indexOf":
|
|
638
|
+
return receiver.indexOf(args[0]);
|
|
639
|
+
}
|
|
640
|
+
} else if (typeof receiver === "string") {
|
|
641
|
+
switch (call.name) {
|
|
642
|
+
case "includes":
|
|
643
|
+
return receiver.includes(textAt(0));
|
|
644
|
+
case "startsWith":
|
|
645
|
+
return receiver.startsWith(textAt(0));
|
|
646
|
+
case "endsWith":
|
|
647
|
+
return receiver.endsWith(textAt(0));
|
|
648
|
+
case "slice":
|
|
649
|
+
return receiver.slice(...bounds());
|
|
650
|
+
case "split":
|
|
651
|
+
return checkLength(receiver.split(textAt(0)), call.pos);
|
|
652
|
+
case "trim":
|
|
653
|
+
return receiver.trim();
|
|
654
|
+
case "toUpperCase":
|
|
655
|
+
return receiver.toUpperCase();
|
|
656
|
+
case "toLowerCase":
|
|
657
|
+
return receiver.toLowerCase();
|
|
658
|
+
}
|
|
659
|
+
} else if (typeof receiver === "number" && call.name === "toFixed") {
|
|
660
|
+
const digits = args.length > 0 ? this.digits(args[0], call.args[0].pos, label) : 0;
|
|
661
|
+
return roundTo(receiver, digits).toFixed(digits);
|
|
662
|
+
}
|
|
663
|
+
throw new RuntimeError(call.pos, `\`${label}\` is not available on ${describe(receiver)}`);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private lambdaMethod(list: readonly Value[], call: Extract<Expr, { type: "method" }>, scope: Scope | null): Value {
|
|
667
|
+
checkLength(list, call.pos);
|
|
668
|
+
const fn = call.args[0];
|
|
669
|
+
if (fn?.type !== "arrow") throw new RuntimeError(call.pos, `\`.${call.name}()\` takes a function, as in \`x => x.price\``);
|
|
670
|
+
const [itemName, indexName] = fn.params;
|
|
671
|
+
const apply = (item: Value, index: number): Value => {
|
|
672
|
+
let inner = scope;
|
|
673
|
+
if (itemName !== undefined) inner = { name: itemName, value: item, parent: inner };
|
|
674
|
+
if (indexName !== undefined) inner = { name: indexName, value: index, parent: inner };
|
|
675
|
+
return this.expr(fn.body, inner);
|
|
676
|
+
};
|
|
677
|
+
switch (call.name) {
|
|
678
|
+
case "map": {
|
|
679
|
+
const base = this.suffix;
|
|
680
|
+
const out = list.map((item, index) => {
|
|
681
|
+
this.suffix = `${base}#${index}`;
|
|
682
|
+
return apply(item, index);
|
|
683
|
+
});
|
|
684
|
+
this.suffix = base;
|
|
685
|
+
return out;
|
|
686
|
+
}
|
|
687
|
+
case "filter":
|
|
688
|
+
return list.filter((item, index) => truthy(apply(item, index)));
|
|
689
|
+
case "find":
|
|
690
|
+
return list.find((item, index) => truthy(apply(item, index))) ?? null;
|
|
691
|
+
case "some":
|
|
692
|
+
return list.some((item, index) => truthy(apply(item, index)));
|
|
693
|
+
default:
|
|
694
|
+
return list.every((item, index) => truthy(apply(item, index)));
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** `$state` initial values in declaration order; an initializer that fails yields null. */
|
|
700
|
+
export function initialState(program: Program, data = NO_DATA): Record<string, Value> {
|
|
701
|
+
const values: Record<string, Value> = {};
|
|
702
|
+
const interpreter = new Interpreter(program, values, data);
|
|
703
|
+
for (const decl of program.states) {
|
|
704
|
+
try {
|
|
705
|
+
values[decl.name] = interpreter.expr(decl.value, null);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
interpreter.recover(error, 0, "");
|
|
708
|
+
values[decl.name] = null;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return values;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Renders the program's roots against `state`, with `data` holding the files `Load` and `Snippet` read (keyed by `dataKey`). Failures become Error nodes plus diagnostics. */
|
|
715
|
+
export function evaluate(
|
|
716
|
+
program: Program,
|
|
717
|
+
state: UiState,
|
|
718
|
+
data = NO_DATA,
|
|
719
|
+
): { roots: UiNode[]; diagnostics: Diagnostic[] } {
|
|
720
|
+
const interpreter = new Interpreter(program, state, data);
|
|
721
|
+
const roots: UiNode[] = [];
|
|
722
|
+
for (const expr of program.roots) {
|
|
723
|
+
const key = `${expr.pos.line}:${expr.pos.column}`;
|
|
724
|
+
try {
|
|
725
|
+
roots.push(...interpreter.children(interpreter.expr(expr, null), expr.pos, "a bare expression", key));
|
|
726
|
+
} catch (error) {
|
|
727
|
+
const failure = interpreter.recover(error, 0, "");
|
|
728
|
+
roots.push(
|
|
729
|
+
failure instanceof LoadPending
|
|
730
|
+
? { kind: "node", type: "Loading", key, props: { path: failure.path, component: null } }
|
|
731
|
+
: { kind: "node", type: "Error", key, props: { message: failure.message } },
|
|
732
|
+
);
|
|
733
|
+
if (failure instanceof LimitError) break;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return { roots, diagnostics: interpreter.diagnostics.items };
|
|
737
|
+
}
|